@a9i5k4/dsh-auto-memory 3.0.0 → 3.0.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.
Files changed (90) hide show
  1. package/README.md +19 -7
  2. package/README.zh-CN.md +19 -7
  3. package/docs/FRONTEND-CO-CREATION.md +191 -0
  4. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  5. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  6. package/docs/PROMO-PROMPT-3.0.md +100 -0
  7. package/docs/USER-GUIDE.en.md +2 -2
  8. package/docs/USER-GUIDE.zh-CN.md +2 -2
  9. package/docs/WHITEPAPER.md +207 -0
  10. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
  11. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
  12. package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
  13. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
  14. package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
  15. package/docs/internal/FEATURE-INVENTORY.md +531 -0
  16. package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
  17. package/docs/internal/G3-DESIGN-20260918.md +82 -0
  18. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
  19. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
  20. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
  21. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
  22. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
  23. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
  24. package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
  25. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
  26. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
  27. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
  28. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
  29. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
  30. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
  31. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
  32. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
  33. package/docs/internal/PROGRESS-20260917.md +93 -0
  34. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
  35. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
  36. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
  37. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
  38. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
  39. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
  40. package/docs/internal/RESUME-20260918.md +171 -0
  41. package/docs/internal/RESUME-20260919.md +104 -0
  42. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
  43. package/docs/internal/ROADMAP-20260917-WEEK.md +134 -0
  44. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +13 -3
  45. package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
  46. package/docs/internal/T6-EXECUTION-20260920.md +130 -0
  47. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
  48. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
  49. package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
  50. package/docs/internal/THREE-LAYER-CONTRACT.md +10 -1
  51. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
  52. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
  53. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  54. package/lib/activation-host.js +63 -9
  55. package/lib/board-mode.js +1 -1
  56. package/lib/client.js +892 -27
  57. package/lib/config-io.js +156 -0
  58. package/lib/context-bridge.js +3 -0
  59. package/lib/context-host.js +16 -9
  60. package/lib/degrade.js +385 -0
  61. package/lib/dsh-home.js +143 -0
  62. package/lib/episodic-store.js +52 -2
  63. package/lib/evidence-store.js +8 -1
  64. package/lib/fact-store.js +21 -2
  65. package/lib/index-sync.js +13 -1
  66. package/lib/index.js +1507 -158
  67. package/lib/intent-clean-safe.js +258 -40
  68. package/lib/l0-extract.js +231 -16
  69. package/lib/m4-corpus.js +8 -2
  70. package/lib/m7-index-sync-host.js +8 -1
  71. package/lib/memory-envelope.js +6 -1
  72. package/lib/memory-hub.js +127 -12
  73. package/lib/memory-index.js +4 -2
  74. package/lib/note-status-apply.js +118 -0
  75. package/lib/note-status.js +196 -0
  76. package/lib/procedure-store.js +84 -3
  77. package/lib/python-sidecar-client.js +29 -3
  78. package/lib/recall-fusion.js +83 -12
  79. package/lib/rules-edit.js +159 -0
  80. package/lib/semantic-decide.js +41 -8
  81. package/lib/semantic-js.js +51 -6
  82. package/lib/shadow-host.js +3 -5
  83. package/lib/skill-export-host.js +153 -0
  84. package/lib/skill-export.js +239 -0
  85. package/lib/storage-manage.js +6 -0
  86. package/lib/temporal-parse.js +191 -159
  87. package/lib/tier0-catalog.js +45 -3
  88. package/lib/wb-contract.js +198 -2
  89. package/lib/wb-sidecar.js +54 -3
  90. package/package.json +1 -1
@@ -191,7 +191,12 @@ export function composeMemoryEnvelopePre(input = {}) {
191
191
  })
