@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
@@ -0,0 +1,143 @@
1
+ /**
2
+ * dsh-home.js —— **DSH_HOME 的唯一解析口径**(上游 issue #86-3 修复)。
3
+ *
4
+ * ## 背景(#86-3;已在 pre 线实跑核验)
5
+ *
6
+ * 修复前,全仓有 **7 处独立解析** `process.env.DSH_HOME`,口径互不相同:
7
+ *
8
+ * | 位置 | 环境变量缺失时的回落 |
9
+ * |---|---|
10
+ * | index.js:808(`dshHome()`) | `path.join(homedir(), '.dsh')` |
11
+ * | index.js:9256(模型根) | `path.join(homedir(), '.dsh')` |
12
+ * | index.js:9613(python-setup) | `path.join(homedir(), '.dsh')`,失败退 `homedir()` |
13
+ * | index.js:9623(python-sidecar) | `path.join(homedir(), '.dsh')`,失败退 **空串** |
14
+ * | semantic-js.js:73/176 | `path.join(homedir(), '.dsh')` |
15
+ * | activation-host.js:72 | 退 `homedir()` 再拼 `.dsh`,全失败退 **'.'** |
16
+ * | context-host.js:40 | 退 **`USERPROFILE || HOME`** 再拼(**前缀不同**) |
17
+ * | shadow-host.js:129 | 同 activation(但注释说漏拼过 `.dsh`) |
18
+ *
19
+ * ⇒ 后果:**同一台机器上,不同子系统可能把数据写到不同根目录**。
20
+ * 最典型的是 `context-host` 用 `USERPROFILE` 作基准,而其余用 `os.homedir()`——
21
+ * 两者在 Windows 上通常一致,但在容器/CI/被改过环境变量的进程里会分叉。
22
+ *
23
+ * ## 本模块的职责
24
+ *
25
+ * 提供**一个**函数 `resolveDshHomePre(override)`,所有站点都调它。
26
+ * 解析顺序(逐级回落,**绝不抛**):
27
+ *
28
+ * 1. `override`(显式传入,最高优先 —— 给测试注入与 engine 级配置留口)
29
+ * 2. `process.env.DSH_HOME`(trim 后非空)
30
+ * 3. `os.homedir()` + `/.dsh`
31
+ * 4. 环境变量 `USERPROFILE || HOME` + `/.dsh`(**保留 context-host 原有的兜底能力**,
32
+ * 只是把它从「基准」降级为「最后兜底」,从而与其余站点统一)
33
+ * 5. 全失败 ⇒ `'.dsh'`(相对路径,保证**永不返回空串**)
34
+ *
35
+ * ## 为什么把 `homedir()` 放在 `USERPROFILE` 之前
36
+ *
37
+ * `os.homedir()` 在 Windows 上**本身就是** `USERPROFILE`(Node 内部优先读它,
38
+ * 读不到才退 `HOMEDRIVE+HOMEPATH`)⇒ 两者绝大多数情况等价,
39
+ * 但 `homedir()` 还会正确处理 `HOME` 覆盖与权限异常 ⇒ **以它为准更稳**。
40
+ * 保留 `USERPROFILE||HOME` 仅作 `homedir()` 抛异常时的兜底。
41
+ *
42
+ * ## 纪律
43
+ * - 零运行时依赖(只 `node:os` / `node:path`)。
44
+ * - **永不抛、永不返回空串**(调用方大量直接 `path.join(dshHome(), ...)`)。
45
+ * - 只读环境变量,**不缓存**(测试会中途改 `process.env.DSH_HOME`)。
46
+ * - CRLF、无 BOM。
47
+ */
48
+ import os from 'node:os'
49
+ import path from 'node:path'
50
+
51
+ /** 环境变量名(集中一处,便于将来改名)。 */
52
+ export const DSH_HOME_ENV_V1 = 'DSH_HOME'
53
+
54
+ /** 默认子目录名。 */
55
+ export const DSH_HOME_DIRNAME_V1 = '.dsh'
56
+
57
+ /** 全失败时的最后兜底(相对路径,保证返回非空)。 */
58
+ export const DSH_HOME_FALLBACK_V1 = '.dsh'
59
+
60
+ /**
61
+ * 取 home 基准目录(用于拼 `.dsh`)。**永不抛**。
62
+ * @returns {string} 非空字符串,或空串(表示取不到基准)
63
+ */
64
+ function homeBasePre() {
65
+ // ① os.homedir() —— 首选:Windows 上等价于 USERPROFILE,且能处理 HOME 覆盖
66
+ try {
67
+ const h = os.homedir()
68
+ if (h && String(h).trim()) return String(h).trim()
69
+ } catch (_) {
70
+ // 落到 ②
71
+ }
72
+ // ② USERPROFILE / HOME —— 兼容 homedir() 抛异常的极端环境
73
+ try {
74
+ const e = process.env.USERPROFILE || process.env.HOME || ''
75
+ if (e && String(e).trim()) return String(e).trim()
76
+ } catch (_) {}
77
+ return ''
78
+ }
79
+
80
+ /**
81
+ * **唯一入口**:解析 DSH_HOME。
82
+ *
83
+ * @param {string} [override] 显式覆盖(测试注入 / engine 级配置);空串视为未提供。
84
+ * @returns {string} 非空路径字符串(**永不抛、永不返回空串**)
85
+ */
86
+ export function resolveDshHomePre(override) {
87
+ // ① 显式覆盖优先
88
+ try {
89
+ if (override != null && String(override).trim()) return String(override).trim()
90
+ } catch (_) {}
91
+ // ② 环境变量
92
+ try {
93
+ const env = process.env[DSH_HOME_ENV_V1]
94
+ if (env && String(env).trim()) return String(env).trim()
95
+ } catch (_) {}
96
+ // ③ / ④ 基准目录 + .dsh
97
+ const base = homeBasePre()
98
+ if (base) {
99
+ try {
100
+ return path.join(base, DSH_HOME_DIRNAME_V1)
101
+ } catch (_) {}
102
+ }
103
+ // ⑤ 最后兜底
104
+ return DSH_HOME_FALLBACK_V1
105
+ }
106
+
107
+ /**
108
+ * engine 级便捷包装:优先用 `engine.__dshHomeOverride`,其次环境变量,最后默认。
109
+ *
110
+ * 之所以要这一层:`activation-host` / `context-host` / `shadow-host` 都是
111
+ * 「engine + 可选 __homedirFn」的形态,统一改调本函数可让三者的口径完全一致,
112
+ * 同时**保留** `__homedirFn` 这个既有测试注入点(不再各自手写回落链)。
113
+ */
114
+ export function resolveDshHomeForEnginePre(engine) {
115
+ const e = engine || {}
116
+ // ★★ 优先级必须与**原实现**一致:`env` 优先于 `__homedirFn`。
117
+ // 原写法是 `const env = process.env.DSH_HOME; if (env.trim()) return env.trim();
118
+ // const base = engine.__homedirFn ? ... : ...` ⇒ env 先判。
119
+ // ⚠️ 2026-09-20 首次实现把 __homedirFn 提到 env 之前,导致用 `process.env.DSH_HOME`
120
+ // 注入的测试(如 smoke-test-m53)被真实 homedir 覆盖,证据写到了**真实用户目录**
121
+ // (症状:C4/C5/C6 evidence 落盘数为 0,离真因很远)。
122
+ // ① 显式 engine 级覆盖(新增能力,原实现没有,放最前不影响兼容)
123
+ try {
124
+ if (e.__dshHomeOverride != null && String(e.__dshHomeOverride).trim()) {
125
+ return String(e.__dshHomeOverride).trim()
126
+ }
127
+ } catch (_) {}
128
+ // ② 环境变量(与原实现同优先级)
129
+ try {
130
+ const env = process.env[DSH_HOME_ENV_V1]
131
+ if (env && String(env).trim()) return String(env).trim()
132
+ } catch (_) {}
133
+ // ③ 既有注入点 __homedirFn:返回的是「home 基准目录」,仍需拼 .dsh
134
+ try {
135
+ if (typeof e.__homedirFn === 'function') {
136
+ const base = e.__homedirFn()
137
+ if (base && String(base).trim()) return path.join(String(base).trim(), DSH_HOME_DIRNAME_V1)
138
+ }
139
+ } catch (_) {}
140
+ // ④ 默认链(homedir → USERPROFILE/HOME → '.dsh')
141
+ return resolveDshHomePre()
142
+ }
143
+
@@ -104,15 +104,60 @@ export function createEpisodicStorePre(opts = {}) {
104
104
  return EPISODE_ID_PREFIX + h.slice(0, 32)
105
105
  }
