@a9i5k4/dsh-auto-memory 3.0.1 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -8
- package/README.zh-CN.md +13 -8
- package/docs/HANDBOOK.md +92 -52
- package/docs/USER-GUIDE.en.md +9 -9
- package/docs/USER-GUIDE.zh-CN.md +9 -9
- package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
- package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
- package/lib/activation-host.js +6 -1
- package/lib/client.js +1376 -255
- package/lib/config-io.js +59 -6
- package/lib/context-host.js +17 -2
- package/lib/episodic-store.js +90 -16
- package/lib/evidence-store.js +27 -1
- package/lib/fact-store.js +463 -41
- package/lib/hub-io.js +217 -0
- package/lib/index.js +904 -87
- package/lib/intent-clean-safe.js +1 -1
- package/lib/jsonl-tail-cursor.js +75 -0
- package/lib/m7-index-sync-host.js +4 -6
- package/lib/memory-hub.js +62 -7
- package/lib/migrate-pack.js +351 -0
- package/lib/note-status.js +9 -1
- package/lib/procedure-store.js +252 -31
- package/lib/procedure-switch.js +38 -0
- package/lib/python-setup.js +109 -27
- package/lib/python-sidecar-client.js +285 -8
- package/lib/recall-stats.js +242 -0
- package/lib/rules-layer.js +106 -15
- package/lib/semantic-js.js +25 -7
- package/lib/shadow-host.js +41 -2
- package/package.json +6 -2
- package/docs/internal/ACCEPT-35-LIVE.md +0 -143
- package/docs/internal/ACCEPTANCE-20260914.md +0 -90
- package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
- package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
- package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
- package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
- package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +0 -397
- package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +0 -351
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -191
- package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +0 -181
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
- package/docs/internal/BATTLE-PLAN-20260917.md +0 -871
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
- package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
- package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
- package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
- package/docs/internal/FEATURE-INVENTORY.md +0 -531
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
- package/docs/internal/G-SERIES-EXECUTION-20260917.md +0 -248
- package/docs/internal/G3-DESIGN-20260918.md +0 -82
- package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +0 -92
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
- package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
- package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
- package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
- package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +0 -309
- package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
- package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +0 -120
- package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +0 -74
- package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +0 -175
- package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +0 -389
- package/docs/internal/ISSUE10-PLAN-20260919.md +0 -254
- package/docs/internal/ISSUE10B-FORENSICS-20260919.md +0 -468
- package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +0 -150
- package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +0 -114
- package/docs/internal/KICKOFF-P0.md +0 -254
- package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +0 -79
- package/docs/internal/MASTER-PLAN-3.0.md +0 -411
- package/docs/internal/MEMORY-GOVERNANCE-20260917.md +0 -309
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
- package/docs/internal/NEXT-VERSION-TODO.md +0 -95
- package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
- package/docs/internal/PENDING-FIXES-20260916.md +0 -289
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +0 -705
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +0 -649
- package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +0 -225
- package/docs/internal/PROGRESS-20260917.md +0 -93
- package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +0 -128
- package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +0 -163
- package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +0 -127
- package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +0 -140
- package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +0 -138
- package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +0 -218
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
- package/docs/internal/RELEASE-PROCESS.md +0 -99
- package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
- package/docs/internal/RESUME-20260918.md +0 -171
- package/docs/internal/RESUME-20260919.md +0 -104
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
- package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +0 -198
- package/docs/internal/ROADMAP-20260917-WEEK.md +0 -439
- package/docs/internal/ROADMAP.md +0 -106
- package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -185
- package/docs/internal/S10-GAP-INVENTORY-20260917.md +0 -239
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
- package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
- package/docs/internal/T6-EXECUTION-20260920.md +0 -130
- package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +0 -146
- package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +0 -89
- package/docs/internal/THESIS-OUTLINE-20260918.md +0 -147
- package/docs/internal/THREE-LAYER-CONTRACT.md +0 -219
- package/docs/internal/TODO-BACKLOG.md +0 -263
- package/docs/internal/TODO-GRAPH.html +0 -715
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
- package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +0 -297
- package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +0 -104
- package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
- package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
- package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
- package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
- package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
- 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
|
-
|
|
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)) {
|
|
@@ -205,8 +482,28 @@ export function createFactStorePre(opts = {}) {
|
|
|
205
482
|
|
|
206
483
|
// 3. 新建 / 合并 / 取代
|
|
207
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
|
+
}
|
|
208
505
|
const fact = {
|
|
209
|
-
factId:
|
|
506
|
+
factId: newFactId,
|
|
210
507
|
scope: c.scope, subject: c.subject, predicate: c.predicate,
|
|
211
508
|
object: c.object === undefined ? null : c.object,
|
|
212
509
|
sourceKind: c.sourceKind,
|
|
@@ -219,41 +516,23 @@ export function createFactStorePre(opts = {}) {
|
|
|
219
516
|
ingestedAt: now,
|
|
220
517
|
occurredAt: c.occurredAt,
|
|
221
518
|
mentionedAt: c.mentionedAt,
|
|
222
|
-
|
|
519
|
+
// ★ B-4(2026-09-22):epistemicStatus **必填**(c 已在入口补默认值,调用方显式值原样保留)——
|
|
520
|
+
// 不留 undefined,前端/面板才能据此区分「模型推断」(observation) 与「用户明说」(fact)。
|
|
521
|
+
epistemicStatus: c.epistemicStatus,
|
|
223
522
|
...(c.trend !== undefined ? { trend: c.trend } : {}),
|
|
224
523
|
}
|
|
225
524
|
const fv = validateFactPre(fact)
|
|
226
525
|
if (!fv.ok) return { ok: false, reason: 'fact-invalid:' + fv.reason, outcome: 'invalid' }
|
|
227
526
|
facts.push(fv.fact)
|
|
228
527
|
stats.created++
|
|
229
|
-
|
|
230
|
-
return
|
|
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 }
|
|
231
532
|
}
|
|
232
533
|
|
|
233
534
|
// 已有且不冲突: 合并(用户声明提升 sourceKind/confidence, 追加 provenance)
|
|
234
|
-
|
|
235
|
-
if (c.sourceKind === 'explicit' || prev.sourceKind !== 'explicit') {
|
|
236
|
-
prev.sourceKind = c.sourceKind === 'explicit' ? 'explicit' : prev.sourceKind
|
|
237
|
-
}
|
|
238
|
-
if (c.sourceClass) prev.sourceClass = c.sourceClass
|
|
239
|
-
if (c.provenance && c.provenance.length) {
|
|
240
|
-
const seen = new Set(prev.provenance)
|
|
241
|
-
for (const s of c.provenance) if (!seen.has(s)) prev.provenance.push(s)
|
|
242
|
-
}
|
|
243
|
-
if (c.confidence !== undefined && c.confidence !== null) prev.confidence = c.confidence
|
|
244
|
-
// M8-1 向后兼容回填(须在 confirmedAt 更新前执行):旧记录(无 ingestedAt)首次合并时补齐,
|
|
245
|
-
// 取其原始确认时间(=首次入库);不改变其余合并语义
|
|
246
|
-
if (prev.ingestedAt === undefined && Number.isFinite(prev.confirmedAt)) prev.ingestedAt = prev.confirmedAt
|
|
247
|
-
prev.confirmedAt = now // 合并视为重新确认
|
|
248
|
-
// M8-1 可选元数据透传:重新陈述时更新发生/陈述时间与认识论状态/趋势(候选提供才写,加法性)
|
|
249
|
-
if (c.occurredAt !== undefined) prev.occurredAt = c.occurredAt
|
|
250
|
-
if (c.mentionedAt !== undefined) prev.mentionedAt = c.mentionedAt
|
|
251
|
-
if (c.epistemicStatus !== undefined) prev.epistemicStatus = c.epistemicStatus
|
|
252
|
-
if (c.trend !== undefined) prev.trend = c.trend
|
|
253
|
-
if (c.ttl !== undefined) prev.ttl = c.ttl
|
|
254
|
-
stats.merged++
|
|
255
|
-
void persist()
|
|
256
|
-
return { ok: true, outcome: 'merged', fact: prev }
|
|
535
|
+
return mergeInto(existing, c, now)
|
|
257
536
|
}
|
|
258
537
|
|
|
259
538
|
/**
|
|
@@ -270,6 +549,11 @@ export function createFactStorePre(opts = {}) {
|
|
|
270
549
|
existing.revoked = true
|
|
271
550
|
existing.revokedAt = nowFn()
|
|
272
551
|
stats.revoked++
|
|
552
|
+
// ★ P2-1 修复(2026-09-21):`stats.superseded` 此前是**死计数器**——
|
|
553
|
+
// 声明在 stats(:141) 且被诊断面读取,但全文件没有任何一处自增(只有 revoked++)。
|
|
554
|
+
// ⇒ supersede 次数恒为 0,「取代 vs 级联撤销」在读数上无法区分。
|
|
555
|
+
// revokeBySource 走的是另一种语义(源删除级联),不在此自增,两计数分工保持可辨。
|
|
556
|
+
stats.superseded++
|
|
273
557
|
}
|
|
274
558
|
return upsert(c)
|
|
275
559
|
}
|
|
@@ -303,7 +587,7 @@ export function createFactStorePre(opts = {}) {
|
|
|
303
587
|
function get(scope, subject, predicate) {
|
|
304
588
|
if (disposed) return null
|
|
305
589
|
const f = findSubjectPredicate(scope, subject, predicate)
|
|
306
|
-
return f ?
|
|
590
|
+
return f ? cloneFact(f) : null // 返回副本(含数组副本), 防外部改内部态
|
|
307
591
|
}
|
|
308
592
|
function query(q = {}) {
|
|
309
593
|
if (disposed) return []
|
|
@@ -315,7 +599,7 @@ export function createFactStorePre(opts = {}) {
|
|
|
315
599
|
(q.scope === undefined || f.scope === q.scope) &&
|
|
316
600
|
(q.subject === undefined || f.subject === q.subject) &&
|
|
317
601
|
(q.predicate === undefined || f.predicate === q.predicate))
|
|
318
|
-
.map(
|
|
602
|
+
.map(cloneFact)
|
|
319
603
|
}
|
|
320
604
|
function conflictsList() {
|
|
321
605
|
return conflicts.map((c) => ({ ...c }))
|
|
@@ -363,46 +647,168 @@ export function createFactStorePre(opts = {}) {
|
|
|
363
647
|
}
|
|
364
648
|
|
|
365
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
|
+
|
|
366
725
|
function persist() {
|
|
367
|
-
try {
|
|
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
|
+
}
|
|
368
736
|
}
|
|
369
737
|
function snapshot(opts = {}) {
|
|
370
738
|
const now = nowFn()
|
|
371
739
|
return {
|
|
372
740
|
schemaVersion: 1, namespace: 'dsh-auto-memory', policyVersion: FACT_POLICY_VERSION,
|
|
373
741
|
savedAt: now,
|
|
742
|
+
// ★ P2-2 修复:快照同样不得与库内对象共享 provenance 数组引用
|
|
743
|
+
// (宿主读快照后 push 会改脏已入账记录)。
|
|
374
744
|
facts: (opts.includeRevoked ? facts : facts.filter((f) => !f.revoked))
|
|
375
|
-
.map(
|
|
376
|
-
conflicts: conflicts.map((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
|
+
})),
|
|
377
751
|
}
|
|
378
752
|
}
|
|
753
|
+
/**
|
|
754
|
+
* ★ A-3 修复(2026-09-21):restore 按 **factId 去重**。
|
|
755
|
+
* 旧实现无条件 `facts.push(v.fact)` ⇒ 磁盘上任何重复主键(facts.json 被并发写过、
|
|
756
|
+
* 或旧版本曾产出重复记录)都会被原样装回内存,把上游 bug 的产物一路带进新进程。
|
|
757
|
+
* 去重策略:**后到者胜**(保留后出现的记录) —— 与「快照按写入顺序追加、后者为更新状态」一致;
|
|
758
|
+
* 同时保证父记录在数组中的**位置不变**(原地替换,不打乱既有顺序)。
|
|
759
|
+
* 重复的 revoked 记录同理被覆盖,不额外保留副本(审计信息以磁盘原文为准)。
|
|
760
|
+
*/
|
|
379
761
|
function restore(data) {
|
|
380
762
|
if (!data || data.schemaVersion !== 1) return { ok: false, reason: 'bad-schema' }
|
|
381
763
|
if (!Array.isArray(data.facts)) return { ok: false, reason: 'bad-facts' }
|
|
382
|
-
|
|
764
|
+
const next = []
|
|
765
|
+
const indexById = new Map()
|
|
766
|
+
let duplicates = 0
|
|
383
767
|
for (const f of data.facts) {
|
|
384
768
|
const v = validateFactPre(f)
|
|
385
769
|
if (!v.ok) continue // 坏记录跳过, 不整体失败(幂等恢复)
|
|
386
|
-
|
|
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)
|
|
387
785
|
}
|
|
786
|
+
facts = next
|
|
388
787
|
conflicts = Array.isArray(data.conflicts) ? data.conflicts.map((c) => ({ ...c })) : []
|
|
389
|
-
return { ok: true, restored: facts.length }
|
|
788
|
+
return { ok: true, restored: facts.length, duplicates }
|
|
390
789
|
}
|
|
391
790
|
function clear() {
|
|
392
791
|
facts = []; conflicts = []
|
|
393
|
-
|
|
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
|
+
}
|
|
394
798
|
// ★ issue #76-6c 修复(2026-09-19):统计字段**全量归零**。
|
|
395
799
|
// 旧实现只写 `stats.created = 0; stats.merged = 0; stats.superseded = 0`,
|
|
396
800
|
// 漏掉 `upserts / conflictAdded / inferenceBlocked / expiredIgnored / revoked`
|
|
397
801
|
// ⇒ clear() 后这些计数残留(实测),诊断读数与实际不符。
|
|
398
802
|
// 改为**按现有键遍历归零**,将来新增 stats 字段也不会再漏。
|
|
399
803
|
for (const k of Object.keys(stats)) stats[k] = 0
|
|
400
|
-
return { ok: true }
|
|
804
|
+
return clearError !== undefined ? { ok: true, cleared, error: clearError } : { ok: true, cleared }
|
|
401
805
|
}
|
|
402
806
|
function dispose(reason) {
|
|
403
|
-
if (disposed) return
|
|
807
|
+
if (disposed) return { ok: true, persisted: true, alreadyDisposed: true }
|
|
404
808
|
disposed = true
|
|
405
|
-
|
|
809
|
+
// ★ A-8:dispose 是最后一次落盘机会,失败必须外显(旧实现空 catch ⇒ 静默丢弃全部未落盘状态)
|
|
810
|
+
const r = persist()
|
|
811
|
+
return { ok: r.ok, persisted: r.ok, ...(r.error ? { error: r.error } : {}) }
|
|
406
812
|
}
|
|
407
813
|
|
|
408
814
|
return {
|
|
@@ -416,6 +822,16 @@ export function createFactStorePre(opts = {}) {
|
|
|
416
822
|
// ⇒ 两适配器从此各自演化。挂进实例后委托分支变为恒真,语义统一到 store 实现。
|
|
417
823
|
factCandidateFromJudgementRow,
|
|
418
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,
|
|
419
835
|
get size() { return facts.length },
|
|
420
836
|
get conflictCount() { return conflicts.length },
|
|
421
837
|
}
|
|
@@ -463,7 +879,13 @@ export function ingestJudgementRows(store, rows, opts = {}) {
|
|
|
463
879
|
if (seen.has(key)) { out.push({ skipped: true, reason: 'duplicate' }); continue }
|
|
464
880
|
seen.add(key)
|
|
465
881
|
const r = cand._suggestion === 'supersede_suggest' ? store.supersede(cand) : store.upsert(cand)
|
|
466
|
-
out.push({
|
|
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
|
+
})
|
|
467
889
|
}
|
|
468
890
|
return { results: out, seenObservationIds: [...seen] }
|
|
469
891
|
}
|