@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.
Files changed (134) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +13 -8
  3. package/docs/HANDBOOK.md +92 -52
  4. package/docs/USER-GUIDE.en.md +9 -9
  5. package/docs/USER-GUIDE.zh-CN.md +9 -9
  6. package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
  7. package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
  8. package/lib/activation-host.js +6 -1
  9. package/lib/client.js +1376 -255
  10. package/lib/config-io.js +59 -6
  11. package/lib/context-host.js +17 -2
  12. package/lib/episodic-store.js +90 -16
  13. package/lib/evidence-store.js +27 -1
  14. package/lib/fact-store.js +463 -41
  15. package/lib/hub-io.js +217 -0
  16. package/lib/index.js +904 -87
  17. package/lib/intent-clean-safe.js +1 -1
  18. package/lib/jsonl-tail-cursor.js +75 -0
  19. package/lib/m7-index-sync-host.js +4 -6
  20. package/lib/memory-hub.js +62 -7
  21. package/lib/migrate-pack.js +351 -0
  22. package/lib/note-status.js +9 -1
  23. package/lib/procedure-store.js +252 -31
  24. package/lib/procedure-switch.js +38 -0
  25. package/lib/python-setup.js +109 -27
  26. package/lib/python-sidecar-client.js +285 -8
  27. package/lib/recall-stats.js +242 -0
  28. package/lib/rules-layer.js +106 -15
  29. package/lib/semantic-js.js +25 -7
  30. package/lib/shadow-host.js +41 -2
  31. package/package.json +6 -2
  32. package/docs/internal/ACCEPT-35-LIVE.md +0 -143
  33. package/docs/internal/ACCEPTANCE-20260914.md +0 -90
  34. package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
  35. package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
  36. package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
  37. package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
  38. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +0 -397
  39. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +0 -351
  40. package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -191
  41. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +0 -181
  42. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
  43. package/docs/internal/BATTLE-PLAN-20260917.md +0 -871
  44. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
  45. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
  46. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
  47. package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
  48. package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
  49. package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
  50. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
  51. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
  52. package/docs/internal/FEATURE-INVENTORY.md +0 -531
  53. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
  54. package/docs/internal/G-SERIES-EXECUTION-20260917.md +0 -248
  55. package/docs/internal/G3-DESIGN-20260918.md +0 -82
  56. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +0 -92
  57. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
  58. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
  59. package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
  60. package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
  61. package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
  62. package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
  63. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +0 -309
  64. package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
  65. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +0 -120
  66. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +0 -74
  67. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +0 -175
  68. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +0 -389
  69. package/docs/internal/ISSUE10-PLAN-20260919.md +0 -254
  70. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +0 -468
  71. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +0 -150
  72. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +0 -114
  73. package/docs/internal/KICKOFF-P0.md +0 -254
  74. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +0 -79
  75. package/docs/internal/MASTER-PLAN-3.0.md +0 -411
  76. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +0 -309
  77. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
  78. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
  79. package/docs/internal/NEXT-VERSION-TODO.md +0 -95
  80. package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
  81. package/docs/internal/PENDING-FIXES-20260916.md +0 -289
  82. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +0 -705
  83. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +0 -649
  84. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +0 -225
  85. package/docs/internal/PROGRESS-20260917.md +0 -93
  86. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +0 -128
  87. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +0 -163
  88. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +0 -127
  89. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +0 -140
  90. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +0 -138
  91. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +0 -218
  92. package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
  93. package/docs/internal/RELEASE-PROCESS.md +0 -99
  94. package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
  95. package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
  96. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
  97. package/docs/internal/RESUME-20260918.md +0 -171
  98. package/docs/internal/RESUME-20260919.md +0 -104
  99. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
  100. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +0 -198
  101. package/docs/internal/ROADMAP-20260917-WEEK.md +0 -439
  102. package/docs/internal/ROADMAP.md +0 -106
  103. package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
  104. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -185
  105. package/docs/internal/S10-GAP-INVENTORY-20260917.md +0 -239
  106. package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
  107. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
  108. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
  109. package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
  110. package/docs/internal/T6-EXECUTION-20260920.md +0 -130
  111. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +0 -146
  112. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +0 -89
  113. package/docs/internal/THESIS-OUTLINE-20260918.md +0 -147
  114. package/docs/internal/THREE-LAYER-CONTRACT.md +0 -219
  115. package/docs/internal/TODO-BACKLOG.md +0 -263
  116. package/docs/internal/TODO-GRAPH.html +0 -715
  117. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
  118. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
  119. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
  120. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
  121. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
  122. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
  123. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +0 -297
  124. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +0 -104
  125. package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
  126. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
  127. package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
  128. package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
  129. package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
  130. package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
  131. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
  132. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
  133. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
  134. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +0 -230