106
106
 
107
+ /**
108
+ * issue#57 修复(2026-09-19):restore 时对 `data.current` 做**形状校验**。
109
+ * 旧实现 `current = data.current || null` 零校验(与 :222 的 validateEpisodePre 形成不对称):
110
+ * 磁盘上 `current:{}`(截断/手改/旧版本残留)会被原样采纳 ⇒ 之后 consolidate() 在
111
+ * `current.segments.length`(:202)抛 TypeError ⇒ **巩固链路静默停摆**,且因异常发生在
112
+ * 调用方 try 之外,统计与日志都不留痕。
113
+ * 纪律:**丢弃优于卡死** —— 形状不合格一律置 null(等效"本会话无未巩固缓冲"),
114
+ * 绝不把结构非法对象放进状态机。
115
+ */
116
+ function restoreCurrentPre(raw) {
117
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
118
+ if (typeof raw.sessionRef !== 'string' || !raw.sessionRef) return null
119
+ if (typeof raw.startedAt !== 'number' || !Number.isFinite(raw.startedAt)) return null
120
+ if (!Array.isArray(raw.segments) || !Array.isArray(raw.userTexts) || !Array.isArray(raw.assistantTexts)) return null
121
+ return raw
122
+ }
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
+
107
140
  // ---- 段追加(会话进行中实时累积) ----
