@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
@@ -1,40 +1,258 @@
1
- /** Remove runtime envelopes only at unquoted line boundaries; preserve literal examples/code. */
2
- const tags = new Set(['memory_system', 'system-reminder', 'long_term_memory'])
3
- export function stripRuntimeIntentPre(text) {
4
- const lines = String(text == null ? '' : text).split(/\r?\n/)
5
- const out = [], stack = []
6
- let fence = null
7
- for (let line of lines) {
8
- const trimmed = line.trim()
9
- if (!stack.length) {
10
- const f = /^( {0,3})(`{3,}|~{3,})/.exec(line)
11
- if (f) {
12
- if (!fence) fence = { char: f[2][0], size: f[2].length }
13
- else if (f[2][0] === fence.char && f[2].length >= fence.size && /^( {0,3})(`+|~+)\s*$/.test(line)) fence = null
14
- out.push(line); continue
15
- }
16
- if (fence || /^\s*>/.test(line) || /^ {4}/.test(line)) { out.push(line); continue }
17
- if (/^(?:current runtime context\.|current dsh file policy:)/i.test(trimmed)) continue
18
- }
19
- // Consume one or more envelopes at the beginning of an unquoted line.
20
- // Closing tags can end a prefix split across messages; trailing human text is retained.
21
- for (;;) {
22
- if (stack.length) {
23
- const token = /<(\/?)(memory_system|system-reminder|long_term_memory)>/.exec(line)
24
- if (!token) { line = ''; break }
25
- line = line.slice(token.index + token[0].length)
26
- if (!token[1]) stack.push(token[2])
27
- else if (stack[stack.length - 1] === token[2]) stack.pop()
28
- continue
29
- }
30
- const open = /^\s*<(memory_system|system-reminder|long_term_memory)>/.exec(line)
31
- if (open && tags.has(open[1])) { stack.push(open[1]); line = line.slice(open[0].length); continue }
32
- const close = /^\s*<\/(memory_system|system-reminder|long_term_memory)>/.exec(line)
33
- if (close) { line = line.slice(close[0].length); continue }
34
- break
35
- }
36
- if (line.trim()) out.push(line)
37
- else if (!stack.length && !trimmed) out.push('')
38
- }
39
- return out.join('\n')
40
- }
1
+ /**
2
+ * 运行时信封剥离器 —— **结构判据版**(2026-09-19 R5)。
3
+ *
4
+ * 只在未被引用/未被代码块包裹的行上剥离信封;保护字面示例与代码块。
5
+ *
6
+ * ★ 本次改动的根因(issue #30 / ③ Hermes 遗留 / H-3):
7
+ * 旧实现在 `:17` 用**一条字面量行首白名单** `/^(?:current runtime context\.|current dsh file policy:)/i`
8
+ * 识别信封 ⇒ 只能挡住两个当期已知形态。真机取证(`~/.dsh/memory/hub/procedures.json`,
9
+ * 10 条 procedure 里 7 条 observed、其中 4 条 title 就是运行时信封)实测漏网 4 类:
10
+ * · `Approval prompts are disabled in this session: …`
11
+ * · `{"path":"D:\\…`(工具回包的 JSON 转储)
12
+ * · 中文「当前运行时上下文。」
13
+ * · 自然语言「消息系统提醒由框架…」
14
+ * ⇒ 这正是本文件旧注释自己预警的风险:「注入形态会演进 ⇒ 漏判」。
15
+ *
16
+ * ★ 修法(**形态侦测,不是再添几条文法**):信封的共同**结构性质**有二,不依赖具体措辞——
17
+ * **F1 结构化转储**:以 `{`/`[` 开头且以 `}`/`]` 收尾的行 = 工具回包的 JSON,永不可能是人的输入;
18
+ * **F2 声明头 + 分隔符**:`<head noun>[::.。] <说明>` 形状,且 head noun 落在
19
+ * **harness 自有词汇族**内(runtime/approval/policy/sandbox/session/permission/escalation/…)。
20
+ * 注意判据是「**族**(family)+ 形状」,不是整句前缀 ⇒ 框架换措辞("Approval prompts…" →
21
+ * "Escalation is handled by…")仍能被同一个族覆盖。
22
+ * F2 额外加**位置约束** `leadingOnly`:仅对消息**开头的信封区**生效(一旦出现真人文本即停止),
23
+ * 因为 harness 一律把元数据注入在头部;这条把误伤面压到接近零(正文里写 "Policy: xxx" 不会被删)。
24
+ *
25
+ * 三条形态族之外的未知形态仍会漏 —— 这是**形态侦测的固有边界**,故本模块只保证:
26
+ * ①已知族全覆盖;②族内可扩展(加词族 ≠ 加整句前缀);③漏判只会让**噪音**进入 intent,
27
+ * 不会把真人问题删掉(真人文本一旦出现即关闭 F2 位置约束)。
28
+ */
29
+
30
+ /** 被识别的信封标签名(闭包内使用,声明必须在本文件任何使用者之前)。 */
31
+ const tags = new Set(['memory_system', 'system-reminder', 'long_term_memory'])
32
+
33
+ /** 行首精确信封前缀(向后兼容,任意位置生效)。 */
34
+ const EXACT_ENVELOPE_RE = /^(?:current runtime context\.|current dsh file policy:)/i
35
+
36
+ /**
37
+ * F2 英文声明头词族。
38
+ *
39
+ * ★ 2026-09-19 真机取证补第二形态:title 会被**截断到 40 字符**,
40
+ * 本机的 `Approval prompts are disabled in this se` 连冒号都被截掉了 ⇒ 只靠「分隔符」判据会漏。
41
+ * 故补 `BE_STATEMENT_RE`(词族开头 + be 动词陈述句),它同样不依赖整句字面量:
42
+ * 框架换措辞("Approval prompts…" → "Escalation prompts are handled…")仍在族内。
43
+ */
44
+ const HARNESS_HEAD_RE = /^(?:current|approval|approvals|sandbox|policy|policies|runtime|session|permission|permissions|escalation|tools?|files?|memory)\b[^.!?\n]{0,90}[::.。]/i
45
+
46
+ /** F2 第二形态:词族开头 + be 动词系表结构(截断后仍成立)。 */
47
+ const BE_STATEMENT_RE = /^(?:current|approval|approvals|sandbox|policy|policies|runtime|session|permission|permissions|escalation)\b[^.!?\n]{0,60}\b(?:is|are|was|were|has been|have been|will be)\b/i
48
+
49
+ /** F2 中文声明头词族(对应上方英文族)。 */
50
+ const HARNESS_HEAD_ZH_RE = /^(?:当前|批准|沙箱|策略|运行时|会话|权限|升级|工具|文件|记忆)[^。!?\n]{0,50}[::。]/
51
+
52
+ /**
53
+ * F3 插件自产注入标记(★ 2026-09-19 真机取证新增)。
54
+ *
55
+ * **发现过程**:⑨ 探针逐条检验真机 `procedures.json` 的 11 条 title,
56
+ * 发现**时间戳最新的一条**(2026-09-19)title 是 `[Retrieved memory refe` ——
57
+ * 它正是**本插件自己注入的「记忆召回」块标记**
58
+ * (`activation-inbox.js:58` 的 `TAIL_MARKER_LINE_V1`)。
59
+ *
60
+ * ⇒ **自污染闭环**:插件注入召回块 → 召回块随 userText 进 episode →
61
+ * `crossFeed()` 把它当成技能 title ⇒ 又回到「技能审批队列」。
62
+ * ⇒ 且它**仍在持续产生**(非历史存量),故必须修,不能只清数据。
63
+ *
64
+ * **判据仍是词族 + 行首位置**(不写整句字面量,故标记措辞微调后仍成立):
65
+ * ① `[Retrieved memory ref…` —— 允许被 `slice(0,40)` 截断,**不要求闭合方括号**
66
+ * ② `Verify against the current user request…` —— 召回块尾部校验行
67
+ * ③ `If a reference hints at what you need…` —— 召回块尾部取用提示行
68
+ * ④ `Source: mem_<32hex>` —— 召回条目的来源行(结构化形态,散文不会这样起行)
69
+ *
70
+ * 位置:与 F1/`EXACT_ENVELOPE_RE` 同属**任意位置生效**(召回块可出现在文本中段)。
71
+ * `^` 锚定保证不误伤引用该标记的句子(如「解释一下 [Retrieved memory…」不会以它开头)。
72
+ */
73
+ const PLUGIN_TAIL_MARKER_RE = /^\[?Retrieved memory ref|^Verify against the current user request|^If a reference hints at what you need|^Source:\s*mem_[0-9a-f]{32}|^Reason:\s*fv2 lane=|^Score:\s*[0-9.]+ \(rank \d+\/\d+\)/i
74
+
75
+ /**
76
+ * F3-补(★ 2026-09-19 T1-0 真机追加):召回块的**块身份锚**与**弱标记行**。
77
+ *
78
+ * **漏网根因(实测)**:`PLUGIN_TAIL_MARKER_RE` 里的 `^Source:\s*mem_[0-9a-f]{32}` 把
79
+ * 「前缀」与「后缀内容」**绑死** ⇒ 一旦该行被 `slice(0, N)` 截断(`Source: mem_…` → `Source: me`),
80
+ * 判据立即失效;且 F3 **完全没有 `^Reference:` 这一条**,而 `renderItemBlock`
81
+ * (`activation-inbox.js:223`)每块都会输出一行 `Reference: …` ⇒ 该行**必然幸存**并成为
82
+ * `extractIntent`(`episodic-store.js:167`)的输入 ⇒ 变成技能标题。
83
+ * 真机后果:`pipeline` 从 3 条涨回 5 条,新增 `Source: me` 与 `Reference: ## 2026-09-18 - dsh-auto-memo`。
84
+ *
85
+ * **修法(族 + 形状,不再绑死后缀)**:拆成两级,避免为堵漏而误伤真人引用句
86
+ * (`⑨` 套件 [4] 组用字面量锁定了「`Source: mem_xxx 这个格式对不对?` 必须保留」):
87
+ * ① `PLUGIN_BLOCK_ANCHOR_RE` —— **强标记**:散文不可能这样起行,单条即可确立「这是召回块」;
88
+ * ② `PLUGIN_BLOCK_LINE_RE` —— **弱标记**:`Source:` / `Reference:` 行的**后缀内容任意**
89
+ * (可能是被截断的 id、记忆正文、甚至 markdown 标题),故**只在①已确立块身份时**才生效。
90
+ * 这样「整块召回」被完整清空,而**孤立的 `Source:` 句子不受影响**。
91
+ */
92
+ const PLUGIN_BLOCK_ANCHOR_RE = /^\[?Retrieved memory ref|^Verify against the current user request|^If a reference hints at what you need|^Reason:\s*fv2 lane=|^Score:\s*[0-9.]+ \(rank \d+\/\d+\)|^Source:\s*mem_[0-9a-f]{32}/i
93
+
94
+ /** F3 弱标记:仅当同一段文本已被 `PLUGIN_BLOCK_ANCHOR_RE` 确立为召回块时才参与判定。 */
95
+ const PLUGIN_BLOCK_LINE_RE = /^Source:\s*\S|^Reference:\s*\S/i
96
+
97
+ /**
98
+ * F3 截断残片:整行只剩「标签 + 单个裸 token」。
99
+ *
100
+ * `Source: me` 正是 `Source: mem_<32hex>` 被截到 10 字符的产物。真人的引用句必带后续文字
101
+ * (如 `Source: mem_xxx 这个格式对不对?` 含空格与整句),故用「**无块上下文也能单独判定**」这条
102
+ * 只需一个约束即可安全覆盖:`\S+` 后**必须直接行尾**(不允许空格续文)。
103
+ */
104
+ const PLUGIN_TRUNCATED_FRAGMENT_RE = /^Source:\s*\S+\s*$/i
105
+
106
+ /**
107
+ * F3 `Reference:` + markdown 标题 —— **插件指纹,可单独判定**。
108
+ *
109
+ * `renderItemBlock`(`activation-inbox.js:223`)写的是 `'Reference: ' + refText`,
110
+ * 而 `refText` 来自 L0 记录正文(以 `## <标题>` 起头)⇒ 该行必然是 `Reference: ## …` 形状。
111
+ * 真人几乎不可能写出「`Reference:` 紧跟 `##`」,故这一条不需要块上下文。
112
+ * (实测漏网样本:`Reference: ## 2026-09-18 - dsh-auto-memory 项目铁律:fail-soft/降级`)
113
+ */
114
+ const PLUGIN_REF_HEADING_RE = /^Reference:\s*#{1,6}\s+\S/i
115
+
116
+ /**
117
+ * F4 编码损坏行(★ 2026-09-19 真机取证新增)。
118
+ *
119
+ * 真机存在一条 title 为 `\ufffd\ufffd…正确(验证)`(U+FFFD 替换字符堆叠)——
120
+ * 是**编码损坏**(非 UTF-8 字节被强行解码)的产物,属结构性垃圾,不可能是有意义标题。
121
+ * 判据:替换字符 ≥3 个(单/双个可能出现在正常文本中,不作判据)。
122
+ */
123
+ function looksEncodingCorruptedPre(trimmed) {
124
+ return (trimmed.match(/\ufffd/g) || []).length >= 3
125
+ }
126
+
127
+ /**
128
+ * F5 行内运行时残留(★ 2026-09-19 T1-3 真机追加)。
129
+ *
130
+ * **发现过程**:新套件 [6] 组跑出一条 FAIL —— 真机 fact[1] 的 object 是
131
+ * `现在是什么情况? Current DSH file policy: danger-full-access. The DS`
132
+ * └─真人话─┘└──────────────── 运行时信封 ────────────────┘
133
+ * 即**真人与信封挤在同一行**。
134
+ *
135
+ * **为什么清洗器处理不了它**:`stripRuntimeIntentPre` 是**按行判断**的 ——
136
+ * 删掉这样的整行会**连带丢掉真人话**(「现在是什么情况?」),所以它必须保留。
137
+ * ⇒ 「清洗后是否变化」这条判据对**行内混合**天然无效。
138
+ *
139
+ * **所以本判据是给「写入侧卫生门」用的**(`hubFlushTick`):
140
+ * 它不负责清洗,只负责回答「这条文本里还有没有运行时痕迹」⇒ 有则 skip。
141
+ * 同样用于 fact 通路:行内混信封的 subject/object 不该进正文。
142
+ *
143
+ * 判据是**标记短语**(任意位置),不要求行首 —— 这正是它区别于 F1/F2 的地方:
144
+ * F1/F2 判「整行是信封」,F5 判「行内夹带信封」。
145
+ */
146
+ const RUNTIME_RESIDUE_RE = /Current DSH file policy|Current runtime context|Approval prompts are disabled in this session|\[Retrieved memory ref|Verify against the current user request|If a reference hints at what you need|Reason:\s*fv2 lane=|Score:\s*[0-9.]+ \(rank \d+\/\d+\)|^\s*(?:Reference|Source|Reason)\s*:\s*-\s*\d{1,2}:\d{2}\s*\[kind:|^\s*\d{1,2}:\d{2}\s*\[kind:[a-z]+\]|^\s*\[kind:[a-z]+\]/im
147
+
148
+ /** F5:行内是否夹带运行时信封痕迹(不做清洗,只做判定)。 */
149
+ export function looksRuntimeResiduePre(text) {
150
+ return RUNTIME_RESIDUE_RE.test(String(text == null ? '' : text))
151
+ }
152
+
153
+ /** F1 结构化转储:以 { / [ 强开幕(后紧跟上引号或嵌套),可能是 JSON 转储。 */
154
+ function looksLikeStructuredDumpPre(trimmed) {
155
+ // 强开幕:`{"` / `[{` / `["` / `[ 数字` —— 人类散文几乎不会这样开头。
156
+ const strongOpen = /^\{"|^\[\{|^\[\s*"|^\[\s*\d/.test(trimmed)
157
+ if (!strongOpen) return false
158
+ // 已闭合 ⇒ 直接判为转储。
159
+ if (/[\]}]\s*$/.test(trimmed)) return true
160
+ // ★ 未闭合:工具回包常被**截断**(真机 title 就是 `{"path":"D:\\…` 断在半路),
161
+ // 故不能要求收尾符;改为要求「引号键值」特征(`"key":`),散文里不会出现这种配对。
162
+ return /"[^"]{0,60}"\s*:/.test(trimmed)
163
+ }
164
+
165
+ /**
166
+ * 判定单行是否为信封行。
167
+ * @param {string} trimmed - 已 trim 的行。
168
+ * @param {boolean} leadingOnly - true 时只允许 F2(位置约束);false 时 F1/F2 都允许。
169
+ */
170
+ function isEnvelopeLinePre(trimmed, leadingOnly, blockAnchored) {
171
+ if (!trimmed) return false
172
+ if (EXACT_ENVELOPE_RE.test(trimmed)) return true
173
+ // ★ F3/F4(2026-09-19 ⑨):插件自产标记与编码损坏行 —— **任意位置生效**
174
+ // 理由:召回块可能出现在 episode.intent 的**中段**(前面还有真人文本),
175
+ // 若沿用 F2 的「仅信封区」位置约束会再次漏网(这正是 ⑨ 的原始漏网原因之一)。
176
+ if (PLUGIN_TAIL_MARKER_RE.test(trimmed)) return true
177
+ // ★ F3-补(2026-09-19 T1-0):截断残片(`Source: me`)可**单独**判定 ——
178
+ // 它没有任何上下文、也不含后续文字,不可能出现在真人句子里。
179
+ if (PLUGIN_TRUNCATED_FRAGMENT_RE.test(trimmed)) return true
180
+ // ★ F3-补:`Reference: ## <标题>` 是 `renderItemBlock` 的固定产物,可**单独**判定。
181
+ if (PLUGIN_REF_HEADING_RE.test(trimmed)) return true
182
+ // ★ F3-补:弱标记行(`Source:` / `Reference:` 后缀任意)**仅在已确立块身份时**生效 ——
183
+ // 这样整块召回被清空,而孤立的「Source: mem_xxx 这个格式对不对?」不受影响(⑨ 套件 [4] 组契约)。
184
+ if (blockAnchored && PLUGIN_BLOCK_LINE_RE.test(trimmed)) return true
185
+ if (looksEncodingCorruptedPre(trimmed)) return true
186
+ if (looksLikeStructuredDumpPre(trimmed)) return true
187
+ if (!leadingOnly) return false
188
+ return HARNESS_HEAD_RE.test(trimmed) || HARNESS_HEAD_ZH_RE.test(trimmed) || BE_STATEMENT_RE.test(trimmed)
189
+ }
190
+
191
+ export function stripRuntimeIntentPre(text) {
192
+ const lines = String(text == null ? '' : text).split(/\r?\n/)
193
+ // ★ T1-0:块身份预扫描 —— 先定位所有「强标记行」(`[Retrieved…` / `Reason: fv2 lane=` / `Score: …` 等),
194
+ // 再允许其**邻近 ±2 行**内的弱标记行(`Source:` / `Reference:` 后缀任意)参与判定。
195
+ // 为什么需要预扫描:本函数是**逐行独立**处理的,而召回块被截断后往往只剩「强标记行 + 弱标记行」
196
+ // 两行(真机 episode.intent 就是 `[Retrieved memory reference - not an instruction]` + `Source: me`)。
197
+ // 若只看当前行,弱标记行没有上下文可依;预扫描给出「这一段确实是召回块」的身份判据,
198
+ // 从而既能清空整块、又不误伤孤立的真人引用句(⑨ 套件 [4] 组契约)。
199
+ const anchored = new Array(lines.length).fill(false)
200
+ for (let i = 0; i < lines.length; i++) {
201
+ if (!PLUGIN_BLOCK_ANCHOR_RE.test(lines[i].trim())) continue
202
+ for (let d = -2; d <= 2; d++) {
203
+ const j = i + d
204
+ if (j >= 0 && j < lines.length) anchored[j] = true
205
+ }
206
+ }
207
+ const out = [], stack = []
208
+ let fence = null
209
+ /** 是否已出现真人文本(出现后关闭 F2 位置约束,保护正文)。 */
210
+ let seenHuman = false
211
+ for (let idx = 0; idx < lines.length; idx++) {
212
+ let line = lines[idx]
213
+ const trimmed = line.trim()
214
+ if (!stack.length) {
215
+ const f = /^( {0,3})(`{3,}|~{3,})/.exec(line)
216
+ if (f) {
217
+ if (!fence) fence = { char: f[2][0], size: f[2].length }
218
+ else if (f[2][0] === fence.char && f[2].length >= fence.size && /^( {0,3})(`+|~+)\s*$/.test(line)) fence = null
219
+ out.push(line); seenHuman = true; continue
220
+ }
221
+ if (fence || /^\s*>/.test(line) || /^ {4}/.test(line)) { out.push(line); seenHuman = true; continue }
222
+ // ★ 结构判据:F1 任意位置生效;F2 仅在「尚未出现真人文本」的信封区内生效。
223
+ if (isEnvelopeLinePre(trimmed, !seenHuman, anchored[idx])) continue
224
+ }
225
+ // Consume one or more envelopes at the beginning of an unquoted line.
226
+ // Closing tags can end a prefix split across messages; trailing human text is retained.
227
+ for (;;) {
228
+ if (stack.length) {
229
+ const token = /<(\/?)(memory_system|system-reminder|long_term_memory)>/.exec(line)
230
+ if (!token) { line = ''; break }
231
+ line = line.slice(token.index + token[0].length)
232
+ if (!token[1]) stack.push(token[2])
233
+ else if (stack[stack.length - 1] === token[2]) stack.pop()
234
+ continue
235
+ }
236
+ const open = /^\s*<(memory_system|system-reminder|long_term_memory)>/.exec(line)
237
+ if (open && tags.has(open[1])) { stack.push(open[1]); line = line.slice(open[0].length); continue }
238
+ const close = /^\s*<\/(memory_system|system-reminder|long_term_memory)>/.exec(line)
239
+ if (close) { line = line.slice(close[0].length); continue }
240
+ break
241
+ }
242
+ if (line.trim()) { out.push(line); seenHuman = true }
243
+ else if (!stack.length && !trimmed) out.push('')
244
+ }
245
+ return out.join('\n')
246
+ }
247
+
248
+ /** 导出判据本身,供套件直接断言(避免只能通过整串行为间接验证)。 */
249
+ export const RUNTIME_ENVELOPE_V1 = Object.freeze({
250
+ EXACT_ENVELOPE_RE,
251
+ HARNESS_HEAD_RE,
252
+ HARNESS_HEAD_ZH_RE,
253
+ PLUGIN_TAIL_MARKER_RE,
254
+ RUNTIME_RESIDUE_RE,
255
+ looksEncodingCorruptedPre,
256
+ looksRuntimeResiduePre,
257
+ isEnvelopeLinePre: (line) => isEnvelopeLinePre(String(line == null ? '' : line).trim(), true),
258
+ })
package/lib/l0-extract.js CHANGED
@@ -43,6 +43,49 @@ const SENTENCE_SPLIT_RE = /[。;;!!??\n]/
43
43
  /** 主题块标题后缀:`(12:02)` 或 `(12:02)`。 */
44
44
  const HEADING_SUFFIX_RE = /\s*[((]\s*\d{1,2}:\d{2}\s*[))]\s*$/
45
45
 
46
+ /**
47
+ * ★M2.5a(2026-09-18):**退化标题判据**。
48
+ *
49
+ * 规则① 原先假设「有标题 ⇒ 标题即摘要」,该假设**只对日志成立**:
50
+ * - 日志 `## 主题(12:02)` → 去掉时间后缀 = 真摘要 ✅
51
+ * - **项目/用户级笔记 `## 2026-09-17`(纯日期)→ 去后缀无效 ⇒ L0 = 一个日期** ❌
52
+ *
53
+ * 实测(本机真数据,`artifacts/_probe-l0-quality.mjs`):
54
+ * project-notes 16/16 废(100%)、user-notes 46/50 废(92%)、
55
+ * log 449 条 0 废、reflection 21 条 0 废。
56
+ * ⇒ 笔记层(**结论层**)在语义臂里向量彼此几乎相同 ⇒ **几乎不可检索**。
57
+ *
58
+ * 判据:标题若「不携带可检索语义」(纯日期/纯时间/纯符号数字/序号/短代号)
59
+ * ⇒ **不采信,继续下探到规则②**(首个 `- ` 条目首句——那才是笔记的真实内容)。
60
+ *
61
+ * 边界:只拦**明显退化**的形态,绝不拦正常标题(宁可漏判,不可误伤)。
62
+ */
63
+ const DEGENERATE_HEADING_RES = Object.freeze([
64
+ /^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}$/, // 2026-09-17 / 2026/9/17
65
+ /^\d{4}年\d{1,2}月(\d{1,2}日)?$/, // 2026年9月17日
66
+ /^\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}$/, // 09-17 / 9.17.2026
67
+ /^\d{1,2}:\d{2}(:\d{2})?$/, // 12:02
68
+ /^[\d\s\-/.·、_]+$/, // 纯数字/符号
69
+ /^第?\s*\d+\s*(章|节|部分|阶段|步|次|条|天|周|月|年)?$/, // 第3节 / 3
70
+ /^[A-Za-z]{0,3}\d+(\.\d+)*$/, // v1 / P3 / v1.2.3
71
+ ])
72
+
73
+ /** 标题是否退化(不携带可检索语义)。 */
74
+ function isDegenerateHeading(title) {
75
+ const t = clean(title)
76
+ if (!t) return true
77
+ // ★ 只按**形态**判,不按长度判。
78
+ // 教训(2026-09-18,被既有套件抓出):曾加 `t.length < 4` 作为"过短即退化"的判据,
79
+ // 结果误伤 `主题甲`(3 个 CJK 字符,是合法标题)⇒ 标题被拒 ⇒ 落到规则② ⇒
80
+ // 把正文里的隐私标记当成了 L0(`smoke-test-l0-index.mjs` 的"索引不得含原文"断言真红)。
81
+ // 中文标题短而有效是常态,**长度不是质量信号**。
82
+ // 纪律:宁可漏判(退化标题照旧被当摘要),不可误伤(合法标题被拒而拉入正文)。
83
+ return DEGENERATE_HEADING_RES.some((re) => re.test(t))
84
+ }
85
+
86
+ /** 行级标题正则(与规则① 同一口径,供剥标题用)。 */
87
+ const HEADING_LINE_RE = /^\s{0,3}#{1,6}\s+(.+?)\s*$/
88
+
46
89
  export const L0_DEFAULTS = Object.freeze({
47
90
  maxChars: 160,
48
91
  minChars: 15,
@@ -112,6 +155,74 @@ export function classifyLayerPre(source) {
112
155
  return null
113
156
  }
114
157
 
158
+ /** R4-B(2026-09-18):分层**呈现**用的层序(最高优先在前)。
159
+ *
160
+ * 与 `recall-fusion.js:FUSION_LAYER_ORDER_V1` / `tier-layer-inject.js:TIER_LAYER_ORDER_V1`
161
+ * **同序但独立声明** —— 本仓既有约定:跨模块不共享同一常量对象,避免一处改动静默改变另一处语义;
162
+ * 三者相等由断言锁定(见 smoke-test-r4-recall-layers-pre.mjs)。 */
163
+ export const L0_LAYER_DISPLAY_ORDER_V1 = Object.freeze(['project', 'whiteboard', 'user', 'reflection', 'log'])
164
+
165
+ /** R4-B:层 → 呈现标题。措辞刻意让「结论」与「流水」一眼可分 —— 这正是检索区分度问题的靶心:
166
+ * 语义臂内部不分层(`index.js` 纯分数 sort)时,模型看到的 MEMORY.md(结论)与 2026-09-xx.md(流水)
167
+ * 在视觉上完全同级,含金量被数量淹没。 */
168
+ export const L0_LAYER_LABELS_V1 = Object.freeze({
169
+ project: '结论层 · 项目笔记',
170
+ user: '结论层 · 用户级记忆',
171
+ whiteboard: '结论层 · 白板/账本',
172
+ reflection: '反思层 · 每日反思',
173
+ log: '流水层 · 每日日志',
174
+ })
175
+
176
+ /** 判不出层时的兜底标题:**不猜层**,如实说"未分层",且固定排在最后(避免给出错误的层次暗示)。 */
177
+ export const L0_LAYER_UNKNOWN_LABEL_V1 = '未分层'
178
+
179
+ /**
180
+ * R4-B(2026-09-18):把检索命中**按层分组**(**只改呈现,不改排序**)。
181
+ *
182
+ * 硬契约(与本仓"回滚必须逐字节相同"纪律对齐):
183
+ * ① **不增删条目**:输出各组条目总数 = 入参长度,且**层内保持入参原相对顺序**
184
+ * ⇒ 调用方无需改排序;`#finalRank` 标签仍在行内,排序信息可完全还原;
185
+ * ② 组顺序 = `L0_LAYER_DISPLAY_ORDER_V1`;判不出层的固定归**末组**;
186
+ * ③ 空入参 / 非数组 / 取层函数抛错 → **永不抛**(fail-soft:呈现层失败绝不打断检索)。
187
+ *
188
+ * 调用方职责:**只有一组时不打标题** ⇒ 单一层(如纯日志命中)的输出与旧版逐字节相同。
189
+ *
190
+ * @param {Array} items 命中条目
191
+ * @param {(item:any)=>string} layerOf 取层函数(返回值经 classifyLayerPre 归一)
192
+ * @returns {Array<{layer:string,label:string,items:Array}>}
193
+ */
194
+ export function groupL0ByLayerPre(items, layerOf) {
195
+ const groups = []
196
+ try {
197
+ if (!Array.isArray(items) || !items.length) return groups
198
+ const fn = typeof layerOf === 'function' ? layerOf : () => ''
199
+ /** @type {Map<string, Array>} 层 → 条目(插入序即入参相对序) */
200
+ const byLayer = new Map()
201
+ for (const it of items) {
202
+ let layer = null
203
+ try { layer = classifyLayerPre(fn(it)) } catch (_) { layer = null }
204
+ const key = layer || ''
205
+ if (!byLayer.has(key)) byLayer.set(key, [])
206
+ byLayer.get(key).push(it)
207
+ }
208
+ // 已知层按契约层序;未知层紧随其后,保持首次出现序。
209
+ // ★注:`classifyLayerPre` 的返回值域是**闭合词表**(L0_LAYERS 或 null),故下面
210
+ // `l && ...` 分支**当前不可达** —— 保留它是有意的前向兼容:将来 L0_LAYERS 扩容时,
211
+ // 旧版调用方不会把新层误并入"未分层",而是照实单独成组。
212
+ // (变异演示已证实:改这一段不会让任何断言变红 —— 它确实不参与当下语义。)
213
+ const ordered = L0_LAYER_DISPLAY_ORDER_V1.filter((l) => byLayer.has(l))
214
+ for (const l of byLayer.keys()) if (l && ordered.indexOf(l) === -1) ordered.push(l)
215
+ for (const l of ordered) {
216
+ groups.push({ layer: l, label: L0_LAYER_LABELS_V1[l] || l, items: byLayer.get(l) })
217
+ }
218
+ // 未分层固定末组(不猜层)—— **不走通用流程**:它不属层词表,也不该排在结论层之前。
219
+ if (byLayer.has('')) {
220
+ groups.push({ layer: '', label: L0_LAYER_UNKNOWN_LABEL_V1, items: byLayer.get('') })
221
+ }
222
+ } catch (_) { /* fail-soft:分组失败即降级为"无分组",调用方按单组处理,检索不受影响 */ }
223
+ return groups
224
+ }
225
+
115
226
  /**
116
227
  * 三层契约 I5 的**检索侧过滤谓词**:只有 `status === 'current'` 的记录可进入检索与注入。
117
228
  *
@@ -128,6 +239,77 @@ export function isCurrentPre(record) {
128
239
  return st === 'current'
129
240
  }
130
241
 
242
+ /**
243
+ * ★R4-A(2026-09-19 定稿):**检索侧**准入谓词 —— I5 的**收窄**版。
244
+ *
245
+ * ── 变更依据(用户两次修正后定稿)──────────────────────────────────
246
+ * 原 I5(`THREE-LAYER-CONTRACT.md:183`):「非 `current` 的条目在**检索结果与注入内容两处**都被过滤」。
247
+ *
248
+ * ① 用户第一次修正:「**返回但标记是正确的**」⇒ 检索侧改为放行 + 标记。
249
+ * ② 用户第二次修正(**推翻 agent 的"retracted 继续硬挡"方案**):
250
+ * 「这个 retracted **不是过滤掉**……**并不是挡,我感觉是备注**。
251
+ * 因为比如说你之前踩过 3 次的那个坑,如果你不记住这个教训的话,你还会再踩。」
252
+ *
253
+ * ⇒ **三态一律返回、一律标记**。agent 原方案按「检索视角」分(过时的别干扰判断);
254
+ * 用户按「**学习视角**」分(**做错的事恰恰最该被记住**)。
255
+ * 对记忆系统而言后者才是目的:`retracted` 不是垃圾数据,它是**一条教训**——
256
+ * 把它藏起来 = **系统性遗忘自己的错误**,正是「还会再踩」的成因。
257
+ *
258
+ * ⇒ **本谓词不再过滤任何已知 status**,只对**未知值 fail-closed**
259
+ * (防将来新增枚举时静默放行 —— 枚举类常量必须配断言兜底,本仓纪律)。
260
+ *
261
+ * ── 与注入侧的分工(本谓词只用于检索侧)─────────────────────────
262
+ * 注入侧仍用 `isCurrentPre`:注入是**常驻目录**(B0 仅 800 token),
263
+ * 拿常驻预算装过时条目会挤掉现行结论;检索是**按需**的,装一条带警告的过时结论划算。
264
+ * ⇒ **两处判据不同是有意为之**,不是漏改(`tier-layer-inject.js` 继续 import `isCurrentPre`)。
265
+ *
266
+ * @param {{status?:string}|null|undefined} record 记录(或任何带 status 的对象)
267
+ * @returns {boolean} 是否可进入检索结果
268
+ */
269
+ export function isRetrievablePre(record) {
270
+ if (!record || typeof record !== 'object') return true
271
+ const st = record.status
272
+ if (st === undefined || st === null || st === '') return true
273
+ // 已知三态一律放行(含 retracted —— 它是教训,不是垃圾);未知值 fail-closed。
274
+ return L0_STATUSES.includes(st)
275
+ }
276
+
277
+ /** 检索侧标记语的取值域(枚举类常量须配断言兜底 —— 本仓纪律)。 */
278
+ export const L0_SUPERSEDED_MARK_V1 = '⚠已作废'
279
+ export const L0_RETRACTED_MARK_V1 = '⚠已撤回'
280
+
281
+ /**
282
+ * 为「返回但标记」生成**呈现后缀**(R4-A 的可见面)。
283
+ *
284
+ * 契约(**逐字节向后兼容**是硬约束):
285
+ * `current` / 缺 status / 未知 / 非对象 ⇒ 空串(旧行为零变化)
286
+ * `superseded` ⇒ ` ⚠已作废(已被 mem_<32hex> 取代)`
287
+ * `retracted` ⇒ ` ⚠已撤回(原因:<reason>;更正见 mem_<32hex>)`
288
+ *
289
+ * `reason` 才是「教训」的正文,比 status 本身有价值(用户第二次修正的要点)。
290
+ * id 只认 `/^mem_[0-9a-f]{32}$/`,不合法一律丢弃 —— 防止把任意文本拼进检索呈现。
291
+ *
292
+ * @param {{status?:string, supersededBy?:string, reason?:string}} record
293
+ * @returns {string} 追加到条目末尾的后缀(含前导空格;无需标记时为空串)
294
+ */
295
+ export function supersededMarkPre(record) {
296
+ try {
297
+ if (!record || typeof record !== 'object') return ''
298
+ const idOf = (v) => (/^mem_[0-9a-f]{32}$/.test(String(v || '').trim()) ? String(v).trim() : '')
299
+ if (record.status === 'superseded') {
300
+ const safe = idOf(record.supersededBy)
301
+ return ' ' + L0_SUPERSEDED_MARK_V1 + (safe ? '(已被 ' + safe + ' 取代)' : '(已被更新结论取代)')
302
+ }
303
+ if (record.status === 'retracted') {
304
+ const safe = idOf(record.supersededBy)
305
+ const reason = record.reason ? String(record.reason).replace(/[\r\n]+/g, ' ').trim().slice(0, 80) : ''
306
+ const tail = (reason ? '原因:' + reason + ';' : '') + (safe ? '更正见 ' + safe : '已被撤回')
307
+ return ' ' + L0_RETRACTED_MARK_V1 + '(' + tail + ')'
308
+ }
309
+ return ''
310
+ } catch (_) { return '' } // fail-soft:标记失败绝不影响检索
311
+ }
312
+
131
313
  /**
132
314
  * 按锚点切分记忆条目。
133
315
  *
@@ -175,13 +357,15 @@ export function extractL0Pre(body, opts = {}) {
175
357
 
176
358
  const lines = text.split(/\r?\n/)
177
359
 
178
- // ① 主题块标题
360
+ // ① 主题块标题(★M2.5a:加质量门 —— 退化标题不采信,继续下探)
179
361
  for (const line of lines) {
180
- const h = /^\s{0,3}#{1,6}\s+(.+?)\s*$/.exec(line)
181
- if (h) {
182
- const title = clean(h[1]).replace(HEADING_SUFFIX_RE, '')
183
- if (title) return { l0: cut(title, maxChars), source: 'heading' }
184
- }
362
+ const h = HEADING_LINE_RE.exec(line)
363
+ if (!h) continue
364
+ const title = clean(h[1]).replace(HEADING_SUFFIX_RE, '')
365
+ if (!title) continue
366
+ // 退化标题(纯日期/纯时间/序号/短代号)不携带可检索语义 ⇒ 跳过,让规则② 取真实内容。
367
+ if (isDegenerateHeading(title)) continue
368
+ return { l0: cut(title, maxChars), source: 'heading' }
185
369
  }
186
370
 
187
371
  // ② 首个 `- ` 条目:先取首句,过短再并接(并接时剥列表标记与时间戳)
@@ -195,8 +379,11 @@ export function extractL0Pre(body, opts = {}) {
195
379
  return { l0: cut(s, maxChars), source: 'firstSentence' }
196
380
  }
197
381
 
198
- // ③ 兜底:正文截断
199
- const flat = clean(text.replace(/\s+/g, ' '))
382
+ // ③ 兜底:正文截断(★M2.5a:先剥标题行 —— 否则退化标题会被压进 L0)
383
+ // 场景:条目既无标题(或标题已退化被跳过)又无 `- ` 列表项时落到此处;
384
+ // 直接压平会把 `## 2026-09-09` 变成 L0 开头(实测 user-notes 修后仍见该形态)。
385
+ const bodyLines = lines.filter((l) => !HEADING_LINE_RE.test(l))
386
+ const flat = clean((bodyLines.length ? bodyLines : lines).join('\n').replace(/\s+/g, ' '))
200
387
  return { l0: cut(flat, maxChars), source: 'truncate' }
201
388
  }
202
389
 
@@ -207,23 +394,45 @@ export function extractL0Pre(body, opts = {}) {
207
394
  * 老调用方读 `id / l0 / source / chars / bodyChars` 完全不受影响,签名与调用方式不变。
208
395
  * 层归属:`opts.layer`(层名 / 路径 / 键名,经 classifyLayerPre)→ `opts.path` → `L0_DEFAULT_LAYER`。
209
396
  *
397
+ * ★R4-A(2026-09-18):新增**可选** `opts.statusOf(id)` 注入解析器 —— 让本模块保持
398
+ * **零 IO 纯函数**(存储格式属写入侧 G3 的决定,本层只透传,不猜存储)。
399
+ * 未注入时行为与从前**逐字节相同**(`status` 恒 `'current'`)⇒ 向后兼容。
400
+ *
210
401
  * @param {string} text 文件内容
211
- * @param {{maxChars?:number, minChars?:number, layer?:string, path?:string}} [opts]
212
- * @returns {Array<{id:string, l0:string, source:string, chars:number, bodyChars:number, layer:string, status:string}>}
402
+ * @param {{maxChars?:number, minChars?:number, layer?:string, path?:string,
403
+ * statusOf?:(id:string)=>({status?:string, supersededBy?:string}|null|undefined)}} [opts]
404
+ * @returns {Array<{id:string, l0:string, source:string, chars:number, bodyChars:number, layer:string, status:string, supersededBy?:string}>}
213
405
  */
214
406
  export function buildL0IndexPre(text, opts = {}) {
215
407
  const { items } = parseMemoryItemsPre(text)
216
408
  const layer = resolveLayerPre(opts)
409
+ const statusOf = opts && typeof opts.statusOf === 'function' ? opts.statusOf : null
217
410
  const out = items.map((it) => {
218
411
  const r = extractL0Pre(it.body, opts)
219
- // TODO(C1 写入侧,未实现):status 恒为 current。superseded(被新记录经 supersedes 替代改正)
220
- // 与 retracted(人工判定作废)需要**写入侧先落盘状态**(fact-store 的 supersedes 边 / 审计视图标记),
221
- // 本层不做存储、只负责透传;存储与双层过滤(契约 I5)在 C2 接线 + 写入侧一起做。
222
- const status = 'current'
223
- return {
412
+ // C1 写入侧(G3)落盘状态后,由调用方经 `statusOf` 注入;本层只透传。
413
+ // fail-closed 语义在**消费侧**(isRetrievablePre 未知值挡下),此处只做形态净化。
414
+ let status = 'current'
415
+ let supersededBy
416
+ let reason
417
+ if (statusOf) {
418
+ try {
419
+ const st = statusOf(it.id)
420
+ if (st && typeof st === 'object') {
421
+ if (typeof st.status === 'string' && L0_STATUSES.includes(st.status)) status = st.status
422
+ if (typeof st.supersededBy === 'string' && st.supersededBy) supersededBy = st.supersededBy
423
+ // R4-A:撤回原因 —— 这才是「教训」的正文,比 status 本身有价值。
424
+ if (typeof st.reason === 'string' && st.reason) reason = st.reason
425
+ }
426
+ } catch (_) { /* fail-soft:状态解析失败 ⇒ 按 current 处理,绝不影响索引构建 */ }
427
+ }
428
+ const rec = {
224
429
  id: it.id, l0: r.l0, source: r.source, chars: r.l0.length, bodyChars: it.body.length,
225
430
  layer, status,
226
431
  }
432
+ // 只在真的有值时附字段 —— 保证「无状态时」输出与旧版逐字节相同。
433
+ if (supersededBy) rec.supersededBy = supersededBy
434
+ if (reason) rec.reason = reason
435
+ return rec
227
436
  })
228
437
  out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
229
438
  return out
@@ -249,7 +458,13 @@ function cut(s, n) {
249
458
  /** 首句过短时,并接后续句子直到 minChars 或 maxChars。每句先剥列表标记与时间戳前缀。 */
250
459
  function growToMin(first, full, minChars, maxChars) {
251
460
  if (first.length >= minChars) return first
252
- const flat = clean(full.replace(/\s+/g, ' '))
461
+ // ★ issue #71 修复(2026-09-19):**保留换行结构**。
462
+ // 旧实现先 `full.replace(/\s+/g, ' ')` 把换行压成空格,**再用含 `\n` 的 SENTENCE_SPLIT_RE 切分**
463
+ // ⇒ `\n` 分支恒不命中(死代码),整段多行正文被当成**一个 part**;
464
+ // 而剥前缀的 `/^\s*[-*+]\s+/` 与 `LEAD_TIME_RE` 都是 `^` 锚定,只剥得掉该 part 的**首个**标记
465
+ // ⇒ 第 2 行起的 `- HH:MM` 原样进入 L0(嵌入/检索输入)。
466
+ // 现改为:只把「行内连续空白」压成单空格,**行界 `\n` 保留** ⇒ 逐行切分、逐 part 剥前缀。
467
+ const flat = clean(full.replace(/[^\S\r\n]+/g, ' ').replace(/\r\n?/g, '\n'))
253
468
  if (!flat || flat.length <= first.length) return first
254
469
  const parts = flat.split(SENTENCE_SPLIT_RE)
255
470
  .map((x) => clean(String(x).replace(/^\s*[-*+]\s+/, '').replace(LEAD_TIME_RE, '')))
package/lib/m4-corpus.js CHANGED
@@ -94,11 +94,17 @@ export function loadCorpusSnapshot(catalog, io = {}, opts = {}) {
94
94
  if (buf.length > INDEX_MAX_FILE_BYTES) { dropped.push({ stage: 'corpus', reason: 'oversized', sourceRef: source.sourceRef }); continue }
95
95
  const fileDigest = sha256Hex(buf)
96
96
  if (fileDigest !== sc.fileDigest) { dropped.push({ stage: 'corpus', reason: 'stale-source', sourceRef: source.sourceRef }); continue }
97
+ // ★ issue #66 修复(2026-09-19):**byteStart/byteEnd 是相对「剥掉 BOM 之后」的坐标系**
98
+ // (契约见 `memory-anchor.js` 的 `parseAnchors` 与其 C18 断言;`fileDigest` 则按含 BOM 的整 buffer 算)。
99
+ // 旧实现直接 `buf.subarray(...)` 用在**含 BOM 的原 buffer** 上 ⇒ BOM 文件整体偏 3 字节
100
+ // ⇒ `recordDigest` 恒不等 ⇒ 该文件**全部记录被判 record-stale 丢弃**,从语料/召回静默消失。
101
+ // 修法:消费侧对齐——有 BOM 时先剥掉再切片(无 BOM 时 body === buf,零行为变化)。
102
+ const body = (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) ? buf.subarray(3) : buf
97
103
  for (const r of (sc.records || [])) {
98
- if (!Number.isInteger(r.byteStart) || !Number.isInteger(r.byteEnd) || r.byteStart < 0 || r.byteEnd > buf.length) {
104
+ if (!Number.isInteger(r.byteStart) || !Number.isInteger(r.byteEnd) || r.byteStart < 0 || r.byteEnd > body.length) {
99
105
  dropped.push({ stage: 'corpus', reason: 'record-stale', memoryId: r.memoryId, sourceRef: source.sourceRef }); continue
100
106
  }
101
- if (sha256Hex(buf.subarray(r.byteStart, r.byteEnd)) !== r.recordDigest) {
107
+ if (sha256Hex(body.subarray(r.byteStart, r.byteEnd)) !== r.recordDigest) {
102
108
  dropped.push({ stage: 'corpus', reason: 'record-stale', memoryId: r.memoryId, sourceRef: source.sourceRef }); continue
103
109
  }
104
110
  records.push({
@@ -98,7 +98,14 @@ export function createIndexSyncHostPre(opts = {}) {
98
98
  drop('generation-reset', 0, ctxKey)
99
99
  }
100
100
  // epoch 变化(worker 重启) → 缓存失效,必须重同步
101
- if (cached && epoch && cached.epoch !== epoch) {
101
+ // ★ issue #69 修复(2026-09-19):判据补上「**cached.epoch 为空 ⇒ 一律视为无效**」。
102
+ // 旧写法 `if (cached && epoch && cached.epoch !== epoch)` 里的 **`epoch &&` 前置**是缺陷所在:
103
+ // 当 `currentEpoch()` 返回 null(**client 未启动 / 已 exit 但 stdio 未排空**)时,该分支**永不成立**
104
+ // ⇒ 一条 `cached.epoch === null` 的旧缓存(冷启动时采样于 lazy spawn 之前,见 `:90`)会被一直保留
105
+ // ⇒ 对刚 spawn 出来的空索引 worker 误判 `ready=true`,该段 `context_push` 拿到零候选零激活,
106
+ // 且 `context-host` 的 `indexNotReady` 也不点亮(readyRes.ready 为 true)⇒ **连降级留痕都没有**。
107
+ // 保留旧语义的另一半(epoch 非空时要求严格相等)——worker 重启换 epoch 仍会正常失效重同步。
108
+ if (cached && (!cached.epoch || (epoch && cached.epoch !== epoch))) {
102
109
  readyCache.delete(k)
103
110
  stats.epochReset++
104
111
  drop('epoch-reset', 0, ctxKey)