@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
package/lib/fact-store.js CHANGED
@@ -19,6 +19,19 @@
19
19
  * 与 M-06 可读投影 / sidecar 索引的衔接(输出侧,预留):
20
20
  * - snapshot() 导出全部 fact 供 M-06 投影; Host 接线点=fact-store-pre 的 store.put 回调。
21
21
  *
22
+ * ★ B-3(2026-09-22) 事实性入口判据(保守版):
23
+ * - 入口只拒「明显不是事实陈述」的 5 种形态(纯空白/纯标点表情/整句疑问/整句指令/散文残片过短),
24
+ * 判据函数 looksFactStatementPre(text) / looksFactCandidatePre(cand) 单独导出;
25
+ * 命中返回 {ok:false, reason:'not-a-fact-statement', code:'factness-*', message:<中文人话>},
26
+ * 并计入 stats.factnessRejected 与 getLastFactnessReject(),**不静默丢弃**。
27
+ * - 边界(用户方针:宁可放过不可误杀):**不看**置信度、不看有没有「可能/大概」这类推测词;
28
+ * 凡是「看起来不确定」一律放行,只按上面 5 种形态判。
29
+ *
30
+ * ★ B-4(2026-09-22) epistemicStatus 默认值:
31
+ * - 写入时补默认(调用方显式给了合法值就尊重):来源=模型推断 → 'observation'(推断),
32
+ * 来源=用户明确陈述(sourceKind=explicit / sourceClass=user-memory)→ 'fact'(明说);
33
+ * restore() 对旧快照按同一规则 backfill,还原后不留 undefined。
34
+ *
22
35
  * 全部同输入确定; UTF-8 无 BOM。
23
36
  */
24
37
  import { createHash } from 'node:crypto'
@@ -123,6 +136,181 @@ export function isFactConflict(a, b) {
123
136
  return ao !== bo
124
137
  }
125
138
 