108
141
  function append(seg) {
109
142
  if (disposed) return { ok: false, reason: 'disposed' }
110
143
  const v = validateEpisodeSegmentPre(seg)
111
144
  if (!v.ok) return { ok: false, reason: v.reason }
112
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
+ }
113
158
  if (!current) {
114
159
  current = {
115
- sessionRef: String(s.sessionRef || 'unknown'),
160
+ sessionRef: incomingRef,
116
161
  startedAt: nowFn(),
117
162
  segments: [],
118
163
  userTexts: [], assistantTexts: [],
@@ -139,7 +184,13 @@ export function createEpisodicStorePre(opts = {}) {
139
184
  if (current.assistantTexts.length) current.assistantTexts.shift()
140
185
  }
141
186
  stats.segmentsAppended++
142
- 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 }
143
194
  }
144
195
 
145
196
  /** 从累积文本提取 intent(2026-08-28 提纯;2026-09-16 issue#30 改为共享清洗器)。
@@ -215,13 +266,24 @@ export function createEpisodicStorePre(opts = {}) {
215
266
  unresolved: extractUnresolved(current),
216
267
  outcome,
217
268
  success: outcome === 'success',
218
- 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)),
219
274
  startedAt: current.startedAt,
220
275
  consolidatedAt: nowFn(),
221
276
  }
222
277
  const v = validateEpisodePre(ep)
223
278
  if (!v.ok) { current = null; return { ok: false, reason: 'invalid:' + v.reason } }
224
- 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)
225
287
  // 保留策略: 超 retention 淘汰最旧
226
288
  if (episodes.length > cfg.retention) {
227
289
  episodes = episodes.slice(-cfg.retention)
@@ -229,8 +291,10 @@ export function createEpisodicStorePre(opts = {}) {
229
291
  }
230
292
  current = null
231
293
  stats.consolidated++
232
- void persist()
233
- 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 }
234
298
  }
235
299
 
236
300
  /** 会话中途强制巩固(跨天续接/会话切换时调用)。 */
@@ -247,14 +311,14 @@ export function createEpisodicStorePre(opts = {}) {
247
311
  (q.sessionRef === undefined || e.sessionRef === q.sessionRef) &&
248
312
  (q.outcome === undefined || e.outcome === q.outcome) &&
249
313
  (q.success === undefined || e.success === q.success))
250
- .map((e) => ({ ...e }))
314
+ .map(cloneEpisode)
251
315
  }
252
316
  function recent(n = 10) {
253
- return episodes.slice(-n).map((e) => ({ ...e }))
317
+ return episodes.slice(-n).map(cloneEpisode)
254
318
  }
255
319
  function get(episodeId) {
256
320
  const e = episodes.find((x) => x.episodeId === episodeId)
257
- return e ? { ...e } : null
321
+ return e ? cloneEpisode(e) : null
258
322
  }
259
323
 
260
324
  // ---- M-04 挂钩: 供 Procedure 晋升用的事故/成功统计 ----
@@ -273,13 +337,48 @@ export function createEpisodicStorePre(opts = {}) {
273
337
  return {
274
338
  schemaVersion: 1, namespace: 'dsh-auto-memory', policyVersion: EPISODIC_POLICY_VERSION,
275
339
  savedAt: nowFn(),
276
- episodes: episodes.map((e) => ({ ...e })),
340
+ // ★ P2-2 修复:快照数组字段同样做副本(调用方改快照不得改脏库内 episode)
341
+ episodes: episodes.map(cloneEpisode),
277
342
  current: current ? {
278
343
  sessionRef: current.sessionRef, startedAt: current.startedAt,
279
- segments: current.segments, userTexts: current.userTexts, assistantTexts: current.assistantTexts,
344
+ segments: current.segments.map((s) => ({ ...s })),
345
+ userTexts: [...current.userTexts], assistantTexts: [...current.assistantTexts],
280
346
  } : null,
281
347
  }
282
348
  }