package/lib/config-io.js CHANGED
@@ -27,12 +27,34 @@
27
27
  * - CRLF、无 BOM。
28
28
  */
29
29
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
30
- import { writeFile } from 'node:fs/promises'
30
+ import { rename, writeFile } from 'node:fs/promises'
31
+ import { setTimeout as sleepDelayPre } from 'node:timers/promises'
31
32
  import path from 'node:path'
32
33
 
33
34
  /** 临时文件后缀。与既有 hub 落盘的 `.tmp` 同族,便于排查时一眼认出。 */
34
35
  export const ATOMIC_TMP_SUFFIX_V1 = '.tmp'
35
36
 
37
+ /**
38
+ * ★2026-09-22(并发写修复):tmp 路径的**唯一段** = pid + 进程内自增序号。
39
+ *
40
+ * 病症:tmp 名原先**只由目标路径决定**(`file + '.tmp'`)⇒ 对同一目标并发保存时,
41
+ * 先完成者把 tmp 改名走,后到者的 rename 找不到源文件而抛 **ENOENT**(非瞬时错误、
42
+ * 按纪律不得重试)⇒ 后一次保存静默失败(只回 `{ok:false}`,界面看起来"改了没生效")。
43
+ * 实测复现:对同一目标并发 5 次 `writeTextAtomicPre`,部分调用返回
44
+ * `{ok:false, error:'ENOENT … rename … .tmp -> …'}`(见 `tests/smoke/smoke-test-p3-batchb-guards-pre.mjs` P3B-2d)。
45
+ *
46
+ * 处方:每次调用生成一个独立的 tmp 名 —— 同进程并发各写各的,跨进程并发靠 pid 区分;
47
+ * 最后一次 rename 胜出,**原子性不变量不变**(目标任意时刻仍是旧完整版或新完整版)。
48
+ *
49
+ * 边界:仍落在**同目录**且保持 `.tmp` 前缀(`rename` 必须同文件系统);失败路径照旧
50
+ * `rmSync` 清理。唯一代价是**进程崩溃**时会在目标目录留下一个 `.tmp-<pid>-<n>`
51
+ * (可识别、可手工清理;不自动扫描删除,避免误删他人正在写的 tmp)。
52
+ */
53
+ let _tmpSeqPre = 0
54
+ function tmpUniqueSuffixPre() {
55
+ return '-' + process.pid + '-' + (++_tmpSeqPre)
56
+ }
57
+
36
58
  /** 损坏留存的默认后缀前缀。 */
37
59
  export const CORRUPT_QUARANTINE_PREFIX_V1 = '.corrupt-'
38
60
 