139
+ // ========== B-3 事实性入口判据(保守版:只拒「明显不是事实陈述」的形态) ==========
140
+
141
+ /** 陈述文本最小长度(去空白后);低于此值且呈散文形态才判为碎片。 */
142
+ export const FACT_MIN_STATEMENT_CHARS_V1 = 6
143
+
144
+ /** 判据子类码(冻结;前端/工具按它给中文说明,不要按 code 反推语义)。 */
145
+ export const FACTNESS_CODES_V1 = Object.freeze([
146
+ 'factness-empty', 'factness-symbol-only', 'factness-question', 'factness-imperative', 'factness-too-short',
147
+ ])
148
+
149
+ /** 子类码 → 中文人话说明:既让模型知道「怎么改写成合法陈述」,也让人不看代码就看得懂。 */
150
+ export const FACTNESS_MESSAGES_V1 = Object.freeze({
151
+ 'factness-empty': '内容为空白(没有任何字符),不是一条事实陈述。请写成「主体 + 谓词 (+ 宾语)」再入库,例如 主体=「项目」、谓词=「构建工具」、宾语=「esbuild」。',
152
+ 'factness-symbol-only': '内容只有标点、符号或表情,没有可读的文字或数字,不是一条事实陈述。请补上文字描述再入库,例如 主体=「项目」、谓词=「状态」、宾语=「进行中」。',
153
+ 'factness-question': '内容是一个疑问句(以「?」或「?」结尾,且整句没有陈述成分),不是一条事实陈述。请先把答案写成陈述句再入库,例如写「端口 默认值 3080」,而不是「端口是多少?」。',
154
+ 'factness-imperative': '内容是一条指令或请求(以「帮我」「请」「把…改成」这类祈使开头,且整句没有陈述成分),不是一条事实陈述。请改写成已发生或已成立的陈述再入库,例如「项目 构建工具 esbuild」。',
155
+ 'factness-too-short': '内容过短(去掉空白后不足 6 个字符),像对话残片而不是完整陈述。请补全成「主体 + 谓词 (+ 宾语)」的陈述再入库。',
156
+ })
157
+
158
+ /** 判据子类码 → stats 计数键(诊断面可分别读数)。 */
159
+ const FACTNESS_STAT_KEYS_V1 = Object.freeze({
160
+ 'factness-empty': 'factnessEmpty',
161
+ 'factness-symbol-only': 'factnessSymbolOnly',
162
+ 'factness-question': 'factnessQuestion',
163
+ 'factness-imperative': 'factnessImperative',
164
+ 'factness-too-short': 'factnessTooShort',
165
+ })
166
+
167
+ /** 认识论状态强弱序:推断(observation) < 明确陈述(fact/directive)。 */
168
+ const FACT_EPISTEMIC_RANK_V1 = Object.freeze({ observation: 1, fact: 2, directive: 2 })
169
+
170
+ /** 有词形字符(拉丁/数字/CJK/假名/谚文/西里尔/希腊/全角字母数字)才算「有内容」。 */
171
+ const FACT_WORD_CHAR_RE_V1 = /[0-9A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u04FF\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A]/
172
+
173
+ /** 句读/符号:字段里出现即视为「散文残片」,而不是结构化词条(空白 + ASCII 标点 + 中西标点/表情区)。 */
174
+ const FACT_PROSE_PUNCT_RE_V1 = /[\s!"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~\u2000-\u206F\u2190-\u2BFF\u3000-\u303F\uD800-\uDFFF\uFE10-\uFE1F\uFE30-\uFE6F\uFF01-\uFF0F\uFF1A-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65]/
175
+
176
+ /** 只含中日韩文字(用于「英文祈使开头只在纯英文文本上生效」这条护栏)。 */
177
+ const FACT_CJK_CHAR_RE_V1 = /[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/
178
+
179
+ /** 疑问尾:「?」或「?」结尾(且整句无陈述成分时判为疑问)。 */
180
+ const FACT_QUESTION_TAIL_RE_V1 = /[??]\s*$/
181
+
182
+ /** 陈述成分标记:出现任一即认为「有主谓/已成立」⇒ 一律放行(宁可放过不可误杀)。 */
183
+ const FACT_CLAUSE_MARKER_RE_V1 = /(是|为|在|有|了|被|过|会|能|需|由|得|着|将|已|后|前|时|与|和|及|即|属|含|指|表示|代表|完成|支持|启用|关闭|默认|等于|位于|\b(is|are|was|were|be|been|has|have|had|will|would|can|could|must|should|equals?|means|contains|supports|defaults?|uses?)\b)/i
184
+
185
+ /** 疑问/系词复合:先摘掉,免得「为什么」里的「为」被误当成陈述标记。 */
186
+ const FACT_CLAUSE_STRIP_RE_V1 = /(为什么|为何|什么样|什么时候|因为|认为|以为|视为|作为|属于|分为)/g
187
+
188
+ /** 祈使开头:这些形态**且整句无陈述成分**时才判为指令(请求式英文词始终生效)。 */
189
+ const FACT_IMPERATIVE_OPENERS_V1 = Object.freeze([
190
+ /^(帮我|帮忙|帮个忙|麻烦|拜托|烦请|给我|替我|请帮|请把|请将|请给|请写|请记|请查|请修|请改|请加|请添|请看|请做)/,
191
+ /^把[^,,。.;;??!!]{0,12}(改成|改为|换成|换为|调成|调为|设置为|设为|调整|删除|删掉|去掉|加上|添加|补上|写上|写成)/,
192
+ /^(please|pls|plz|help me|can you|could you|would you|i need you to|i want you to|go ahead and|make sure|remember to|do not|don't)\b/i,
193
+ ])
194
+
195
+ /** 裸动词祈使开头:**仅纯英文文本**才生效(中式事实句「Run 命令:…」不得被误杀)。 */
196
+ const FACT_IMP_VERB_OPENERS_EN_V1 = /^(add|change|update|fix|remove|delete|rename|move|run|write|create|implement|refactor|ensure|revert|bump|install)\b/i
197
+
198
+ /** 去掉全部空白(长度与词形判定用)。 */
199
+ function factFlatPre(text) {
200
+ return String(text == null ? '' : text).replace(/\s+/g, '')
201
+ }
202
+
203
+ /** 整句是否带陈述成分(带 ⇒ 放行信号)。 */
204
+ function factHasClausePre(text) {
205
+ const s = String(text == null ? '' : text).replace(FACT_CLAUSE_STRIP_RE_V1, ' ')
206
+ return FACT_CLAUSE_MARKER_RE_V1.test(s)
207
+ }
208
+
209
+ /** 整句是否「只有疑问」:以 ?/? 结尾且没有陈述成分。 */
210
+ function factQuestionOnlyPre(text) {
211
+ const s = String(text == null ? '' : text).trim()
212
+ if (!FACT_QUESTION_TAIL_RE_V1.test(s)) return false
213
+ return !factHasClausePre(s)
214
+ }
215
+
216
+ /** 整句是否「只有指令」:祈使形态开头且没有陈述成分。 */
217
+ function factImperativeOnlyPre(text) {
218
+ const s = String(text == null ? '' : text).trim()
219
+ if (!s) return false
220
+ if (FACT_IMPERATIVE_OPENERS_V1.some((re) => re.test(s))) return !factHasClausePre(s)
221
+ if (!FACT_CJK_CHAR_RE_V1.test(s) && FACT_IMP_VERB_OPENERS_EN_V1.test(s)) return !factHasClausePre(s)
222
+ return false
223
+ }
224
+
225
+ /** 单个字段是否「词条形态」(不含空白与标点)。 */
226
+ function factTermLikeFieldPre(v) {
227
+ if (typeof v !== 'string') return true
228
+ const s = v.trim()
229
+ if (!s) return true
230
+ return !FACT_PROSE_PUNCT_RE_V1.test(s)
231
+ }
232
+
233
+ /** 三元组是否「结构化短词条」(每个字段都是词条形态)。 */
234
+ function factStructuredTuplePre(cand) {
235
+ if (!cand || typeof cand !== 'object') return false
236
+ return ['subject', 'predicate', 'object'].every((k) => factTermLikeFieldPre(cand[k]))
237
+ }
238
+
239
+ /** 组装一个拒绝判定(code + 中文人话 message)。 */
240
+ function factnessVerdictPre(code) {
241
+ return { ok: false, code, message: FACTNESS_MESSAGES_V1[code] || code }
242
+ }
243
+
244
+ /**
245
+ * ★ B-3 判据(纯函数,导出供宿主与测试复用):判断**一段文本**是不是事实陈述形态。
246
+ * 只拒任务枚举的 5 种形态;**不看**置信度、不看有没有「可能/大概」这类推测词 ——
247
+ * 凡是「看起来不确定」的一律放行(用户方针:宁可放过不可误杀)。
248
+ * @param {string} text
249
+ * @returns {{ok:true}|{ok:false, code:string, message:string}}
250
+ */
251
+ export function looksFactStatementPre(text) {
252
+ const raw = String(text == null ? '' : text).trim()
253
+ const flat = raw.replace(/\s+/g, '')
254
+ if (!flat) return factnessVerdictPre('factness-empty')
255
+ if (!FACT_WORD_CHAR_RE_V1.test(flat)) return factnessVerdictPre('factness-symbol-only')
256
+ if (factQuestionOnlyPre(raw)) return factnessVerdictPre('factness-question')
257
+ if (factImperativeOnlyPre(raw)) return factnessVerdictPre('factness-imperative')
258
+ if (flat.length < FACT_MIN_STATEMENT_CHARS_V1) return factnessVerdictPre('factness-too-short')
259
+ return { ok: true }
260
+ }
261
+
262
+ /** 候选三元组 → 陈述文本(subject + predicate + object,去空段,单空格相连)。 */
263
+ export function factStatementTextPre(cand) {
264
+ if (!cand || typeof cand !== 'object') return ''
265
+ return ['subject', 'predicate', 'object']
266
+ .map((k) => (typeof cand[k] === 'string' ? cand[k].trim() : ''))
267
+ .filter((s) => s !== '')
268
+ .join(' ')
269
+ }
270
+
271
+ /**
272
+ * ★ B-3 判据(候选级 = 入库闸门用):对 subject 与整条陈述文本各判一次。
273
+ *
274
+ * 两处「宁可放过」的收窄(实测换来的,别再放宽——放宽即误杀):
275
+ * ① 结构化短词条豁免:三个字段都是不含空白/标点的词条形态时(如 'A'/'B'/'c'、'临时'/'任务'/'x'),
276
+ * 视为机器构造的结构化元组而非对话残片,**不套用长度下限**。宿主与既有套件大量使用这类短元组。
277
+ * ② 词条形态的**短主体**不按碎片判:subject 本身不含空白/标点时(如「部署流程」),它只是短词条,
278
+ * 不是残片 —— 残片的判据是「有散文形态(含空格/标点)且长度不足」。实测反例:
279
+ * subject=「部署流程」/predicate=「走 pnpm」/object=「build 后 rsync」这类合法事实,
280
+ * 若按「subject < 6 字符就拒」会被整条误杀(smoke-test-m85-storage-manage H3 实测变红)。
281
+ * ⇒ 长度判据实际只对**有散文形态**的残片生效。
282
+ * @returns {{ok:true}|{ok:false, code:string, message:string}}
283
+ */
284
+ export function looksFactCandidatePre(cand) {
285
+ if (!cand || typeof cand !== 'object') return factnessVerdictPre('factness-empty')
286
+ const subject = typeof cand.subject === 'string' ? cand.subject.trim() : ''
287
+ const structured = factStructuredTuplePre(cand)
288
+ for (const text of [subject, factStatementTextPre(cand)]) {
289
+ const verdict = looksFactStatementPre(text)
290
+ if (verdict.ok) continue
291
+ if (verdict.code === 'factness-too-short') {
292
+ if (structured) continue
293
+ if (text === subject && factTermLikeFieldPre(subject)) continue
294
+ }
295
+ return verdict
296
+ }
297
+ return { ok: true }
298
+ }
299
+
300
+ /**
301
+ * ★ B-4 默认认识论状态(导出供宿主/测试复用):
302
+ * 调用方显式给了合法值就尊重(本函数只在缺省时用);
303
+ * 来源 = 用户明确陈述(sourceKind='explicit' / sourceClass='user-memory' / origin='user')→ 'fact'(明说);
304
+ * 其余(模型推断、自动沉淀、judgement 候选)→ 'observation'(推断)。
305
+ * ⚠️ 取值必须落在冻结枚举 FACT_EPISTEMIC_STATUSES_V1 内 —— 写 'inference'/'explicit'
306
+ * 这类枚举外的字面量会被 validateFactPre 判非法并**整条拒收**,不能用。
307
+ */
308
+ export function defaultEpistemicStatusPre(cand) {
309
+ if (!cand || typeof cand !== 'object') return 'observation'
310
+ const explicit = cand.sourceKind === 'explicit' || cand.sourceClass === 'user-memory' || cand.origin === 'user'
311
+ return explicit ? 'fact' : 'observation'
312
+ }
313
+
126
314
  /**
127
315
  * 主状态机工厂。
128
316
  *
@@ -140,7 +328,12 @@ export function createFactStorePre(opts = {}) {
140
328
  const stats = {
141
329
  upserts: 0, created: 0, merged: 0, superseded: 0, conflictAdded: 0,
142
330
  inferenceBlocked: 0, expiredIgnored: 0, revoked: 0,
331
+ // ★ B-3(2026-09-22) 事实性入口判据的诊断计数(全部数值:clear() 按现有键遍历归零,不走嵌套对象)
332
+ factnessRejected: 0, factnessEmpty: 0, factnessSymbolOnly: 0,
333
+ factnessQuestion: 0, factnessImperative: 0, factnessTooShort: 0,
143
334
  }
335
+ // ★ B-3:最近一次被事实性判据拒绝的候选(code + 中文人话 + 被判文本 + 时间),供诊断面展示
336
+ let lastFactnessReject = null
144
337
  const io = opts.io || { save() {}, load() { return [] }, clear() {} }
145
338
  const nowFn = typeof opts.now === 'function' ? opts.now : () => Date.now()
146
339
  const idFn = typeof opts.factId === 'function' ? opts.factId : defaultFactId
@@ -165,15 +358,99 @@ export function createFactStorePre(opts = {}) {
165
358
  return facts.find((f) => f.scope === scope && f.subject === subject && f.predicate === predicate && !f.revoked && !isExpired(f, nowFn()))
166
359
  }
167
360
 
361
+ /** A-3 修复(2026-09-21):按 factId 查记录(含 revoked),不按 subject/predicate 过滤。
362
+ * 用于「同一元组被撤销后重新 upsert」时识别**已存在的同 id 记录**:
363
+ * 确定性 factId 只由 (scope,subject,predicate,object) 派生,不看 revoked 状态
364
+ * ⇒ 撤销后重新成立会算出**同一个 factId**,旧实现直接 push 出第二条主键重复记录。
365
+ * 宿主 index.js:9299 用 factId 做写回去重键(`hubFlushState.flushed[fact.factId]`)
366
+ * ⇒ 重复记录里的新事实会被当成"已处理"永久跳过。 */
367
+ function findById(factId) {
368
+ return facts.find((f) => f.factId === factId)
369
+ }
370
+
371
+ /**
372
+ * ★ P2-2 修复(2026-09-21):查询返回**深一层副本**。
373
+ * 旧实现 `{ ...f }` 是浅拷贝 ⇒ `provenance` 数组与库内对象**同引用**,
374
+ * 调用方 `get(...).provenance.push(x)` 会**直接改脏已入账记录**(且绕过 persist/统计)。
375
+ * 与 :234 冲突快照的既有做法(数组副本)保持一致。
376
+ */
377
+ function cloneFact(f) {
378
+ if (!f) return f
379
+ return { ...f, provenance: Array.isArray(f.provenance) ? [...f.provenance] : f.provenance }
380
+ }
381
+
382
+ /**
383
+ * 把候选合并进一条**已存在**的记录(原 upsert 末尾的 merge 分支,提为共用函数)。
384
+ * 语义与改动前逐字节一致:用户声明提升 sourceKind/confidence、追加 provenance、
385
+ * 回填 ingestedAt、confirmedAt 更新为本次确认时间、M8-1 可选元数据加法性透传。
386
+ * 复用于两处:① 已有且不冲突的常规合并;② A-3 同 factId 命中(撤销后重新成立)的复活合并。
387
+ * @returns {{ok:boolean, outcome:string, fact:object}}
388
+ */
389
+ function mergeInto(prev, c, now) {
390
+ if (c.sourceKind === 'explicit' || prev.sourceKind !== 'explicit') {
391
+ prev.sourceKind = c.sourceKind === 'explicit' ? 'explicit' : prev.sourceKind
392
+ }
393
+ if (c.sourceClass) prev.sourceClass = c.sourceClass
394
+ if (c.provenance && c.provenance.length) {
395
+ const seen = new Set(prev.provenance)
396
+ for (const s of c.provenance) if (!seen.has(s)) prev.provenance.push(s)
397
+ }
398
+ if (c.confidence !== undefined && c.confidence !== null) prev.confidence = c.confidence
399
+ // M8-1 向后兼容回填(须在 confirmedAt 更新前执行):旧记录(无 ingestedAt)首次合并时补齐,
400
+ // 取其原始确认时间(=首次入库);不改变其余合并语义
401
+ if (prev.ingestedAt === undefined && Number.isFinite(prev.confirmedAt)) prev.ingestedAt = prev.confirmedAt
402
+ prev.confirmedAt = now // 合并视为重新确认
403
+ // M8-1 可选元数据透传:重新陈述时更新发生/陈述时间与认识论状态/趋势(候选提供才写,加法性)
404
+ if (c.occurredAt !== undefined) prev.occurredAt = c.occurredAt
405
+ if (c.mentionedAt !== undefined) prev.mentionedAt = c.mentionedAt
406
+ if (c.epistemicStatus !== undefined) {
407
+ // ★ B-4(2026-09-22):认识论状态跟随候选(入口 :~250 已补默认值),但**降级**要挡住 ——
408
+ // 用户明确陈述(fact/directive)不得被后续模型推断(observation)覆盖(与上面 sourceKind 的提升同向)。
409
+ const prevRank = FACT_EPISTEMIC_RANK_V1[prev.epistemicStatus] || 0
410
+ const candRank = FACT_EPISTEMIC_RANK_V1[c.epistemicStatus] || 0
411
+ if (prev.epistemicStatus === undefined || c.sourceKind === 'explicit' || candRank > prevRank) {
412
+ prev.epistemicStatus = c.epistemicStatus
413
+ }
414
+ }
415
+ if (c.trend !== undefined) prev.trend = c.trend
416
+ if (c.ttl !== undefined) prev.ttl = c.ttl
417
+ stats.merged++
418
+ // ★ A-8:落盘结果向上透传(persisted=false 时调用方不得把该条当"已处理")
419
+ const pr = persist()
420
+ return pr.ok
421
+ ? { ok: true, outcome: 'merged', fact: prev, persisted: true }
422
+ : { ok: true, outcome: 'merged', fact: prev, persisted: false, persistError: pr.error }
423
+ }
424
+
168
425
  // ---- 核心 upsert(M-03 元代码逐行) ----
169
426
  function upsert(cand) {
170
427
  if (disposed) return { ok: false, reason: 'disposed', outcome: 'disposed' }
171
428
  stats.upserts++
172
429
  const v = validateFactCandidatePre(cand)
173
430
  if (!v.ok) return { ok: false, reason: v.reason, outcome: 'invalid' }
174
- const c = v.candidate
431
+ // ★ B-4(2026-09-22):候选先做**浅拷贝**再补 epistemicStatus 默认值 ——
432
+ // 不改调用方对象(validateFactCandidatePre 返回的 candidate 就是原引用);
433
+ // 调用方显式给了合法值就尊重(上面 validate 已保证枚举合法),没给则按来源判。
434
+ const c = { ...v.candidate }
435
+ if (c.epistemicStatus === undefined) c.epistemicStatus = defaultEpistemicStatusPre(c)
175
436
  const now = nowFn()
176
437
 
438
+ // ★ B-3(2026-09-22) 事实性入口判据(保守版):只拒「明显不是事实陈述」的形态,
439
+ // 命中即**拒绝入库**,并把结构化原因(code + 中文人话)返回给调用方、
440
+ // 计入 stats.factnessRejected 与 lastFactnessReject(getLastFactnessReject 可读),**不静默丢弃**。
441
+ // 顺序:结构化校验(validate)在前 —— 形状非法仍报 'invalid:*';本闸门只判形态。
442
+ const factness = looksFactCandidatePre(c)
443
+ if (!factness.ok) {
444
+ stats.factnessRejected++
445
+ const statKey = FACTNESS_STAT_KEYS_V1[factness.code]
446
+ if (statKey) stats[statKey]++
447
+ lastFactnessReject = { code: factness.code, message: factness.message, text: factStatementTextPre(c), at: now }
448
+ return {
449
+ ok: false, reason: 'not-a-fact-statement', outcome: 'not-a-fact-statement',
450
+ code: factness.code, message: factness.message,
451
+ }
452
+ }
453
+
177
454
  // 1. 冲突检测: 同 subject+predicate+scope 但 object 不同
178
455
  const existing = findSubjectPredicate(c.scope, c.subject, c.predicate)
179
456
  if (existing && isFactConflict(existing, c)) {
@@ -181,10 +458,17 @@ export function createFactStorePre(opts = {}) {
181
458
  // conflictId 必须唯一:同一候选值反复出现时,每次冲突都是独立待决事件。
182
459
  // 用 subject+predicate+object+序号+detectedAt 派生,保证可被逐个 resolve。
183
460
  conflictSeq++
461
+ // ★ 登记**检测时快照**而非活引用(2026-09-19 上游 PR #80 第 3 项 / issue #67 同步落地):
462
+ // 旧实现 `left: existing` 持 store 内活对象引用 ⇒ 后续 merge 会**原地改写** existing.confidence
463
+ // 并对 existing.provenance **数组原地 push** ⇒ 已展示/已落盘(facts.json)的冲突左侧
464
+ // ≠ 检测时的值 ⇒ 审计面失真("当时判定冲突的两个值"被事后改写)。
465
+ // 故此处取快照,provenance 额外做数组副本。
184
466
  conflicts.push({
185
467
  conflictId: defaultFactId(c.scope, c.subject, c.predicate, c.object) + '_conflict_' + conflictSeq + '_' + String(now),
186
468
  scope: c.scope, subject: c.subject, predicate: c.predicate,
187
- left: existing, right: c, detectedAt: now, resolved: false,
469
+ left: { ...existing, provenance: Array.isArray(existing.provenance) ? [...existing.provenance] : existing.provenance },
470
+ right: { ...c, provenance: Array.isArray(c.provenance) ? [...c.provenance] : c.provenance },
471
+ detectedAt: now, resolved: false,
188
472
  })
189
473
  void persist() // 冲突集是重要状态,必须落盘(不持久化会丢失待决冲突)
190
474
  return { ok: true, outcome: 'conflict-added', conflict: conflicts[conflicts.length - 1], existing }
@@ -198,8 +482,28 @@ export function createFactStorePre(opts = {}) {
198
482
 
199
483
  // 3. 新建 / 合并 / 取代
200
484
  if (!existing) {
485
+ // ★ A-3 修复(2026-09-21):factId 是**主键**(确定性哈希只看 scope+subject+predicate+object,
486
+ // 不看 revoked),创建前必须先查同 id 是否已存在(含已撤销/已过期记录)。
487
+ // 命中 = 「同一元组曾被撤销后重新成立」⇒ 走**复活 + merge**:
488
+ // 把旧记录的 revoked 置回 false、更新 confirmedAt、合并 provenance,**绝不 push 第二条**。
489
+ // 为什么是硬 bug:宿主 index.js:9299 用 `hubFlushState.flushed[fact.factId]` 做写回去重键
490
+ // ⇒ 重复主键下,重新成立的新事实会被当成"已处理"永久跳过。
491
+ const newFactId = idFn(c.scope, c.subject, c.predicate, c.object)
492
+ const dup = findById(newFactId)
493
+ if (dup) {
494
+ const revived = dup.revoked === true || isExpired(dup, now)
495
+ dup.revoked = false
496
+ // 撤销痕迹(revokedAt/revokeReason)属于历史审计信息,复活后必须清掉——
497
+ // 否则「已撤销时间」会残留在一条非撤销记录上,审计面自相矛盾。
498
+ delete dup.revokedAt
499
+ delete dup.revokeReason
500
+ // 注意:stats.revoked 是**累计撤销事件数**(与 superseded/created 同为事件计数器),
501
+ // 复活不清减它 —— 保持单调,不被误读成"当前撤销条数"。
502
+ const rr = mergeInto(dup, c, now)
503
+ return { ...rr, revived, outcome: 'merged', reason: revived ? 'revived-duplicate-id' : 'duplicate-id-merged' }
504
+ }
201
505
  const fact = {
202
- factId: idFn(c.scope, c.subject, c.predicate, c.object),
506
+ factId: newFactId,
203
507
  scope: c.scope, subject: c.subject, predicate: c.predicate,
204
508
  object: c.object === undefined ? null : c.object,
205
509
  sourceKind: c.sourceKind,
@@ -212,41 +516,23 @@ export function createFactStorePre(opts = {}) {
212
516
  ingestedAt: now,
213
517
  occurredAt: c.occurredAt,
214
518
  mentionedAt: c.mentionedAt,
215
- ...(c.epistemicStatus !== undefined ? { epistemicStatus: c.epistemicStatus } : {}),
519
+ // ★ B-4(2026-09-22):epistemicStatus **必填**(c 已在入口补默认值,调用方显式值原样保留)——
520
+ // 不留 undefined,前端/面板才能据此区分「模型推断」(observation) 与「用户明说」(fact)。
521
+ epistemicStatus: c.epistemicStatus,
216
522
  ...(c.trend !== undefined ? { trend: c.trend } : {}),
217
523
  }
218
524
  const fv = validateFactPre(fact)
219
525
  if (!fv.ok) return { ok: false, reason: 'fact-invalid:' + fv.reason, outcome: 'invalid' }
220
526
  facts.push(fv.fact)
221
527
  stats.created++
222
- void persist()
223
- return { ok: true, outcome: 'created', fact: fv.fact }
528
+ const pr = persist()
529
+ return pr.ok
530
+ ? { ok: true, outcome: 'created', fact: fv.fact, persisted: true }
531
+ : { ok: true, outcome: 'created', fact: fv.fact, persisted: false, persistError: pr.error }
224
532
  }
225
533
 
226
534
  // 已有且不冲突: 合并(用户声明提升 sourceKind/confidence, 追加 provenance)
227
- const prev = existing
228
- if (c.sourceKind === 'explicit' || prev.sourceKind !== 'explicit') {
229
- prev.sourceKind = c.sourceKind === 'explicit' ? 'explicit' : prev.sourceKind
230
- }
231
- if (c.sourceClass) prev.sourceClass = c.sourceClass
232
- if (c.provenance && c.provenance.length) {
233
- const seen = new Set(prev.provenance)
234
- for (const s of c.provenance) if (!seen.has(s)) prev.provenance.push(s)
235
- }
236
- if (c.confidence !== undefined && c.confidence !== null) prev.confidence = c.confidence
237
- // M8-1 向后兼容回填(须在 confirmedAt 更新前执行):旧记录(无 ingestedAt)首次合并时补齐,
238
- // 取其原始确认时间(=首次入库);不改变其余合并语义
239
- if (prev.ingestedAt === undefined && Number.isFinite(prev.confirmedAt)) prev.ingestedAt = prev.confirmedAt
240
- prev.confirmedAt = now // 合并视为重新确认
241
- // M8-1 可选元数据透传:重新陈述时更新发生/陈述时间与认识论状态/趋势(候选提供才写,加法性)
242
- if (c.occurredAt !== undefined) prev.occurredAt = c.occurredAt
243
- if (c.mentionedAt !== undefined) prev.mentionedAt = c.mentionedAt
244
- if (c.epistemicStatus !== undefined) prev.epistemicStatus = c.epistemicStatus
245
- if (c.trend !== undefined) prev.trend = c.trend
246
- if (c.ttl !== undefined) prev.ttl = c.ttl
247
- stats.merged++
248
- void persist()
249
- return { ok: true, outcome: 'merged', fact: prev }
535
+ return mergeInto(existing, c, now)
250
536
  }
251
537
 
252
538
  /**
@@ -263,6 +549,11 @@ export function createFactStorePre(opts = {}) {
263
549
  existing.revoked = true
264
550
  existing.revokedAt = nowFn()
265
551
  stats.revoked++
552
+ // ★ P2-1 修复(2026-09-21):`stats.superseded` 此前是**死计数器**——
553
+ // 声明在 stats(:141) 且被诊断面读取,但全文件没有任何一处自增(只有 revoked++)。
554
+ // ⇒ supersede 次数恒为 0,「取代 vs 级联撤销」在读数上无法区分。
555
+ // revokeBySource 走的是另一种语义(源删除级联),不在此自增,两计数分工保持可辨。
556
+ stats.superseded++
266
557
  }
267
558
  return upsert(c)
268
559
  }
@@ -296,7 +587,7 @@ export function createFactStorePre(opts = {}) {
296
587
  function get(scope, subject, predicate) {
297
588
  if (disposed) return null
298
589
  const f = findSubjectPredicate(scope, subject, predicate)
299
- return f ? { ...f } : null // 返回副本, 防外部改内部态
590
+ return f ? cloneFact(f) : null // 返回副本(含数组副本), 防外部改内部态
300
591
  }
301
592
  function query(q = {}) {
302
593
  if (disposed) return []
@@ -308,7 +599,7 @@ export function createFactStorePre(opts = {}) {
308
599
  (q.scope === undefined || f.scope === q.scope) &&
309
600
  (q.subject === undefined || f.subject === q.subject) &&
310
601
  (q.predicate === undefined || f.predicate === q.predicate))
311
- .map((f) => ({ ...f }))
602
+ .map(cloneFact)
312
603
  }
313
604
  function conflictsList() {
314
605
  return conflicts.map((c) => ({ ...c }))
@@ -356,47 +647,191 @@ export function createFactStorePre(opts = {}) {
356
647
  }
357
648
 
358
649
  // ---- 持久化(可注入 IO) ----
650
+ /**
651
+ * ★ A-8 修复(2026-09-21):旧实现 `try { io.save(snapshot()) } catch (_) {}` **吞掉落盘失败** ——
652
+ * 调用方看到 ok:true、宿主把该条标进 `hubFlushState.flushed`,但实际上磁盘没写上,
653
+ * 该条**永不重写**(静默数据丢失,且无任何可观察痕迹)。
654
+ * 现改为:① 返回结构化结果 `{ok, error?}` 给调用方; ② 失败时记进模块内可读状态
655
+ * `lastPersistError`(经 `getLastPersistError()` 暴露),至少留下可观察痕迹。
656
+ * 不引入任何新 import —— 本模块保持纯核心零依赖。
657
+ * @returns {{ok:boolean, error?:string}}
658
+ */
659
+ let lastPersistError = null
660
+
661
+ /**
662
+ * ★#110(2026-09-22,用户拍板口径):**保留上限 + 有序淘汰**。
663
+ *
664
+ * 背景:`snapshot({includeRevoked:true})` 会把**已撤销**的记录一起留在快照里 ⇒ facts.json 只增不减。
665
+ * 用户口径(2026-09-22 原话要点):默认上限 1000;以**更新**的为主;**撤销的优先清掉**;
666
+ * 「有些比较老但是比较重要的」要再考虑 ⇒ 重要项**最后**才淘汰。
667
+ *
668
+ * 淘汰顺序(只在**超出上限**时才动手,且只删到刚好回到上限):
669
+ * ① 不重要 且 已撤销(revokedAt/confirmedAt 最旧优先)
670
+ * ② 不重要 且 未撤销(confirmedAt 最旧优先)
671
+ * ③ 重要 且 已撤销
672
+ * ④ 重要 且 未撤销(最后手段;说明上限设得过低,`stats.pruneProtected` 会记下来)
673
+ * 「重要」判据(只用**既有字段**,不新造状态):`pinned === true` / `sourceClass === 'user-memory'`
674
+ * / `sourceKind === 'explicit'` / `confidence >= 0.8`。
675
+ *
676
+ * 不静默:每次淘汰记 `stats.pruned` / `stats.pruneProtected` / `lastPrune`(含被删条数与最旧时间),
677
+ * 经 `getLastPrune()` 暴露给诊断面。上限取 `opts.config.maxFacts`,非法值回落 1000;`<= 0` 视为不限。
678
+ */
679
+ const maxFactsRaw = Number(opts.config && opts.config.maxFacts)
680
+ const maxFacts = (Number.isFinite(maxFactsRaw) && maxFactsRaw >= 1) ? Math.floor(maxFactsRaw) : 1000
681
+ let lastPrune = null
682
+ const isImportantFact = (f) => !!f && (
683
+ f.pinned === true ||
684
+ f.sourceClass === 'user-memory' ||
685
+ f.sourceKind === 'explicit' ||
686
+ (typeof f.confidence === 'number' && Number.isFinite(f.confidence) && f.confidence >= 0.8)
687
+ )
688
+ function pruneIfNeeded() {
689
+ if (!(maxFacts > 0) || facts.length <= maxFacts) return { pruned: 0 }
690
+ const over = facts.length - maxFacts
691
+ const rank = (f) => (isImportantFact(f) ? 2 : 0) + (f.revoked === true ? 0 : 1) // 0 先删 → 3 最后删
692
+ const at = (f, k) => Number(f && f[k]) || 0
693
+ // ★关键:同一毫秒写入的多条事实 `confirmedAt` **会完全相等**(实测:一个 tick 内 upsert 的
694
+ // 三条 confirmedAt 全是同一个值)⇒ 只靠时间戳无法区分新旧,必须再用**数组下标**兜底。
695
+ // `facts` 数组本身就是按写入顺序追加的(最旧在前),因此下标升序 = 由旧到新。
696
+ // 这一条替代了原先的 `factId.localeCompare` —— 那等于按哈希排序,等于随机删,
697
+ // 会违背用户「以更新的为主」的口径(首版守卫当场抓到删了新的、留下旧的)。
698
+ const seq = new Map()
699
+ for (let i = 0; i < facts.length; i++) seq.set(facts[i].factId, i)
700
+ const ordered = facts.slice().sort((a, b) => {
701
+ const dr = rank(a) - rank(b)
702
+ if (dr !== 0) return dr
703
+ // 同类内:更旧的先删(撤销项用 revokedAt,其余用 confirmedAt)
704
+ const ta = at(a, a.revoked === true ? 'revokedAt' : 'confirmedAt') || at(a, 'confirmedAt')
705
+ const tb = at(b, b.revoked === true ? 'revokedAt' : 'confirmedAt') || at(b, 'confirmedAt')
706
+ if (ta !== tb) return ta - tb
707
+ return (seq.get(a.factId) || 0) - (seq.get(b.factId) || 0) // 时间戳打平时按下标(写入顺序)
708
+ })
709
+ const doomed = new Set(ordered.slice(0, over).map((f) => f.factId))
710
+ const protectedCount = ordered.slice(0, over).filter(isImportantFact).length
711
+ const oldest = ordered.slice(0, over).reduce((m, f) => {
712
+ const t = at(f, f.revoked === true ? 'revokedAt' : 'confirmedAt') || at(f, 'confirmedAt')
713
+ return (m === 0 || (t > 0 && t < m)) ? t : m
714
+ }, 0)
715
+ facts = facts.filter((f) => !doomed.has(f.factId))
716
+ stats.pruned = (stats.pruned || 0) + doomed.size
717
+ if (protectedCount) stats.pruneProtected = (stats.pruneProtected || 0) + protectedCount
718
+ lastPrune = {
719
+ pruned: doomed.size, protected: protectedCount, limit: maxFacts, over,
720
+ oldestAt: oldest || null, at: nowFn(),
721
+ }
722
+ return { pruned: doomed.size }
723
+ }
724
+
359
725
  function persist() {
360
- try { io.save(snapshot({ includeRevoked: true })) } catch (_) {}
726
+ try {
727
+ pruneIfNeeded() // ★#110:落盘前先按上限有序淘汰(只在超限时动手)
728
+ io.save(snapshot({ includeRevoked: true }))
729
+ lastPersistError = null
730
+ return { ok: true }
731
+ } catch (e) {
732
+ const msg = (e && e.message) ? String(e.message) : String(e)
733
+ lastPersistError = msg
734
+ return { ok: false, error: msg }
735
+ }
361
736
  }
362
737
  function snapshot(opts = {}) {
363
738
  const now = nowFn()
364
739
  return {
365
740
  schemaVersion: 1, namespace: 'dsh-auto-memory', policyVersion: FACT_POLICY_VERSION,
366
741
  savedAt: now,
742
+ // ★ P2-2 修复:快照同样不得与库内对象共享 provenance 数组引用
743
+ // (宿主读快照后 push 会改脏已入账记录)。
367
744
  facts: (opts.includeRevoked ? facts : facts.filter((f) => !f.revoked))
368
- .map((f) => ({ ...f })),
369
- conflicts: conflicts.map((c) => ({ ...c })),
745
+ .map(cloneFact),
746
+ conflicts: conflicts.map((c) => ({
747
+ ...c,
748
+ left: c.left ? cloneFact(c.left) : c.left,
749
+ right: c.right ? cloneFact(c.right) : c.right,
750
+ })),
370
751
  }
371
752
  }
753
+ /**
754
+ * ★ A-3 修复(2026-09-21):restore 按 **factId 去重**。
755
+ * 旧实现无条件 `facts.push(v.fact)` ⇒ 磁盘上任何重复主键(facts.json 被并发写过、
756
+ * 或旧版本曾产出重复记录)都会被原样装回内存,把上游 bug 的产物一路带进新进程。
757
+ * 去重策略:**后到者胜**(保留后出现的记录) —— 与「快照按写入顺序追加、后者为更新状态」一致;
758
+ * 同时保证父记录在数组中的**位置不变**(原地替换,不打乱既有顺序)。
759
+ * 重复的 revoked 记录同理被覆盖,不额外保留副本(审计信息以磁盘原文为准)。
760
+ */
372
761
  function restore(data) {
373
762
  if (!data || data.schemaVersion !== 1) return { ok: false, reason: 'bad-schema' }
374
763
  if (!Array.isArray(data.facts)) return { ok: false, reason: 'bad-facts' }
375
- facts = []
764
+ const next = []
765
+ const indexById = new Map()
766
+ let duplicates = 0
376
767
  for (const f of data.facts) {
377
768
  const v = validateFactPre(f)
378
769
  if (!v.ok) continue // 坏记录跳过, 不整体失败(幂等恢复)
379
- facts.push(v.fact)
770
+ // ★ B-4(2026-09-22):旧快照没有 epistemicStatus —— 用与写入侧同一套默认规则 backfill,
771
+ // 还原后**不留 undefined**(否则前端仍分不清「模型推断」与「用户明说」)。
772
+ // 显式给了且合法的记录保持原值;非法值仍按上面的 fail-closed 跳过。
773
+ // 回填走**浅拷贝**,不原地改调用方传进来的快照对象(validateFactPre 返回的就是同一个引用)。
774
+ const fact = v.fact.epistemicStatus === undefined
775
+ ? { ...v.fact, epistemicStatus: defaultEpistemicStatusPre(v.fact) }
776
+ : v.fact
777
+ const seenAt = indexById.get(fact.factId)
778
+ if (seenAt !== undefined) {
779
+ next[seenAt] = fact // 后到者胜:原地替换,不动顺序
780
+ duplicates++
781
+ continue
782
+ }
783
+ indexById.set(fact.factId, next.length)
784
+ next.push(fact)
380
785
  }
786
+ facts = next
381
787
  conflicts = Array.isArray(data.conflicts) ? data.conflicts.map((c) => ({ ...c })) : []
382
- return { ok: true, restored: facts.length }
788
+ return { ok: true, restored: facts.length, duplicates }
383
789
  }
384
790
  function clear() {
385
791
  facts = []; conflicts = []
386
- try { io.clear() } catch (_) {}
387
- stats.created = 0; stats.merged = 0; stats.superseded = 0
388
- return { ok: true }
792
+ // ★ A-8:io.clear() 的失败同样不得静默 —— 落盘残留会在下次 load 时"复活"已删数据。
793
+ let cleared = true, clearError
794
+ try { io.clear() } catch (e) {
795
+ cleared = false
796
+ clearError = (e && e.message) ? String(e.message) : String(e)
797
+ }
798
+ // ★ issue #76-6c 修复(2026-09-19):统计字段**全量归零**。
799
+ // 旧实现只写 `stats.created = 0; stats.merged = 0; stats.superseded = 0`,
800
+ // 漏掉 `upserts / conflictAdded / inferenceBlocked / expiredIgnored / revoked`
801
+ // ⇒ clear() 后这些计数残留(实测),诊断读数与实际不符。
802
+ // 改为**按现有键遍历归零**,将来新增 stats 字段也不会再漏。
803
+ for (const k of Object.keys(stats)) stats[k] = 0
804
+ return clearError !== undefined ? { ok: true, cleared, error: clearError } : { ok: true, cleared }
389
805
  }
390
806
  function dispose(reason) {
391
- if (disposed) return
807
+ if (disposed) return { ok: true, persisted: true, alreadyDisposed: true }
392
808
  disposed = true
393
- try { io.save(snapshot({ includeRevoked: true })) } catch (_) {}
809
+ // ★ A-8:dispose 是最后一次落盘机会,失败必须外显(旧实现空 catch ⇒ 静默丢弃全部未落盘状态)
810
+ const r = persist()
811
+ return { ok: r.ok, persisted: r.ok, ...(r.error ? { error: r.error } : {}) }
394
812
  }
395
813
 
396
814
  return {
397
815
  upsert, supersede, get, query, conflictsList, pendingConflicts, resolveConflict,
398
816
  evidenceFor, revokeBySource, snapshot, restore, clear, dispose,
817
+ // ★ issue #76-5 修复(2026-09-19):把**模块级**的 judgement-row 消费器挂进实例。
818
+ // 旧实现 `memory-hub.js:81-84` 检查 `typeof stores.facts.factCandidateFromJudgementRow === 'function'`
819
+ // 以决定是否委托——但该函数此前**只是模块级导出**(见上方 `export function`),
820
+ // 不在 `createFactStorePre()` 的返回对象上 ⇒ **该分支恒假** ⇒ hub 恒走自己的本地副本
821
+ // `factCandidateFromRow`(丢掉 `ttl` 字段,实测同输入下 store 版 ttl=60000、hub 版无 ttl)
822
+ // ⇒ 两适配器从此各自演化。挂进实例后委托分支变为恒真,语义统一到 store 实现。
823
+ factCandidateFromJudgementRow,
399
824
  getStats: () => ({ ...stats }),
825
+ /** ★ B-3(2026-09-22):最近一次被事实性判据拒绝的详情(code + 中文人话 + 被判文本 + 时间)或 null。
826
+ * 与 stats.factness* 计数配套 —— 拒绝**必须留下可观察痕迹**,不得静默丢弃。 */
827
+ getLastFactnessReject: () => (lastFactnessReject ? { ...lastFactnessReject } : null),
828
+ /** ★ A-8:最近一次落盘失败(字符串)或 null —— 供宿主诊断"写盘失败但流程继续"的静默缺口。 */
829
+ getLastPersistError: () => lastPersistError,
830
+ /** ★#110(2026-09-22):最近一次保留上限淘汰详情,或 null。`{pruned,protected,limit,over,oldestAt,at}`。
831
+ * `protected>0` 表示已轮到删「重要项」——说明上限设得过低,需要人工确认。 */
832
+ getLastPrune: () => (lastPrune ? { ...lastPrune } : null),
833
+ /** 当前生效的保留上限(<=0 表示不限)。 */
834
+ retentionLimit: () => maxFacts,
400
835
  get size() { return facts.length },
401
836
  get conflictCount() { return conflicts.length },
402
837
  }
@@ -444,7 +879,13 @@ export function ingestJudgementRows(store, rows, opts = {}) {
444
879
  if (seen.has(key)) { out.push({ skipped: true, reason: 'duplicate' }); continue }
445
880
  seen.add(key)
446
881
  const r = cand._suggestion === 'supersede_suggest' ? store.supersede(cand) : store.upsert(cand)
447
- out.push({ row: key, outcome: r.outcome, ok: r.ok, reason: r.reason || null })
882
+ out.push({
883
+ row: key, outcome: r.outcome, ok: r.ok, reason: r.reason || null,
884
+ // ★ B-3(2026-09-22):事实性拒绝要**带结构化原因向上透传**(code + 中文人话),
885
+ // 调用方(宿主/诊断面)才能说明「为什么没入库、怎么改写」,而不是只看到 ok:false。
886
+ ...(r.code ? { code: r.code } : {}),
887
+ ...(r.message ? { message: r.message } : {}),
888
+ })
448
889
  }
449
890
  return { results: out, seenObservationIds: [...seen] }
450
891
  }