349
+ /**
350
+ * ★ 增量导入(2026-09-19 上游 PR #77 / issue #63 同步落地,**P0 数据丢失**)。
351
+ *
352
+ * **为什么必须单独有这个函数**:hub 的 `ingestJudgement` 原本对每行 `episodic_candidate`
353
+ * 调 `restore({schemaVersion:1, episodes:[row]})`,而 `restore()` 是**快照整体替换**语义
354
+ * (先 `episodes = []`)。worker 产出的候选行普遍缺 `validateEpisodePre` 必填字段
355
+ * ⇒ 校验必拒(`restored:0`),**但 episodes 已被清空、current 已被置 null**,
356
+ * 且 `restore()` 仍返回 `{ok:true}` ⇒ hub 记 `consumedEpisodic++` / `outcome:'restored'`
357
+ * ⇒ 下次 consolidate/flush 把清空态落盘 ⇒ **一次 ingest 抹掉全部已巩固 episode,不可逆**。
358
+ *
359
+ * 契约(与 `restore` 严格区分):
360
+ * - **绝不清空既有状态**(不清 episodes、不动 current);
361
+ * - 逐条校验,**只追加合法项**,非法项计入 `rejected`(不静默);
362
+ * - 按 `episodeId` **幂等去重**(重复导入同一行不产生副本);
363
+ * - **不持久化**(由调用方决定何时 flush),与 `restore` 一致。
364
+ * @param {Array} rows - 候选 episode 行(原始形态,内部走 validateEpisodePre)
365
+ * @returns {{ok: boolean, imported: number, rejected: number, duplicates: number, reason?: string}}
366
+ */
367
+ function importEpisodes(rows) {
368
+ if (!Array.isArray(rows)) return { ok: false, imported: 0, rejected: 0, duplicates: 0, reason: 'bad-rows' }
369
+ let imported = 0, rejected = 0, duplicates = 0
370
+ const seen = new Set(episodes.map((e) => e.episodeId))
371
+ for (const raw of rows) {
372
+ const v = validateEpisodePre(raw)
373
+ if (!v.ok) { rejected++; continue }
374
+ if (seen.has(v.episode.episodeId)) { duplicates++; continue }
375
+ episodes.push(v.episode)
376
+ seen.add(v.episode.episodeId)
377
+ imported++
378
+ }
379
+ return { ok: true, imported, rejected, duplicates }
380
+ }
381
+
283
382
  function restore(data) {
284
383
  if (!data || data.schemaVersion !== 1) return { ok: false, reason: 'bad-schema' }
285
384
  if (!Array.isArray(data.episodes)) return { ok: false, reason: 'bad-episodes' }
@@ -289,27 +388,52 @@ export function createEpisodicStorePre(opts = {}) {
289
388
  if (!v.ok) continue
290
389
  episodes.push(v.episode)
291
390
  }
292
- current = data.current || null
391
+ current = restoreCurrentPre(data.current)
293
392
  return { ok: true, restored: episodes.length }
294
393
  }
295
394
  function clear() {
296
395
  episodes = []; current = null
297
- try { io.clear() } catch (_) {}
298
- 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 }
299
403
  }
300
404
  function dispose(reason) {
301
- if (disposed) return
405
+ if (disposed) return { ok: true, persisted: true, alreadyDisposed: true }
302
406
  disposed = true
303
- 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 } : {}) }
304
410
  }
305
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
306
420
  function persist() {
307
- 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
+ }
308
430
  }
309
431
 
310
432
  return {
311
433
  append, consolidate, flush, query, recent, get, statsFor,
312
- snapshot, restore, clear, dispose,
434
+ snapshot, restore, importEpisodes, clear, dispose,
435
+ /** ★ A-8:最近一次落盘失败(字符串)或 null —— 供宿主诊断"写盘失败但流程继续"的静默缺口。 */
436
+ getLastPersistError: () => lastPersistError,
313
437
  getStats: () => ({ ...stats }),
314
438
  get size() { return episodes.length },
315
439
  get hasCurrent() { return !!current },
@@ -118,7 +118,14 @@ export class EvidenceEventStore {
118
118
  }
119
119
  if (id) this._appended.add(id)
120
120
  this._chain = this._chain.then(() => this._writeLine(proj.line))
121
- return this._chain.then((written) => ({ ok: written, reason: written ? 'ok' : 'write-failed', evidenceId: id, projected: proj.projected }))
121
+ return this._chain.then((written) => {
122
+ // issue#56 修复(2026-09-19):写盘失败必须**释放幂等登记**。
123
+ // 旧实现只 add 从不回退 ⇒ 一次瞬时写失败后,同 evidenceId 的重试恒被判
124
+ // duplicate-evidence 而拒绝 ⇒ 该条证据**静默永久丢失**。
125
+ // 登记保留在调用时(维持同步去重窗口),失败时撤销(允许重试)。
126
+ if (!written && id) this._appended.delete(id)
127
+ return { ok: written, reason: written ? 'ok' : 'write-failed', evidenceId: id, projected: proj.projected }
128
+ })
122
129
  }
123
130
 
124
131
  async _writeLine(line) {