192
192
  } else {
193
193
  const before = only.text.length
194
- const room = Math.max(1, lim - mark.length)
194
+ // ★2026-09-20 移植(issue #94③ / PR #100):预算必须先扣掉同分项内 **must 段**的占用 ——
195
+ // 旧实现 room = lim − mark 不扣 must ⇒ 超限纯由 must 造成时,对本已放得下的段也追加
196
+ // 「已截断」标记(越截越长)并注入失实降级说明。
197
+ const mustChars = bucketTotal(bucket) - only.text.length
198
+ const available = Math.max(0, lim - mustChars)
199
+ const room = Math.max(1, available - mark.length)
195
200
  only.text = only.text.slice(0, room) + mark
196
201
  truncated.push({ bucket, kind: only.kind, charsBefore: before, charsAfter: only.text.length, reason: 'over-limit-truncated' })
197
202
  degradations.push({
package/lib/memory-hub.js CHANGED
@@ -1,4 +1,4 @@
1
- import { stripRuntimeIntentPre } from './intent-clean-safe.js'
1
+ import { stripRuntimeIntentPre, looksRuntimeResiduePre } from './intent-clean-safe.js'
2
2
  import { isObservationOnlyPre } from './procedure-observation.js'
3
3
  /**
4
4
  * M8-3 Memory Hub 编排器(docs/PROJECT-FREEZE-AND-ROADMAP.md M8/M9; 记忆中枢)。
@@ -51,6 +51,9 @@ export function createMemoryHubPre(opts = {}) {
51
51
  const { createEpisodicStorePre } = opts._stores || {}
52
52
  const { createFactStorePre } = opts._stores || {}
53
53
  const { createProcedureStorePre } = opts._stores || {}
54
+ // ★T10:机械 procedure 切片开关(默认关闭;缺省值在 index.js 的 DEFAULT_CONFIG)。
55
+ // 这里只读、不判定语义 —— 具体用途见 crossFeed() 的 procedure 分支。
56
+ const mechanicalProcedureFeedEnabled = opts.mechanicalProcedureFeedEnabled === true
54
57
  const nowFn = typeof opts.now === 'function' ? opts.now : () => Date.now()
55
58
  const log = typeof opts.log === 'function' ? opts.log : () => {}
56
59
 
@@ -96,10 +99,25 @@ export function createMemoryHubPre(opts = {}) {
96
99
  return { consumed: 'procedure', outcome: r.ok ? 'observed' : r.reason }
97
100
  }
98
101
  if (layer === 'episodic' && stores.episodic) {
99
- // episodic_candidate 已是巩固后的 episode,直接喂
100
- const r = stores.episodic.restore({ schemaVersion: 1, episodes: [row] })
101
- stats.consumedEpisodic++
102
- return { consumed: 'episodic', outcome: r.ok ? 'restored' : r.reason }
102
+ // ★ 2026-09-19 上游 PR #77 / issue #63 同步落地(**P0 数据丢失**):
103
+ // 旧实现走 `stores.episodic.restore({schemaVersion:1, episodes:[row]})`,而 `restore()` 是
104
+ // **快照整体替换**语义(先清空 episodes)。候选行普遍缺 validateEpisodePre 必填字段
105
+ // ⇒ 校验拒(restored:0),**但 episodes 已被清空、current 已置 null**,且仍返回 {ok:true}
106
+ // ⇒ 一次 ingest 抹掉全部已巩固 episode,不可逆。
107
+ // 现改走**增量导入**:不清空、逐条校验、按 episodeId 幂等。
108
+ // fallback:老 store 无 importEpisodes 时退化为"不导入",**绝不回退到 restore**。
109
+ if (typeof stores.episodic.importEpisodes !== 'function') {
110
+ stats.skipped++
111
+ return { skipped: true, reason: 'no-import-episodes' }
112
+ }
113
+ const r = stores.episodic.importEpisodes([row])
114
+ if (r.ok && r.imported > 0) {
115
+ stats.consumedEpisodic++
116
+ return { consumed: 'episodic', outcome: 'imported' }
117
+ }
118
+ // 如实区分:非法行 / 重复行都不算 consumed(旧实现把两种情况都记成 'restored')
119
+ stats.skipped++
120
+ return { skipped: true, reason: r.duplicates > 0 ? 'duplicate-episode' : 'rejected:' + r.rejected }
103
121
  }
104
122
  } catch (e) {
105
123
  log('memory-hub ingest error: ' + String(e && e.message || e))
@@ -135,7 +153,10 @@ export function createMemoryHubPre(opts = {}) {
135
153
  // 而丢失富候选的 successCriteria(晋升永久卡死)。现在显式标 observationOnly=true,
136
154
  // 并在 title 前过滤运行时信封(避免注入文本变成技能标题)。
137
155
  const procedureIntent = stripRuntimeIntentPre(ep.intent).trim()
138
- if (ep.success && stores.procedures && procedureIntent && procedureIntent !== '(未提取)' && ep.actions && ep.actions.length) {
156
+ // ★T10(2026-09-20 用户报「技能名/内容看不懂」):**门控机械切片**。
157
+ // 关闭时整段跳过 ⇒ 不再产出 `intent.slice(0,40)` 这种机械观察行。
158
+ // ⚠️ 只包住 procedure 分支:下方 fact 分支与循环外的逻辑一律不受影响。
159
+ if (mechanicalProcedureFeedEnabled && ep.success && stores.procedures && procedureIntent && procedureIntent !== '(未提取)' && ep.actions && ep.actions.length) {
139
160
  const cand = {
140
161
  title: procedureIntent.slice(0, 40),
141
162
  riskLevel: 'low',
@@ -149,9 +170,24 @@ export function createMemoryHubPre(opts = {}) {
149
170
  }
150
171
  // 有未决事项的 episode → 事实候选(不直接固化,留给 judgement)
151
172
  if (ep.unresolved && ep.unresolved.length && stores.facts) {
173
+ // ★ T1-1(2026-09-19 真机追加):**fact 分支必须与 procedure 分支同样过清洗器**。
174
+ // 根因:上面 procedure 分支早已调 `stripRuntimeIntentPre`(:152),但本分支直接用
175
+ // **未清洗**的 `ep.intent` ⇒ 运行时信封/U+FFFD 原样进 facts.json ⇒ 前端面板乱码(⑩-a),
176
+ // 且经 `hubFlushTick` 写回 `MEMORY.md` 污染注入面与语义语料(⑩-b)。实测证据:
177
+ // `fact_ac4920327df6601f25200d66e52df71f` 的 subject 含 22 个 U+FFFD;
178
+ // 另两条的 object 内嵌 `Current DSH file policy: …` / `Approval prompts are disabled …`。
179
+ // 清洗后再截断,且**空值回退**到 'episode'(保持原 `|| 'episode'` 语义不变)。
180
+ // ★ F5(行内残留):清洗器是**按行**判断的,真人与信封挤在同一行时整行必须保留
181
+ // (删了会丢人话)⇒ 此时「清洗后是否变化」检测不到脏。故再补一道 `looksRuntimeResiduePre`:
182
+ // 该字段**含任何运行时痕迹即整体判脏并丢弃**(置空 ⇒ 走下面的空值回退),
183
+ // 而不是把半截信封写进 facts.json。
184
+ const rawIntent = String(ep.intent == null ? '' : ep.intent)
185
+ const rawObject = String(ep.unresolved[0] == null ? '' : ep.unresolved[0])
186
+ const factIntent = looksRuntimeResiduePre(rawIntent) ? '' : stripRuntimeIntentPre(rawIntent).trim()
187
+ const factObject = looksRuntimeResiduePre(rawObject) ? '' : stripRuntimeIntentPre(rawObject).trim()
152
188
  const cand = {
153
- scope: 'Workspace', subject: ep.intent.slice(0, 30) || 'episode', predicate: '有未决事项',
154
- object: ep.unresolved[0].slice(0, 60), sourceKind: 'inference',
189
+ scope: 'Workspace', subject: factIntent.slice(0, 30) || 'episode', predicate: '有未决事项',
190
+ object: factObject.slice(0, 60), sourceKind: 'inference',
155
191
  sourceClass: 'semantic-candidate', provenance: [ep.episodeId],
156
192
  }
157
193
  const r = stores.facts.upsert(cand)
@@ -198,7 +234,25 @@ export function createMemoryHubPre(opts = {}) {
198
234
  .filter((p) => p.stage !== 'active' && p.stage !== 'deprecated')
199
235
  // issue #30:如实暴露 observationOnly —— 审批面必须能区分"可晋升技能"与"仅观察线索",
200
236
  // 否则使用者会对着一个结构上不可能晋升的条目反复点晋升。
201
- .map((p) => ({ procedureId: p.procedureId, title: p.title, stage: p.stage, riskLevel: p.riskLevel, evidence: p.evidence, pinned: !!p.pinned, observationOnly: isObservationOnlyPre(p) })),
237
+ // ★R2(2026-09-20):补 `promotion` 判定投影 —— 用户要「晋升原因必须显式展示」。
238
+ // 走**纯只读**的 evaluatePromotion(),绝不在 overview 里碰 promote()(它写盘)。
239
+ // 失败时置 null(fail-soft:判定异常不得拖垮整个面板)。
240
+ .map((p) => {
241
+ let promotion = null
242
+ try {
243
+ promotion = stores.procedures.evaluatePromotion
244
+ ? stores.procedures.evaluatePromotion(p.procedureId)
245
+ : null
246
+ } catch (_) { promotion = null }
247
+ return {
248
+ procedureId: p.procedureId, title: p.title, stage: p.stage, riskLevel: p.riskLevel,
249
+ evidence: p.evidence, pinned: !!p.pinned, observationOnly: isObservationOnlyPre(p),
250
+ // R4 预览用:晋升后会注入的真实 checklist 文本
251
+ steps: Array.isArray(p.steps) ? p.steps.slice(0, 12) : [],
252
+ successCriteria: Array.isArray(p.successCriteria) ? p.successCriteria.slice(0, 6) : [],
253
+ promotion,
254
+ }
255
+ }),
202
256
  stats: stores.procedures.getStats ? stores.procedures.getStats() : null,
203
257
  } : null,
204
258
  }
@@ -236,11 +290,24 @@ export function factCandidateFromRow(row) {
236
290
  if (kind !== 'semantic_candidate' && kind !== 'profile_candidate') return null
237
291
  const sourceIds = Array.isArray(row.sourceIds) ? row.sourceIds : []
238
292
  if (!sourceIds.length) return null
293
+ // ★ T1-2(2026-09-19 真机追加):这是 fact 的**第二条入口**(judgement shadow 行),
294
+ // 与 `crossFeed` 的 fact 分支同源,同样必须过清洗器 —— 否则「补了 A 口、漏了 B 口」,
295
+ // 脏数据仍会经本函数进入 facts.json(再被 hubFlushTick 写回 MEMORY.md)。
296
+ // ★ F5:清洗器按行判断,**行内混信封**时整行保留 ⇒ 再加一道 `looksRuntimeResiduePre`,
297
+ // 命中即置空(走下面的空值回退),不把半截信封写进库。
298
+ const rawSubject = String(row.subject == null ? '' : row.subject)
299
+ const rawPredicate = String(row.predicate == null ? '' : row.predicate)
300
+ const rawObject = row.object === undefined || row.object === null ? null : String(row.object)
301
+ const cSubject = looksRuntimeResiduePre(rawSubject) ? '' : stripRuntimeIntentPre(rawSubject).trim()
302
+ const cPredicate = looksRuntimeResiduePre(rawPredicate) ? '' : stripRuntimeIntentPre(rawPredicate).trim()
303
+ const cObject = rawObject === null
304
+ ? null
305
+ : (looksRuntimeResiduePre(rawObject) ? null : (stripRuntimeIntentPre(rawObject).trim() || null))
239
306
  return {
240
307
  scope: row.scope === 'User' ? 'User' : 'Workspace',
241
- subject: String(row.subject || sourceIds[0]),
242
- predicate: String(row.predicate || 'relation'),
243
- object: row.object === undefined || row.object === null ? null : String(row.object),
308
+ subject: cSubject || String(sourceIds[0]),
309
+ predicate: cPredicate || 'relation',
310
+ object: cObject,
244
311
  sourceKind: 'inference',
245
312
  sourceClass: kind === 'profile_candidate' ? 'profile-candidate' : 'semantic-candidate',
246
313
  provenance: [...sourceIds],
@@ -267,3 +334,51 @@ export function procedureCandidateFromRow(row) {
267
334
  successCriteria: Array.isArray(row.successCriteria) ? row.successCriteria : [],
268
335
  }
269
336
  }
337
+
338
+ /**
339
+ * ④ 教训 → 观察型候选(2026-09-19 R5)。
340
+ *
341
+ * **用户裁定(2026-09-18 00:20)**:
342
+ * 「**教训肯定得进 C 啊,它不自动晋升,但是可以形成候选,模型也可以通过搜索搜索到**。
343
+ * 因为教训那边,我现在**自动注入的硬约束也是某种教训,把它上升到了约束层面**。」
344
+ *
345
+ * **通路设计**:
346
+ * `retracted` 条目 + 撤回原因(reason)→ 本函数 → `observationOnly: true` 观察型候选
347
+ * → `procedure-store.observe()` → **`promote()` 短路返回 `observation-only` ⇒ 永不自动晋升**
348
+ * → 但 `query()` 可检索到 ⇒ 模型能主动搜到「这条曾经被判错、原因是什么」。
349
+ *
350
+ * **三条硬约束(缺一即错)**:
351
+ * 1. **必须 `observationOnly: true`** —— 这是「永不自动晋升」的**唯一结构保证**。
352
+ * 若漏掉,它会变成可晋升富候选,与用户裁定直接冲突。
353
+ * 2. **`sourceMemoryIds` 必须带被撤回条目的 id** —— 教训的 provenance 是那条 retracted 记忆本身;
354
+ * 不得留空(留空会让 `addEvidence` 的 sourceMemoryIds 匹配计数恒零,且与
355
+ * episode→观察行 那条通路的语义混淆)。
356
+ * 注意:这与 `crossFeed()` 里 `sourceMemoryIds: []` 的**有意留空不同** ——
357
+ * 那里是"episode 只提供线索、不得凭空造 provenance";这里 id 是**真实存在**的。
358
+ * 3. **title/intent 必须先过信封清洗** —— 否则运行时信封会再次变成"教训标题"(H-3 同款)。
359
+ *
360
+ * @param {{memoryId?: string, title?: string, text?: string, reason?: string, retractedReason?: string}} row
361
+ * @returns {object|null} procedure candidate(observationOnly),输入不合法返回 null
362
+ */
363
+ export function lessonCandidateFromRetractedPre(row) {
364
+ if (!row || typeof row !== 'object') return null
365
+ const memoryId = String(row.memoryId || '').trim()
366
+ // 只认严格锚点 id 形态:与 G3 状态行同一套判据(防任意文本被当成 provenance 拼进去)
367
+ if (!/^mem_[0-9a-f]{32}$/.test(memoryId)) return null
368
+ const rawTitle = String(row.title || row.text || '').trim()
369
+ const title = stripRuntimeIntentPre(rawTitle).trim()
370
+ if (!title) return null
371
+ const reasonRaw = String(row.reason || row.retractedReason || '').trim()
372
+ const reason = stripRuntimeIntentPre(reasonRaw).trim().replace(/\s+/g, ' ').slice(0, 120)
373
+ return {
374
+ title: ('教训:' + title).slice(0, 60),
375
+ riskLevel: 'low',
376
+ // 步骤形态:明说「这是一条教训」+ 撤回原因(原因才是教训的正文)
377
+ steps: [reason ? ('曾判错,原因:' + reason + '。下次避免:' + title.slice(0, 60)) : ('曾判错:' + title.slice(0, 80))],
378
+ // ★ 约束 1:永不自动晋升的结构保证
379
+ observationOnly: true,
380
+ sourceEpisodes: [],
381
+ // ★ 约束 2:真实 provenance(非凭空构造)
382
+ sourceMemoryIds: [memoryId],
383
+ }
384
+ }
@@ -55,10 +55,12 @@ function decodedLine(buf, s, e) {
55
55
  function buildIndex(sourceFile, content, prev) {
56
56
  const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')
57
57
  if (buf.length > INDEX_MAX_FILE_BYTES) {
58
- return { sourceFile, fileDigest: '', sourceVersion: (prev && prev.version) || 1, skipped: true, records: [] }
58
+ // ★2026-09-20 移植(issue #92 / PR #97):宿主缓存只写 sourceVersion,旧读 prev.version
59
+ // 在生产路径恒为 undefined ⇒ 版本封顶 2、未变重读回退 1。
60
+ return { sourceFile, fileDigest: '', sourceVersion: (prev && prev.sourceVersion) || 1, skipped: true, records: [] }
59
61
  }
60
62
  const fileDigest = createHash('sha256').update(buf).digest('hex')
61
- const sourceVersion = prev && prev.fileDigest === fileDigest ? (prev.version || 1) : (prev ? (prev.version || 1) + 1 : 1)
63
+ const sourceVersion = prev && prev.fileDigest === fileDigest ? (prev.sourceVersion || 1) : (prev ? (prev.sourceVersion || 1) + 1 : 1)
62
64
  const lines = splitByteLines(buf)
63
65
  const records = []
64
66
  let cur = null
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 结论层状态 · **条目级应用**(G3 写盘的核心纯函数)
3
+ *
4
+ * 职责:把一条 `status` 落到**指定 memoryId 的条目正文末尾**,返回**新文本**(不写盘)。
5
+ * 与 `note-status.js` 的分工:
6
+ * - `note-status.js` = 状态行的**语法**(渲染/解析/剥离)
7
+ * - 本模块 = 状态行的**定位与落点**(在文件里找到那条、放到末尾)
8
+ *
9
+ * ── 与既有写入通道的关系(重要)───────────────────────────────
10
+ * 本模块**不代替** `memory-writer` 事务写入,只产出**新全文**;
11
+ * 落盘仍由调用方走既有通道(备份/校验/无 BOM 等纪律不绕过)。
12
+ *
13
+ * ── 为什么必须「只动目标条目」─────────────────────────────────
14
+ * MEMORY.md 是**用户可见的明文**且 25+ 锚点共存。任何"顺手重排/格式化"都会:
15
+ * ① 让无关条目的 `recordDigest` 变化 ⇒ sidecar sourceVersion 无谓 +1 ⇒ 全量缓存失效;
16
+ * ② 制造巨大的 diff,用户在 GUI 里看不出"到底改了什么"。
17
+ * ⇒ 契约:**除目标条目的状态行外,逐字节保持原样**(含 CRLF 行尾)。
18
+ *
19
+ * 纪律:纯函数、零 IO、fail-soft(不改动即返回 null,绝不返回半成品文本)。
20
+ */
21
+ import { NOTE_STATUS_OPEN_V1, renderStatusLinePre, stripStatusLinePre, statusOfBodyPre } from './note-status.js'
22
+
23
+ /** 锚点:与 `l0-extract.js` / `memory-anchor.js` 同形态(此处独立声明,避免耦合)。 */
24
+ const ANCHOR_RE = /<!--\s*memory:(mem_[0-9a-f]{32})\s*-->/g
25
+
26
+ /**
27
+ * 定位一条条目在原文中的**正文区间**。
28
+ *
29
+ * 语义与 `parseMemoryItemsPre` 一致:anchor marker **其后**的内容归该条,
30
+ * 直到**下一个** marker 之前(或文件末尾)。
31
+ *
32
+ * @param {string} text 文件全文
33
+ * @param {string} memoryId
34
+ * @returns {{start:number, end:number, id:string}|null} 正文的 [start,end) 字符区间
35
+ */
36
+ export function locateRecordBodyPre(text, memoryId) {
37
+ try {
38
+ const src = String(text == null ? '' : text)
39
+ if (!src || !/^mem_[0-9a-f]{32}$/.test(String(memoryId || ''))) return null
40
+ ANCHOR_RE.lastIndex = 0
41
+ const marks = []
42
+ let m
43
+ while ((m = ANCHOR_RE.exec(src)) !== null) {
44
+ // 同时记录 marker 的**真实**起止(不重建字符串 —— 锚点允许空白浮动)
45
+ marks.push({ id: m[1], start: m.index, end: m.index + m[0].length })
46
+ if (m.index === ANCHOR_RE.lastIndex) ANCHOR_RE.lastIndex++
47
+ }
48
+ for (let i = 0; i < marks.length; i++) {
49
+ if (marks[i].id !== memoryId) continue
50
+ const start = marks[i].end
51
+ // 正文止于**下一个 marker 的起始**(该 marker 及其后内容不属于本条)
52
+ const end = i + 1 < marks.length ? marks[i + 1].start : src.length
53
+ return { start, end, id: memoryId }
54
+ }
55
+ return null
56
+ } catch (_) { return null }
57
+ }
58
+
59
+ /**
60
+ * ★ 主函数:给指定条目应用状态,返回**新全文**。
61
+ *
62
+ * 行为契约:
63
+ * - 目标不存在 ⇒ `null`(**fail-soft**:绝不凭空创建条目)
64
+ * - `status === 'current'` ⇒ **剥掉**既有状态行(撤销通道;正文其余不变)
65
+ * - 已是目标状态且 reason/by 未变 ⇒ 返回**原文**(幂等,调用方可据此跳过写盘)
66
+ * - 其余 ⇒ 剥旧状态行 + 在**正文末尾**追加新状态行
67
+ * - **行尾风格沿用原文件**(CRLF 保持 CRLF;本仓文件全 CRLF)
68
+ * - 任何异常 ⇒ `null`(绝不返回半成品)
69
+ *
70
+ * @param {string} text 文件全文
71
+ * @param {string} memoryId 目标条目
72
+ * @param {string} status current | superseded | retracted
73
+ * @param {{supersededBy?:string, reason?:string}} [opts]
74
+ * @returns {string|null} 新全文;不可应用时为 null
75
+ */
76
+ export function applyStatusToRecordPre(text, memoryId, status, opts = {}) {
77
+ try {
78
+ const src = String(text == null ? '' : text)
79
+ if (!src) return null
80
+ const loc = locateRecordBodyPre(src, memoryId)
81
+ if (!loc) return null
82
+
83
+ const seg = src.slice(loc.start, loc.end)
84
+ // CRLF 感知:本仓文件全 CRLF,必须沿用,否则整文件 diff 爆炸
85
+ const eol = seg.includes('\r\n') ? '\r\n' : '\n'
86
+
87
+ // 先把段落按当前 EOL 归一化切分,处理后再拼回
88
+ const bodyClean = stripStatusLinePre(seg)
89
+ const trimmed = bodyClean.replace(/[\r\n\s]+$/, '')
90
+
91
+ const line = renderStatusLinePre(status, opts)
92
+ let next = line ? trimmed + eol + line + eol : (trimmed ? trimmed + eol : '')
93
+ // 段落与下一个 marker 之间保留一个空行(与既有文件形态一致)
94
+ next = next ? next + eol : next
95
+
96
+ const out = src.slice(0, loc.start) + next + src.slice(loc.end)
97
+ // 幂等:无变化 ⇒ 返回原文(调用方可据此跳过写盘,避免无谓 sourceVersion +1)
98
+ return out === src ? src : out
99
+ } catch (_) { return null }
100
+ }
101
+
102
+ /**
103
+ * 只读查询:某条目当前状态(供接线侧判断是否需要写)。
104
+ * 与 `statusOfBodyPre` 同源,此处补上「文件级」定位。
105
+ *
106
+ * @returns {{status:string, supersededBy?:string, reason?:string}|null} 条目不存在 ⇒ null
107
+ */
108
+ export function readRecordStatusPre(text, memoryId) {
109
+ try {
110
+ const src = String(text == null ? '' : text)
111
+ const loc = locateRecordBodyPre(src, memoryId)
112
+ if (!loc) return null
113
+ return statusOfBodyPre(src.slice(loc.start, loc.end))
114
+ } catch (_) { return null }
115
+ }
116
+
117
+ /** 供反向锁使用:确认本模块**不碰锚点语法**。 */
118
+ export const NOTE_STATUS_MARKER_V1 = NOTE_STATUS_OPEN_V1
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 结论层状态(G3)· **纯函数核心** —— 零 IO、零副作用、可单测。
3
+ *
4
+ * ⚠️ **状态:形态候选,未接线**(2026-09-19)
5
+ * 本模块只提供**判定与格式**能力;**没有任何调用方**,因此不产生任何写入。
6
+ * 写盘接线必须等 `G3-DISK-FORMAT-GAP-20260919.md` §4 的 **F1/F2 拍板**后才做:
7
+ * F1 = 状态行的磁盘语法;F2 = 「显式声明取代」的模型侧写法。
8
+ * 理由(误判代价不对称):漏判 = 维持现状(可接受);**误判 = 有效结论被当废纸**。
9
+ *
10
+ * ── 为什么需要本模块(设计稿未覆盖的缺口)─────────────────────────
11
+ * `MEMORY-GOVERNANCE-20260917.md` §8.1 断言「地基已存在,只差写入方」。
12
+ * 实施前核查证明:该断言**只对内存索引列成立**;`status` 的**磁盘表示完全不存在**:
13
+ * ① `memory-anchor.js:28` `MARKER_RE` 是**严格锚定** ⇒ 锚点行加不了属性;
14
+ * ② 行首含 `MARKER_OPEN` 但不匹配 ⇒ `malformed-anchor` **冲突**(:214)⇒ 写入被拒(storage-manage:181);
15
+ * ③ 全仓**零处**从磁盘读 `status`(`statusOf` 无任何生产者);
16
+ * ④ sidecar 不能承载(`memory-writer.js:434` 明示其为**可重建的派生数据**)。
17
+ * ⇒ 必须先定一个**磁盘形态**,这正是 F1 要拍的板。
18
+ *
19
+ * ── 形态选择(方案 A/B 已排除,见 GAP 文档 §3)────────────────────
20
+ * 方案 A(锚点加属性)❌ 撞 ①②,属契约级改动;
21
+ * 方案 B(独立状态文件)❌ 违反 S10.4「不新建状态源」;
22
+ * **方案 C(条目正文内保留行)✅ 采用** —— 状态归条目自身,零语法风险,用户可读。
23
+ *
24
+ * ── ★ 关键实现约束:不得污染 L0 ──────────────────────────────────
25
+ * `extractL0Pre` 规则③(`l0-extract.js:385`)会把**所有非标题行压平**成 L0。
26
+ * 若状态行留在正文里,无标题条目的 L0 会变成「⚠已作废…」⇒ **检索质量被污染**
27
+ * (与 M2.5a 修的是同一类问题)。
28
+ * ⇒ 消费方**必须先 `stripStatusLinePre(body)` 再抽取 L0**,或经 `statusOf` 提供状态。
29
+ */
30
+
31
+ /** 状态行开标记。**刻意不复用** `MARKER_OPEN`(`<!-- memory:`)—— 复用会撞锚点契约。 */
32
+ export const NOTE_STATUS_OPEN_V1 = '<!-- dsh-status:'
33
+
34
+ /**
35
+ * 状态行语法(F1 候选 (a):HTML 注释形态)。
36
+ *
37
+ * 为什么用 HTML 注释而非裸行 `status: superseded`:
38
+ * ① 与仓储既有习俗一致(锚点本身也是 HTML 注释);
39
+ * ② 与正文散文**零碰撞**(裸行会被「正文恰好以 status: 开头」误命中);
40
+ * ③ 渲染后不可见,不干扰人读 Markdown。
41
+ * 属性值支持裸词与双引号两种写法(reason 含空格时用引号)。
42
+ */
43
+ const NOTE_STATUS_RE = /^<!--\s*dsh-status:\s*(current|superseded|retracted)\s*((?:\w+=(?:"[^"]*"|[^\s"]+)\s*)*)-->$/
44
+
45
+ /** 属性解析:`by=mem_xxx` / `reason="含空格 的原因"`。 */
46
+ const NOTE_STATUS_ATTR_RE = /(\w+)=(?:"([^"]*)"|([^\s"]+))/g
47
+
48
+ /** 只认本仓 id 形态,防把任意文本拼进状态(同 R4-A 的 `supersededMarkPre` 纪律)。 */
49
+ const MEMORY_ID_RE = /^mem_[0-9a-f]{32}$/
50
+
51
+ /** 三态取值域(与 `l0-extract.js` 的 `L0_STATUSES` 同源;此处独立声明以免循环依赖)。 */
52
+ export const NOTE_STATUSES_V1 = Object.freeze(['current', 'superseded', 'retracted'])
53
+
54
+ /** reason 上限:状态行是**元数据**不是正文,超长即视为脏数据。 */
55
+ export const NOTE_STATUS_REASON_MAX_V1 = 120
56
+
57
+ /**
58
+ * 渲染状态行(**纯函数**)。
59
+ *
60
+ * `current` ⇒ 返回 `''`(**当前态无需落盘**:缺失即默认 current,少写字节、少一处漂移源)。
61
+ * 未知 status / 非法 id ⇒ 返回 `''`(**fail-closed**:宁可不写,绝不写错 —— 与消费侧同纪律)。
62
+ *
63
+ * @param {string} status current | superseded | retracted
64
+ * @param {{supersededBy?:string, reason?:string}} [opts]
65
+ * @returns {string} 状态行(含 `\n` 时不带);不可渲染时为空串
66
+ */
67
+ export function renderStatusLinePre(status, opts = {}) {
68
+ try {
69
+ if (status !== 'superseded' && status !== 'retracted') return ''
70
+ let line = NOTE_STATUS_OPEN_V1 + ' ' + status
71
+ const by = opts && opts.supersededBy ? String(opts.supersededBy).trim() : ''
72
+ if (MEMORY_ID_RE.test(by)) line += ' by=' + by
73
+ const reason = opts && opts.reason
74
+ ? String(opts.reason).replace(/[\r\n]+/g, ' ').replace(/--+>/g, '').trim().slice(0, NOTE_STATUS_REASON_MAX_V1)
75
+ : ''
76
+ if (reason) line += ' reason="' + reason.replace(/"/g, "'") + '"'
77
+ return line + ' -->'
78
+ } catch (_) { return '' } // fail-soft:渲染失败绝不抛(与 supersededMarkPre 同纪律)
79
+ }
80
+
81
+ /**
82
+ * 解析单行状态。非状态行 ⇒ `null`(不抛、不猜)。
83
+ *
84
+ * 返回对象**只含合法字段**:非法 `by` 被丢弃(不写进结果),未知属性忽略。
85
+ *
86
+ * @param {string} line 单行文本
87
+ * @returns {{status:string, supersededBy?:string, reason?:string}|null}
88
+ */
89
+ export function parseStatusLinePre(line) {
90
+ try {
91
+ const s = String(line == null ? '' : line).trim()
92
+ if (!s) return null
93
+ const m = NOTE_STATUS_RE.exec(s)
94
+ if (!m) return null
95
+ const out = { status: m[1] }
96
+ NOTE_STATUS_ATTR_RE.lastIndex = 0
97
+ let a
98
+ while ((a = NOTE_STATUS_ATTR_RE.exec(m[2] || '')) !== null) {
99
+ const key = a[1]
100
+ const val = a[2] !== undefined ? a[2] : a[3]
101
+ if (key === 'by') {
102
+ if (MEMORY_ID_RE.test(String(val || ''))) out.supersededBy = String(val)
103
+ } else if (key === 'reason') {
104
+ const r = String(val || '').replace(/[\r\n]+/g, ' ').trim().slice(0, NOTE_STATUS_REASON_MAX_V1)
105
+ if (r) out.reason = r
106
+ }
107
+ }
108
+ return out
109
+ } catch (_) { return null }
110
+ }
111
+
112
+ /**
113
+ * 从**条目正文**解析状态。
114
+ *
115
+ * 语义:**最后一个**状态行胜出(后写覆盖先写 —— 状态是"当前值"不是"历史轨迹")。
116
+ * 无状态行 ⇒ `{status:'current'}`(与索引层「缺失即默认」口径完全一致)。
117
+ *
118
+ * @param {string} body 条目正文(不含锚点行)
119
+ * @returns {{status:string, supersededBy?:string, reason?:string}}
120
+ */
121
+ export function statusOfBodyPre(body) {
122
+ const s = String(body == null ? '' : body)
123
+ const lines = s.split(/\r?\n/)
124
+ for (let i = lines.length - 1; i >= 0; i--) {
125
+ const hit = parseStatusLinePre(lines[i])
126
+ if (hit) return hit
127
+ }
128
+ return { status: 'current' }
129
+ }
130
+
131
+ /**
132
+ * 剥掉状态行,返回**可安全送往 L0 抽取**的正文。
133
+ *
134
+ * ★ 这是本模块存在的主要理由之一:不剥 ⇒ 无标题条目的 L0 会被状态行污染(见文件头注释)。
135
+ * 纪律:只删**整行**匹配的;行内出现(如正文引用该语法)**保持原样**交由
136
+ * `checkReservedSyntaxInContent` 一类守卫处理,本函数不做静默改写。
137
+ *
138
+ * @param {string} body
139
+ * @returns {string}
140
+ */
141
+ export function stripStatusLinePre(body) {
142
+ const s = String(body == null ? '' : body)
143
+ if (!s.includes(NOTE_STATUS_OPEN_V1)) return s
144
+ return s.split(/\r?\n/).filter((ln) => parseStatusLinePre(ln) === null).join('\n')
145
+ }
146
+
147
+ /**
148
+ * 追加状态行到正文末尾(**纯函数**,不写盘)。
149
+ *
150
+ * 约束(见文件头):
151
+ * - 先剥旧状态行 ⇒ 结果是**幂等**的(同参数重复调用不叠加);
152
+ * - 只追加在**末尾**,绝不前置(前置会污染 L0 规则②/③);
153
+ * - `current` ⇒ 只剥不写。
154
+ *
155
+ * @param {string} body
156
+ * @param {string} status
157
+ * @param {{supersededBy?:string, reason?:string}} [opts]
158
+ * @returns {string}
159
+ */
160
+ export function withStatusLinePre(body, status, opts = {}) {
161
+ const base = stripStatusLinePre(body).replace(/[\r\n\s]+$/, '')
162
+ const line = renderStatusLinePre(status, opts)
163
+ if (!line) return base
164
+ return base + '\n' + line
165
+ }
166
+
167
+ /**
168
+ * ★F2 候选 (b):从**正文**识别「显式声明取代」。
169
+ *
170
+ * ⚠️ 本函数**未接线**,且**不建议作为首个版本的唯一通路**:
171
+ * 用户裁定的门槛是「**(a) 只认显式声明**」,而**结构化参数**(F2 候选 a)才是最纯的显式声明;
172
+ * 从自然语言里正则识别**必然**存在误判,与「宁可漏判不可误判」冲突。
173
+ * 保留它用于:(b)/(c) 落地时作为**第二通路**,或作为「M2 lint 只报告」的输入。
174
+ *
175
+ * 判据(**双条件**,缺一不可 —— 这是保守性的关键):
176
+ * ① 出现取代**动作词**(取代/替换/作废/推翻/废弃 supersede)/ 或显式参数;
177
+ * ② 同时出现**合法 memoryId**。
178
+ * 仅命中其一 ⇒ 返回 `null`(**只报告不标**)。
179
+ *
180
+ * @param {string} content 新写入的正文
181
+ * @returns {{target:string, evidence:string}|null}
182
+ */
183
+ export function detectSupersedeIntentPre(content) {
184
+ try {
185
+ const s = String(content == null ? '' : content)
186
+ if (!s) return null
187
+ const ACT_RE = /(取代|替换|作废|废弃|推翻|supersede[sd]?|replaces?)/i
188
+ const ID_RE = /mem_[0-9a-f]{32}/g
189
+ const ids = s.match(ID_RE)
190
+ if (!ids || !ids.length) return null
191
+ if (!ACT_RE.test(s)) return null
192
+ // 去重保序;多目标时**不自动全标**(保守),交调用方逐个确认
193
+ const uniq = [...new Set(ids)]
194
+ return { target: uniq[0], evidence: (s.match(ACT_RE) || [''])[0] }
195
+ } catch (_) { return null }
196
+ }