@@ -70,7 +92,7 @@ export function quarantineFilePreSync(file, opts = {}) {
70
92
  * @returns {{ok: boolean, path?: string, error?: string}} 结构化结果(fail-soft,不抛)。
71
93
  */
72
94
  export function writeTextAtomicPreSync(file, text) {
73
- const tmp = file + ATOMIC_TMP_SUFFIX_V1
95
+ const tmp = file + ATOMIC_TMP_SUFFIX_V1 + tmpUniqueSuffixPre()
74
96
  try {
75
97
  mkdirSync(path.dirname(file), { recursive: true })
76
98
  writeFileSync(tmp, String(text), 'utf8')
@@ -83,18 +105,49 @@ export function writeTextAtomicPreSync(file, text) {
83
105
  }
84
106
  }
85
107
 
108
+ /**
109
+ * **有界退避 rename**(★P3-12,2026-09-22)。
110
+ *
111
+ * 病症:异步原子写原先走裸 `renameSync` —— Windows 上目标文件被别的进程持有句柄时会抛
112
+ * EPERM/EACCES/EBUSY,于是**整次设置保存失败**(用户侧表现为"改了设置没生效")。
113
+ * 处方:仅对这三个**瞬时**错误码退避重试;首项延迟为 0 ⇒ **正常路径零延迟变化**。
114
+ *
115
+ * ⚠️ **为什么不 import `fs-retry.js`**:本模块的**零运行时依赖纪律**(见文件头)
116
+ * 被 `tests/smoke/smoke-test-t8-configio-pre.mjs` 的 T8c-6 钉死 —— 配置 I/O 层不得因为
117
+ * 任何兄弟模块的加载期故障而失效。故此处**内联**同一套退避刻度(只用 node: 内置),
118
+ * 语义与 `fs-retry.js` 的 `retryRename` 保持一致。
119
+ */
120
+ const RENAME_RETRY_DELAYS_V1 = Object.freeze([0, 50, 150, 400, 1000])
121
+ const TRANSIENT_RENAME_CODES_V1 = new Set(['EPERM', 'EACCES', 'EBUSY'])
122
+ async function renameBoundedRetryPre(from, to) {
123
+ const last = RENAME_RETRY_DELAYS_V1.length - 1
124
+ for (let attempt = 0; attempt <= last; attempt++) {
125
+ const wait = RENAME_RETRY_DELAYS_V1[attempt]
126
+ if (wait) await sleepDelayPre(wait)
127
+ try {
128
+ await rename(from, to)
129
+ return
130
+ } catch (e) {
131
+ const transient = TRANSIENT_RENAME_CODES_V1.has(e && e.code)
132
+ if (!transient || attempt === last) throw e
133
+ }
134
+ }
135
+ }
136
+
86
137
  /**
87
138
  * **异步原子写**:tmp → rename。语义与同步版一致。
88
139
  *
89
- * 只把**可能较大**的正文写入交给异步 IO;rename 本身极快,保持同步调用。
90
- * 这样与既有 `persistConfigPre` 的异步口径对齐,不引入额外的 await 链。
140
+ * 只把**可能较大**的正文写入交给异步 IO。
141
+ *
142
+ * ★P3-12(2026-09-22):rename 走 `renameBoundedRetryPre`(有界退避;首项 0 ⇒ 正常路径零延迟变化)。
143
+ * 同步版 `writeTextAtomicPreSync` 的契约是同步,保持 `renameSync` 不动。
91
144
  */
92
145
  export async function writeTextAtomicPre(file, text) {
93
- const tmp = file + ATOMIC_TMP_SUFFIX_V1
146
+ const tmp = file + ATOMIC_TMP_SUFFIX_V1 + tmpUniqueSuffixPre()
94
147
  try {
95
148
  mkdirSync(path.dirname(file), { recursive: true })
96
149
  await writeFile(tmp, String(text), 'utf8')
97
- renameSync(tmp, file)
150
+ await renameBoundedRetryPre(tmp, file)
98
151
  return { ok: true, path: file }
99
152
  } catch (e) {
100
153
  try { rmSync(tmp, { force: true }) } catch (_) {}
@@ -28,6 +28,7 @@ import { createPythonContextSinkPre } from './context-sink-python.js'
28
28
  import { buildQueryPlan, lexicalSearch, GATE_POLICY_VERSION, LEXICAL_POLICY_VERSION } from './shadow-retrieval.js'
29
29
  import { fuseD6Pre } from './semantic-js.js'
30
30
  import { buildSourceCatalog, loadCorpusSnapshot, CorpusRegistry, canonicalize } from './m4-corpus.js'
31
+ import { resolveProcedureInjectEnabledPre } from './procedure-switch.js'
31
32
 
32
33
  const MAX_DROPS_RING = 64
33
34
  /** 同一 drop 原因的日志限流窗口(ms):窗口内只写首条,其余计数。防会话回放刷屏(2026-09-10)。 */
@@ -163,7 +164,11 @@ export function createContextHost(opts = {}) {
163
164
  return bridge
164
165
  }
165
166
  function storeFor() {
166
- if (!store) store = new EvidenceEventStore({ root: path.join(dshHome(), 'memory', 'evidence') })
167
+ if (!store) store = new EvidenceEventStore({
168
+ root: path.join(dshHome(), 'memory', 'evidence'),
169
+ // ★P3-9(组2,2026-09-22):注入统一节流诊断通道;不注入时 store 内回落 console.error。
170
+ onDiag: (key, msg) => { try { diagCtx(msg) } catch (_) {} },
171
+ })
167
172
  return store
168
173
  }
169
174
  function evidenceRootExists() {
@@ -534,7 +539,12 @@ export function createContextHost(opts = {}) {
534
539
  // 集合不变则缓存命中);不可用回退词法 2-gram。阈值 0.6(e5 相关内容带)。单 offer 出口。
535
540
  try {
536
541
  const hub = engine._memoryHub
537
- const skillEnabled = engine.config.memoryHubEnabled === true && engine.config.procedurePromotionEnabled !== false
542
+ // B-2:旧键 procedurePromotionEnabled 是**语义错配的兼容别名**——它的键名与界面文案写的是
543
+ // 「技能固化与晋升」,但它实际控制的是「已激活的技能是否随本轮上下文注入」这道总闸,
544
+ // 与晋升流程本身无关。改由接口契约模块 lib/procedure-switch.js 统一解析:
545
+ // 新键 procedureInjectEnabled 优先,缺省回退旧键,两键都缺省按开启处理;
546
+ // 老用户显式关掉过总闸的,行为不翻面。此处只替换开关解析口径,不新增任何前置条件。
547
+ const skillEnabled = engine.config.memoryHubEnabled === true && resolveProcedureInjectEnabledPre(engine.config)
538
548
  if (hub && skillEnabled && hub.stores && hub.stores.procedures) {
539
549
  const actives = hub.stores.procedures.activeProcedures()
540
550
  let hit = null
@@ -813,6 +823,11 @@ export function createContextHost(opts = {}) {
813
823
  const st = states.get(runtime)
814
824
  if (st) st.disposed = true
815
825
  states.delete(runtime)
826
+ // ★P3-3(a)(2026-09-22):pathsByKey 原先是「模块生命周期内永不释放」的集合——
827
+ // 写入点只有 capturePaths(每会话一条),全文件零 delete/clear;且 disposeAll 走本函数,
828
+ // 连全量退出路径也清不掉 ⇒ findProvenance 会在已释放的 runtime 上命中陈旧快照。
829
+ // 键口径与 :316/:482/:683 三处读点一致(String(runtime.key || ''))。
830
+ pathsByKey.delete(String(runtime.key || ''))
816
831
  }
817
832
  function disposeAll(reason) {
818
833
  for (const pair of collectStates()) disposeRuntime(pair.runtime)
@@ -121,15 +121,43 @@ export function createEpisodicStorePre(opts = {}) {
121
121
  return raw
122
122
  }
123
123
 
124
+ /**
125
+ * ★ P2-2 修复(2026-09-21):查询返回**深一层副本**。
126
+ * 旧实现 `{ ...e }` 是浅拷贝 ⇒ `actions/entities/unresolved/provenance` 四个数组
127
+ * 与库内对象**同引用**,调用方 push 会直接改脏已巩固 episode(绕过 persist 与统计)。
128
+ */
129
+ function cloneEpisode(e) {
130
+ if (!e) return e
131
+ return {
132
+ ...e,
133
+ actions: Array.isArray(e.actions) ? [...e.actions] : e.actions,
134
+ entities: Array.isArray(e.entities) ? [...e.entities] : e.entities,
135
+ unresolved: Array.isArray(e.unresolved) ? [...e.unresolved] : e.unresolved,
136
+ provenance: Array.isArray(e.provenance) ? [...e.provenance] : e.provenance,
137
+ }
138
+ }
139
+
124
140
  // ---- 段追加(会话进行中实时累积) ----
125
141
  function append(seg) {
126
142
  if (disposed) return { ok: false, reason: 'disposed' }
127
143
  const v = validateEpisodeSegmentPre(seg)
128
144
  if (!v.ok) return { ok: false, reason: v.reason }
129
145
  const s = v.segment
146
+ const incomingRef = String(s.sessionRef || 'unknown')
147
+ // ★ A-7 修复(2026-09-21):**按 sessionRef 隔离**。
148
+ // 旧实现只在 `!current` 时取一次 sessionRef ⇒ 后续不同会话的段被并进同一个 episode:
149
+ // 实测 sessionRef=session-A 但 intent 含 session-B 内容、provenance=["seg:1","seg:1"]。
150
+ // 后果:procedure 晋升依赖的 distinctSessions 被系统性低估 ⇒ 技能永远卡在
151
+ // `diversity-below-3`(跨会话证据明明够,读数却恒为 1)。
152
+ // 现改为:检测到会话切换时,**先巩固当前缓冲**(把上一会话的段固化成一个独立 episode),
153
+ // 再为新的 sessionRef 开一个干净的 current。consolidate 失败(如 too-short)不阻断本段,
154
+ // 其内部已负责把 current 置 null,后续照常新建。
155
+ if (current && current.sessionRef !== incomingRef) {
156
+ consolidate()
157
+ }
130
158
  if (!current) {
131
159
  current = {
132
- sessionRef: String(s.sessionRef || 'unknown'),
160
+ sessionRef: incomingRef,
133
161
  startedAt: nowFn(),
134
162
  segments: [],
135
163
  userTexts: [], assistantTexts: [],
@@ -156,7 +184,13 @@ export function createEpisodicStorePre(opts = {}) {
156
184
  if (current.assistantTexts.length) current.assistantTexts.shift()
157
185
  }
158
186
  stats.segmentsAppended++
159
- return { ok: true, segments: current.segments.length }
187
+ // ★ P2-11 修复(2026-09-21):append 此前**完全不落盘** —— 段只留在内存,
188
+ // 进程重启(崩溃/宿主重载)后未巩固的会话缓冲整段丢失,而调用方看到 ok:true。
189
+ // 现在按段落盘(与 fact-store 各写路径一致)。落盘结果一并透传(A-8 同款纪律)。
190
+ const pr = persist()
191
+ return pr.ok
192
+ ? { ok: true, segments: current.segments.length, persisted: true }
193
+ : { ok: true, segments: current.segments.length, persisted: false, persistError: pr.error }
160
194
  }
161
195
 
162
196
  /** 从累积文本提取 intent(2026-08-28 提纯;2026-09-16 issue#30 改为共享清洗器)。
@@ -232,13 +266,24 @@ export function createEpisodicStorePre(opts = {}) {
232
266
  unresolved: extractUnresolved(current),
233
267
  outcome,
234
268
  success: outcome === 'success',
235
- provenance: current.segments.map((s) => 'seg:' + String(s.eventSeq)),
269
+ // ★ A-7 修复(2026-09-21):provenance 改为 **`sessionRef:eventSeq`**。
270
+ // 旧实现只用 `'seg:' + eventSeq` ⇒ 不同会话里 eventSeq 从 1 重新计数时
271
+ // 会产出完全相同的串(实测 ["seg:1","seg:1"]),既无法溯源到会话,
272
+ // 也让"同一段被重复计入"与"两段恰好同号"在审计面上不可区分。
273
+ provenance: current.segments.map((s) => String(current.sessionRef) + ':' + String(s.eventSeq)),
236
274
  startedAt: current.startedAt,
237
275
  consolidatedAt: nowFn(),
238
276
  }
239
277
  const v = validateEpisodePre(ep)
240
278
  if (!v.ok) { current = null; return { ok: false, reason: 'invalid:' + v.reason } }
241
- episodes.push(v.episode)
279
+ // ★ P2-11 修复(2026-09-21):巩固入店前按 **episodeId 去重**。
280
+ // episodeId = hash(sessionRef, startedAt) ⇒ 同一会话在同一时间戳被重复巩固
281
+ // (或 restore/import 已带入同 id 记录)会产出**同一主键的第二条**,
282
+ // 下游 query/statsFor 会把一次会话数成两次 ⇒ distinctSessions/success 读数虚高。
283
+ // 去重策略:**后到者胜**(原地替换,不打乱既有顺序),与 fact-store restore 一致。
284
+ const dupAt = episodes.findIndex((x) => x.episodeId === v.episode.episodeId)
285
+ if (dupAt >= 0) episodes[dupAt] = v.episode
286
+ else episodes.push(v.episode)
242
287
  // 保留策略: 超 retention 淘汰最旧
243
288
  if (episodes.length > cfg.retention) {
244
289
  episodes = episodes.slice(-cfg.retention)
@@ -246,8 +291,10 @@ export function createEpisodicStorePre(opts = {}) {
246
291
  }
247
292
  current = null
248
293
  stats.consolidated++
249
- void persist()
250
- return { ok: true, episode: v.episode }
294
+ const pr = persist()
295
+ return pr.ok
296
+ ? { ok: true, episode: v.episode, persisted: true }
297
+ : { ok: true, episode: v.episode, persisted: false, persistError: pr.error }
251
298
  }
252
299
 
253
300
  /** 会话中途强制巩固(跨天续接/会话切换时调用)。 */
@@ -264,14 +311,14 @@ export function createEpisodicStorePre(opts = {}) {
264
311
  (q.sessionRef === undefined || e.sessionRef === q.sessionRef) &&
265
312
  (q.outcome === undefined || e.outcome === q.outcome) &&
266
313
  (q.success === undefined || e.success === q.success))
267
- .map((e) => ({ ...e }))
314
+ .map(cloneEpisode)
268
315
  }
269
316
  function recent(n = 10) {
270
- return episodes.slice(-n).map((e) => ({ ...e }))
317
+ return episodes.slice(-n).map(cloneEpisode)
271
318
  }
272
319
  function get(episodeId) {
273
320
  const e = episodes.find((x) => x.episodeId === episodeId)
274
- return e ? { ...e } : null
321
+ return e ? cloneEpisode(e) : null
275
322
  }
276
323
 
277
324
  // ---- M-04 挂钩: 供 Procedure 晋升用的事故/成功统计 ----
@@ -290,10 +337,12 @@ export function createEpisodicStorePre(opts = {}) {
290
337
  return {
291
338
  schemaVersion: 1, namespace: 'dsh-auto-memory', policyVersion: EPISODIC_POLICY_VERSION,
292
339
  savedAt: nowFn(),
293
- episodes: episodes.map((e) => ({ ...e })),
340
+ // ★ P2-2 修复:快照数组字段同样做副本(调用方改快照不得改脏库内 episode)
341
+ episodes: episodes.map(cloneEpisode),
294
342
  current: current ? {
295
343
  sessionRef: current.sessionRef, startedAt: current.startedAt,
296
- segments: current.segments, userTexts: current.userTexts, assistantTexts: current.assistantTexts,
344
+ segments: current.segments.map((s) => ({ ...s })),
345
+ userTexts: [...current.userTexts], assistantTexts: [...current.assistantTexts],
297
346
  } : null,
298
347
  }
299
348
  }
@@ -344,22 +393,47 @@ export function createEpisodicStorePre(opts = {}) {
344
393
  }
345
394
  function clear() {
346
395
  episodes = []; current = null
347
- try { io.clear() } catch (_) {}
348
- return { ok: true }
396
+ // ★ A-8:io.clear() 失败不得静默(残留快照会在下次 load 时"复活"已清空的 episode)
397
+ let cleared = true, clearError
398
+ try { io.clear() } catch (e) {
399
+ cleared = false
400
+ clearError = (e && e.message) ? String(e.message) : String(e)
401
+ }
402
+ return clearError !== undefined ? { ok: true, cleared, error: clearError } : { ok: true, cleared }
349
403
  }
350
404
  function dispose(reason) {
351
- if (disposed) return
405
+ if (disposed) return { ok: true, persisted: true, alreadyDisposed: true }
352
406
  disposed = true
353
- try { io.save(snapshot()) } catch (_) {}
407
+ // ★ A-8:dispose 是最后一次落盘机会,失败必须外显
408
+ const r = persist()
409
+ return { ok: r.ok, persisted: r.ok, ...(r.error ? { error: r.error } : {}) }
354
410
  }
355
411
 
412
+ /**
413
+ * ★ A-8 修复(2026-09-21):旧实现 `try { io.save(snapshot()) } catch (_) {}` **吞掉落盘失败** ——
414
+ * 调用方看到 ok:true、宿主继续推进状态,但磁盘没写上,该状态**永不重写**(静默数据丢失)。
415
+ * 现改为:① 返回结构化结果 `{ok, persisted}`(失败时附 error 文案); ② 失败记进模块内可读状态
416
+ * `lastPersistError`(经 `getLastPersistError()` 暴露)。不引入任何新 import。
417
+ * @returns {{ok:boolean, persisted:boolean, error?:string}}
418
+ */
419
+ let lastPersistError = null
356
420
  function persist() {
357
- try { io.save(snapshot()) } catch (_) {}
421
+ try {
422
+ io.save(snapshot())
423
+ lastPersistError = null
424
+ return { ok: true, persisted: true }
425
+ } catch (e) {
426
+ const msg = (e && e.message) ? String(e.message) : String(e)
427
+ lastPersistError = msg
428
+ return { ok: false, persisted: false, error: msg }
429
+ }
358
430
  }
359
431
 
360
432
  return {
361
433
  append, consolidate, flush, query, recent, get, statsFor,
362
434
  snapshot, restore, importEpisodes, clear, dispose,
435
+ /** ★ A-8:最近一次落盘失败(字符串)或 null —— 供宿主诊断"写盘失败但流程继续"的静默缺口。 */
436
+ getLastPersistError: () => lastPersistError,
363
437
  getStats: () => ({ ...stats }),
364
438
  get size() { return episodes.length },
365
439
  get hasCurrent() { return !!current },
@@ -95,6 +95,23 @@ export function parseDurableEventLine(line) {
95
95
 
96
96
  // ========== Store(appen-only JSONL,engine 级串行链) ==========
97
97
 
98
+ /**
99
+ * ★P3-9(组2,2026-09-22):诊断**节流**表。
100
+ * 原先 `_writeLine` 每次写失败都直接 console.error ⇒ 持续失败(磁盘满/权限错)时刷屏。
101
+ * 表放**模块级**而非实例级:同进程多实例共享 ⇒ 不会各自持一份而双写。
102
+ * 语义同 index.js 的 diagThrottled:首条立即透出,窗口内重复压掉。
103
+ */
104
+ const DIAG_THROTTLE_MS_V1 = 300000
105
+ const _diagAt = new Map()
106
+ function shouldReportDiag(key) {
107
+ try {
108
+ const now = Date.now()
109
+ if (now - (Number(_diagAt.get(key)) || 0) < DIAG_THROTTLE_MS_V1) return false
110
+ _diagAt.set(key, now)
111
+ } catch (_) { /* 计时失败不阻断上报 */ }
112
+ return true
113
+ }
114
+
98
115
  export class EvidenceEventStore {
99
116
  constructor(opts = {}) {
100
117
  this.root = opts.root || null
@@ -103,6 +120,15 @@ export class EvidenceEventStore {
103
120
  this._appended = new BoundedIdSet(EVIDENCE_STORE_POLICY_V1.appendedIdCache)
104
121
  this._swept = false
105
122
  this.stats = { appended: 0, duplicates: 0, oversize: 0, invalid: 0, writeFailed: 0, sweptFiles: 0, lastWriteError: null }
123
+ // ★P3-9(组2):可注入诊断通道(宿主不注入时 reportDiag 回落 console.error,行为向后兼容)。
124
+ this._onDiag = typeof opts.onDiag === 'function' ? opts.onDiag : null
125
+ }
126
+
127
+ /** ★P3-9(组2):节流上报。首条立即透出,窗口内重复压掉。 */
128
+ reportDiag(key, msg) {
129
+ if (!shouldReportDiag(key)) return
130
+ if (this._onDiag) { try { this._onDiag(key, msg); return } catch (_) { /* 回落 */ } }
131
+ try { console.error(msg) } catch (_) {}
106
132
  }
107
133
 
108
134
  /** §9 幂等:同 evidenceId 进程内只落盘一次(seed replay 与 live 双喂安全网;磁盘侧靠 rebuild 去重兜底)。 */
@@ -140,7 +166,7 @@ export class EvidenceEventStore {
140
166
  } catch (e) {
141
167
  this.stats.writeFailed++
142
168
  this.stats.lastWriteError = String(e && e.message ? e.message : e)
143
- try { console.error('[evidence-store] write-failed: ' + this.stats.lastWriteError) } catch (_) {}
169
+ try { this.reportDiag('evidence:write-failed', '[evidence-store] write-failed: ' + this.stats.lastWriteError) } catch (_) {}
144
170
  return false
145
171
  }
146
172
  }