@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.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 (104) hide show
  1. package/README.md +171 -1
  2. package/README.zh-CN.md +171 -1
  3. package/docs/CONTRIBUTORS.html +471 -0
  4. package/docs/HANDOFF-CRITERIA.md +92 -0
  5. package/docs/INTEGRATION-ANALYSIS.md +350 -348
  6. package/docs/USER-GUIDE.en.md +56 -1
  7. package/docs/USER-GUIDE.zh-CN.md +57 -2
  8. package/docs/internal/ACCEPT-35-LIVE.md +143 -0
  9. package/docs/internal/ACCEPTANCE-20260914.md +90 -0
  10. package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
  11. package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
  12. package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
  13. package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
  14. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
  15. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
  16. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
  17. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
  18. package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
  19. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
  20. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
  21. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
  22. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
  23. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
  24. package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
  25. package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
  26. package/docs/internal/KICKOFF-P0.md +254 -0
  27. package/docs/internal/MASTER-PLAN-3.0.md +411 -0
  28. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
  29. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
  30. package/docs/internal/PENDING-FIXES-20260916.md +289 -0
  31. package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
  32. package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
  33. package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
  34. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
  35. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
  36. package/docs/internal/ROADMAP-20260917-WEEK.md +305 -0
  37. package/docs/internal/ROADMAP.md +106 -0
  38. package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
  39. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +175 -0
  40. package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
  41. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
  42. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
  43. package/docs/internal/THREE-LAYER-CONTRACT.md +210 -0
  44. package/docs/internal/TODO-BACKLOG.md +263 -142
  45. package/docs/internal/TODO-GRAPH.html +715 -0
  46. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
  47. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
  48. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
  49. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
  50. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
  51. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
  52. package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
  53. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
  54. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
  55. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
  56. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
  57. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
  58. package/docs/prompts/M8-3-enable-verify.md +49 -49
  59. package/lib/acceptance.js +71 -0
  60. package/lib/activation-host.js +90 -9
  61. package/lib/activation-inbox.js +25 -7
  62. package/lib/board-mode.js +30 -0
  63. package/lib/client.js +878 -75
  64. package/lib/context-bridge.js +2 -2
  65. package/lib/context-host.js +70 -6
  66. package/lib/engine-identity.js +149 -0
  67. package/lib/engine-switch.js +247 -0
  68. package/lib/episodic-store.js +11 -10
  69. package/lib/evidence-store.js +2 -2
  70. package/lib/fact-store.js +1 -1
  71. package/lib/fs-retry.js +46 -0
  72. package/lib/index.js +1987 -153
  73. package/lib/intent-clean-safe.js +40 -0
  74. package/lib/intent-clean.js +12 -16
  75. package/lib/l0-extract.js +263 -149
  76. package/lib/l0-index-sync.js +195 -0
  77. package/lib/l0-index.js +349 -239
  78. package/lib/ledger-criteria.js +142 -0
  79. package/lib/m7-index-sync-host.js +65 -4
  80. package/lib/m7-wire.js +3 -3
  81. package/lib/memory-anchor.js +56 -1
  82. package/lib/memory-envelope.js +252 -0
  83. package/lib/memory-hub.js +14 -4
  84. package/lib/memory-mutation.js +246 -0
  85. package/lib/memory-writer.js +204 -24
  86. package/lib/procedure-observation.js +48 -0
  87. package/lib/procedure-store.js +34 -17
  88. package/lib/python-setup.js +1 -1
  89. package/lib/rerank-host.js +160 -0
  90. package/lib/rules-layer.js +261 -0
  91. package/lib/semantic-js.js +15 -0
  92. package/lib/shadow-retrieval.js +3 -3
  93. package/lib/state-commit.js +245 -0
  94. package/lib/subagent-gc.js +4 -8
  95. package/lib/tier-layer-inject.js +650 -0
  96. package/lib/tier0-catalog.js +693 -0
  97. package/lib/water-window.js +263 -186
  98. package/lib/wb-contract.js +495 -0
  99. package/lib/wb-sidecar.js +839 -0
  100. package/lib/ws-overview-rank.js +2 -2
  101. package/package.json +1 -1
  102. package/python/m7_embedding_v1.py +5 -5
  103. package/python/worker_semantic_v1.py +17 -6
  104. package/python/worker_v1.py +38 -4
@@ -43,7 +43,11 @@ export function createActivationHost(opts = {}) {
43
43
  const registry = new ActivationInboxRegistry()
44
44
  const corpusRegistry = new CorpusRegistry({ sidecarDir: path.join(dshHome(), 'memory', 'index', 'files') })
45
45
  const runtimeState = new Map() // runtime.key → {step, claimed:{packet, inbox}|null, identity:{sessionId,workspaceKey}|null}
46
- let mivCache = { wsRef: null, miv: null }
46
+ // ★多工作区适配(2026-09-17):以下两处此前是**单槽**(一个变量只装"最后写入者")。
47
+ // 症状:会话 A 投递 → 槽里是 A;会话 B(或用户点开另一个工作区触发 B)投递 → 槽里变 B;
48
+ // A 下一轮来取 ⇒ 身份不匹配 ⇒ 降级"投影来自其它会话/工作区" ⇒ A **永远不下探 Tier-1**。
49
+ // 现改为按身份分片:投影按 sessionId、miv 按 wsRef。**只增 key 维度,不改任何判定口径**。
50
+ const mivCacheByWs = new Map() // wsRef -> miv(每个工作区各记一份,不再互相踢)
47
51
  const pathsByKey = new Map()
48
52
  const volatileEvents = [] // ≤16 最小投影
49
53
  const stats = { injected: 0, injectedAccepted: 0, injectedRejected: 0, claims: 0, claimFails: 0, rendered: 0, delivered: 0, seenCreated: 0, seenSkippedNoProv: 0, errors: 0 }
@@ -84,10 +88,13 @@ export function createActivationHost(opts = {}) {
84
88
  }
85
89
 
86
90
 
87
- /** 当前 workspace 的 memoryIndexVersion(自有 CorpusRegistry 懒加载;失败 null 并清缓存)。 */
91
+ /** 当前 workspace 的 memoryIndexVersion(自有 CorpusRegistry 懒加载;失败 null 并清缓存)。
92
+ * ★多工作区适配(2026-09-17):缓存**按 wsRef 分片**。旧实现是单变量 `mivCache`,只认最后一个
93
+ * wsRef —— 用户点开另一个工作区、或另一会话投递时就会把前一个的缓存踢掉,于是两个工作区
94
+ * 来回切换时每次 `currentMiv` 都必然 miss、每次都重算语料快照,并进一步放大 P0-4e
95
+ * ("持续变更 ⇒ 索引永远不就绪")。分片后各工作区各记一份,互不干扰。 */
88
96
  function currentMiv(workspaceKey) {
89
97
  const ws = canonicalize(workspaceKey)
90
- const p = null
91
98
  for (const [, v] of pathsByKey) {
92
99
  if (v.workspaceKey !== ws) continue
93
100
  try {
@@ -99,16 +106,26 @@ export function createActivationHost(opts = {}) {
99
106
  })
100
107
  const res = corpusRegistry.get(catalog)
101
108
  if (res && res.ok) {
102
- mivCache = { wsRef: ws, miv: res.snapshot.memoryIndexVersion }
103
- return mivCache.miv
109
+ mivCacheByWs.set(ws, res.snapshot.memoryIndexVersion)
110
+ return res.snapshot.memoryIndexVersion
104
111
  }
105
112
  } catch (_) {}
106
113
  break
107
114
  }
108
- if (mivCache.wsRef === ws) return mivCache.miv
109
- return null
115
+ return mivCacheByWs.get(ws) || null
116
+ }
117
+ function setMiv(workspaceKey, miv) { mivCacheByWs.set(canonicalize(workspaceKey), miv) }
118
+ /** 取某工作区最近一次已知 miv(无则 null)。供上层投影/诊断读取,不再假设"全局只有一个"。 */
119
+ function mivFor(workspaceKey) {
120
+ const ws = canonicalize(workspaceKey)
121
+ return mivCacheByWs.get(ws) || null
122
+ }
123
+ /** 最近写入的一份 miv(兼容投影:给既有诊断面板/老调用方用;多工作区语义请用 mivFor)。 */
124
+ function latestMiv() {
125
+ let last = null
126
+ for (const v of mivCacheByWs.values()) last = v
127
+ return last
110
128
  }
111
- function setMiv(workspaceKey, miv) { mivCache = { wsRef: canonicalize(workspaceKey), miv } }
112
129
 
113
130
  function identityFor(runtime) {
114
131
  const sessionId = runtime.sessionId || ''
@@ -137,11 +154,70 @@ export function createActivationHost(opts = {}) {
137
154
  return finishInject(box, req)
138
155
  } catch (e) { stats.errors++; return { ok: false, reason: 'internal-error' } }
139
156
  }
157
+ /**
158
+ * C5 闸门输入(2026-09-14 三层契约 §5):把本轮语义命中压成**轻量投影**挂到 engine,
159
+ * 供注入层决定"要不要下探 Tier-1/Tier-2"(未命中就只给 Tier-0 目录层 = 省 token 的关键)。
160
+ * 只存 memoryId/score/摘要/layer/status —— 不复制 packet,不做任何 IO,零 LLM(S9)。
161
+ *
162
+ * T0-2(2026-09-14 P0):**必须同时存版本四元组** `contextVersion / miv / observationId / requestKey`。
163
+ * 旧实现在注入侧只用 `Date.now() - gh.at < 30*60000` 判复用 —— **只查时间不查版本**,
164
+ * 于是「A 快照产生的候选到 B 快照才准备输出」时 A 的正文会进 B 的注入(违反契约 I6「三层同一快照」)。
165
+ * 这里补齐投影字段,判定与 fail-closed 语义在 `lib/tier-layer-inject.js:selectReusableTierHitsPre`。
166
+ * `workspaceKey` 同时补齐:身份门需要它证明投影归属(旧实现压根没存工作区)。
167
+ */
168
+ function recordTierGateHits(req) {
169
+ try {
170
+ if (!engine || typeof engine !== 'object') return
171
+ const sid = String(req.sessionId || '')
172
+ const lq = engine._lastTierQuery || null
173
+ const q = (lq && Date.now() - (Number(lq.at) || 0) < 120000 && String(lq.sessionId || '') === sid)
174
+ ? String(lq.text || '') : String(req.triggerText || '')
175
+ const projection = {
176
+ sessionId: sid,
177
+ agentId: String(req.agentId || ''),
178
+ workspaceKey: String(req.workspaceKey || ''),
179
+ at: Date.now(),
180
+ question: q.slice(0, 1200),
181
+ // —— T0-2 版本四元组(复用前必须逐项比对;缺任一项下游 fail closed 不复用)——
182
+ contextVersion: Number.isInteger(req.contextVersion) ? req.contextVersion : undefined,
183
+ miv: String(req.memoryIndexVersion || '') || undefined,
184
+ observationId: String(req.observationId || '') || undefined,
185
+ requestKey: String(req.requestKey || '') || undefined,
186
+ // 状态过滤(I5/C8)在注入侧做(见 tier-layer-inject-pre 的 filterCurrentHitsPre),
187
+ // 这里**照原样保留 status**:投影是审计面,过滤是准入面,两者不混。
188
+ hits: (Array.isArray(req.candidates) ? req.candidates : []).slice(0, 8).map((c) => ({
189
+ memoryId: String((c && c.memoryId) || ''),
190
+ score: Number(c && c.score),
191
+ excerpt: String((c && c.excerpt) || ''),
192
+ layer: (c && c.layer) || 'log',
193
+ status: (c && c.status) || 'current',
194
+ sourceRef: String((c && c.sourceRef) || ''),
195
+ scope: String((c && c.scope) || ''),
196
+ })),
197
+ }
198
+ // ★多工作区适配(2026-09-17):投影**按会话分片**存放,读取侧按当前会话取。
199
+ // 旧实现是单槽 `engine._tierGateHits = {...}`:会话 A 投递后被 B 覆盖,A 下一轮取到的
200
+ // 是 B 的投影 ⇒ 身份门判 session-mismatch ⇒ A **永远不下探 Tier-1**("谁也没法注入")。
201
+ // 分片后 A/B 各存各的,互不覆盖;仍然**逐项比对四元组**,判定口径一字未改。
202
+ if (!engine._tierGateHitsBySession || typeof engine._tierGateHitsBySession.set !== 'function') {
203
+ engine._tierGateHitsBySession = new Map()
204
+ }
205
+ engine._tierGateHitsBySession.set(sid, projection)
206
+ // 兼容投影:保留旧字段指向"最近一次投递",供旧诊断面板/测试继续可读(只增不改语义)。
207
+ engine._tierGateHits = projection
208
+ // 有界:最多保留 32 个会话的投影,避免长时间运行后 Map 无限增长。
209
+ if (engine._tierGateHitsBySession.size > 32) {
210
+ const oldest = engine._tierGateHitsBySession.keys().next().value
211
+ engine._tierGateHitsBySession.delete(oldest)
212
+ }
213
+ } catch (_) {}
214
+ }
140
215
  function finishInject(box, req) {
141
216
  box.setCursor({ contextVersion: req.contextVersion, memoryIndexVersion: req.memoryIndexVersion })
142
217
  const r = box.offerActivation(req, { nowStep: stepFor(req.sessionId, req.workspaceKey), currentMemoryIndexVersion: req.memoryIndexVersion })
143
218
  if (r.ok) {
144
219
  stats.injectedAccepted++
220
+ recordTierGateHits(req)
145
221
  // fake 来源「注入即泵」:立刻以请求自声明版本过四重门 claim,并把 claimed 挂到
146
222
  // 目标 runtime 态——下一次该 session 的自然 compose 会渲染尾注并 markDelivered(+seen)。
147
223
  pumpClaimed(box, req)
@@ -215,6 +291,7 @@ export function createActivationHost(opts = {}) {
215
291
  // 在回合中段必死(cv 每段自增+TTL3 步),Python emit 的时序机制完全相同,同一证据
216
292
  // 同一修法。泵是 M6 投递侧机制,不耦合任何语义档(JS/Python 各自独立可用)。
217
293
  pumpClaimed(box, req)
294
+ recordTierGateHits(req) // C5 闸门输入(与 fake 档同一投影)
218
295
  } else stats.injectedRejected++
219
296
  return r
220
297
  } catch (e) { stats.errors++; return { ok: false, reason: 'internal-error' } }
@@ -386,7 +463,11 @@ export function createActivationHost(opts = {}) {
386
463
  cooldownSteps: REFERENCE_TAIL_COOLDOWN_STEPS_V1,
387
464
  capability: cap,
388
465
  sourceMode: sourceMode(),
389
- memoryIndexVersion: mivCache.miv || null,
466
+ // 多工作区适配(2026-09-17):不再返回"最后一个写入者"的 miv,而是按工作区分片的映射。
467
+ // 同时保留旧字段 `memoryIndexVersion` 作为**兼容投影**(取最近写入的那一份),供既有
468
+ // 诊断面板/测试继续工作 —— 只增字段不改语义,避免开关耦合。
469
+ memoryIndexVersion: latestMiv() || null,
470
+ mivByWorkspace: Object.fromEntries(mivCacheByWs),
390
471
  inboxCount: registry.size,
391
472
  stats: { ...stats },
392
473
  recentEvents: volatileEvents.slice(-4),
@@ -200,14 +200,26 @@ export function validateActivationSkillPre(s) {
200
200
  }
201
201
  }
202
202
 
203
- /** 单条引用块(provenance 三行身份永不省略;reference 行内容经卫生处理)。 */
204
- function renderItemBlock(item, reason) {
203
+ /**
204
+ * 单条引用块(provenance 三行身份永不省略;reference 行内容经卫生处理)。
205
+ * 2026-09-14 P1-⑮:补 **Score 行** —— 0-1 相似度与批内排名此前在渲染时被丢弃,
206
+ * 注入侧只看得见 lane/reasonCodes,看不到"到底多像"(用户报告"0-1 相似度排序没了")。
207
+ * 分值与排名都取自 packet 内既有字段(item.score + 排序后的位置),确定性同 build/重渲染;
208
+ * score 非法(非有限数)时整行省略,保持与旧版逐字节一致的降级行为。
209
+ */
210
+ function renderItemBlock(item, reason, rank, total) {
205
211
  const refText = item.reference != null ? sanitizeTailText(item.reference) : ''
212
+ const sc = Number(item.score)
206
213
  const lines = [
207
214
  TAIL_MARKER_LINE_V1,
208
215
  'Source: ' + item.memoryId + ' / ' + item.scope + ' / v' + item.sourceVersion + ' / ' + String(item.recordDigest).slice(0, 16),
209
216
  'Reason: ' + reason,
210
217
  ]
218
+ if (Number.isFinite(sc)) {
219
+ const r = Number.isInteger(rank) && rank > 0 ? rank : null
220
+ const t = Number.isInteger(total) && total > 0 ? total : null
221
+ lines.push('Score: ' + sc.toFixed(2) + (r && t ? ' (rank ' + r + '/' + t + ')' : ''))
222
+ }
211
223
  lines.push(refText ? 'Reference: ' + refText : 'Reference: (omitted by budget)')
212
224
  return lines.join('\n')
213
225
  }
@@ -256,8 +268,9 @@ export function renderReferenceTail(items, opts = {}) {
256
268
  if (used + skillCost > budget) skillDropped = true
257
269
  else { skillBlock = sb; used += skillCost }
258
270
  }
259
- for (const it of sorted) {
260
- const block = renderItemBlock(it, reason)
271
+ for (let rank = 0; rank < sorted.length; rank++) {
272
+ const it = sorted[rank]
273
+ const block = renderItemBlock(it, reason, rank + 1, sorted.length)
261
274
  const cost = Buffer.byteLength(block, 'utf8') + 1
262
275
  if (used + cost > budget) { dropped.push({ memoryId: it.memoryId, recordDigest: it.recordDigest, reason: 'tail-budget' }); continue }
263
276
  blocks.push(block)
@@ -277,7 +290,7 @@ export function renderReferenceTail(items, opts = {}) {
277
290
 
278
291
  /** §5 packetId:由 activationId+contextVersion+indexVersion+exactDigest 确定。 */
279
292
  export function buildPacketId(activationId, contextVersion, memoryIndexVersion, exactDigest) {
280
- const parts = ['reference-tail-packet-pre-v1', String(activationId || ''), contextVersion | 0, String(memoryIndexVersion || ''), String(exactDigest || '')]
293
+ const parts = ['reference-tail-packet-v1', String(activationId || ''), contextVersion | 0, String(memoryIndexVersion || ''), String(exactDigest || '')]
281
294
  return PACKET_ID_PREFIX + first32(sha256Str(JSON.stringify(parts)))
282
295
  }
283
296
 
@@ -391,7 +404,7 @@ export function buildReferenceTailPacketPre(input) {
391
404
  */
392
405
  export function makeFakeActivationRequestPre(opts = {}) {
393
406
  const seed = String(opts.seed || 'fake-seed')
394
- const activationId = ACTIVATION_ID_PREFIX + first32(sha256Str('fake-activation-pre-v1\u0000' + seed))
407
+ const activationId = ACTIVATION_ID_PREFIX + first32(sha256Str('fake-activation-v1\u0000' + seed))
395
408
  const records = Array.isArray(opts.records) ? opts.records : []
396
409
  const picked = records.slice(0, Math.max(1, Math.min(opts.maxItems || 3, REFERENCE_TAIL_BUDGET_V1.maxCandidates)))
397
410
  const candidates = picked.map((rec, i) => ({
@@ -400,6 +413,8 @@ export function makeFakeActivationRequestPre(opts = {}) {
400
413
  sourceEpoch: rec.sourceEpoch, sourceVersion: rec.sourceVersion, fileDigest: rec.fileDigest, recordDigest: rec.recordDigest,
401
414
  score: typeof rec.score === 'number' ? rec.score : 0.9 - i * 0.05,
402
415
  excerpt: rec.excerpt != null ? sanitizeExcerpt(rec.excerpt) : undefined,
416
+ // C8/I5(2026-09-14):fixture 也要能构造"非 current"候选,否则注入侧过滤无从测起。
417
+ status: rec.status || 'current',
403
418
  }))
404
419
  const now = Number.isFinite(opts.now) ? opts.now : Date.now()
405
420
  const ttlSteps = opts.ttlSteps || 2
@@ -409,7 +424,10 @@ export function makeFakeActivationRequestPre(opts = {}) {
409
424
  kind: 'activation_request',
410
425
  activationId,
411
426
  observationId: opts.observationId || ('obs_' + first32(sha256Str('fake-obs\u0000' + seed))),
412
- workerEpoch: opts.workerEpoch || 'fake-epoch-pre-v1',
427
+ // T0-2(2026-09-14):请求侧可显式给 requestKey(同一次用户请求的稳定标识)。
428
+ // 缺省为空串——下游对它 fail closed("没有就不复用"),不会因此误判为同请求。
429
+ requestKey: opts.requestKey || '',
430
+ workerEpoch: opts.workerEpoch || 'fake-epoch-v1',
413
431
  sessionId: opts.sessionId || '',
414
432
  agentId: opts.agentId || '',
415
433
  workspaceKey: opts.workspaceKey || '',
@@ -0,0 +1,30 @@
1
+ /**
2
+ * board-mode-pre —— WB-GRAPH 白板线总开关(2026-09-16, board_mode_v1)。
3
+ *
4
+ * 裁定来源: WB-GRAPH-DECISIONS-20260914.md §E + 用户 2026-09-16 凌晨原话:
5
+ * 「一口气全做完,但是线先别着急接。可以先在设置里,或者在接续面板上设置一个按钮,一键切换旧版和新版。」
6
+ *
7
+ * 设计(硬纪律):
8
+ * - boardMode = 'legacy'(默认) | 'graph'(新版看板 dsh-graph + sidecar + 遍历工具)。
9
+ * - **开关解耦**(用户硬性规则): boardMode 只决定"白板线新能力是否激活",
10
+ * 不顺带改变任何其他功能行为; legacy 模式下一切行为与 P5 收官时**字节级一致**。
11
+ * - graph_* 工具(dsh-graph vendor)与 memory_expand_pre/memory_trace_pre(P3)
12
+ * 仅在 boardMode='graph' 时注册; P2 sidecar 仅在 'graph' 时写盘。
13
+ * - fail closed: 非法值一律按 legacy(旧行为), 绝不猜。
14
+ */
15
+
16
+ export const BOARD_MODE_VERSION = 'board_mode_v1'
17
+ export const BOARD_MODES_V1 = Object.freeze(['legacy', 'graph'])
18
+
19
+ /**
20
+ * 解析白板模式。非法/缺省 → legacy(旧行为, fail closed)。
21
+ * @param {string|undefined} raw 配置里的 boardMode 原值
22
+ * @returns {{mode:'legacy'|'graph', graphEnabled:boolean, source:'default'|'config'|'fallback'}}
23
+ */
24
+ export function resolveBoardModePre(raw) {
25
+ const s = String(raw == null ? '' : raw).trim().toLowerCase()
26
+ if (s === 'graph') return { mode: 'graph', graphEnabled: true, source: 'config' }
27
+ if (s === 'legacy' || s === '') return { mode: 'legacy', graphEnabled: false, source: s === '' ? 'default' : 'config' }
28
+ // 非法值: fallback 到 legacy 并留痕(调用方可把 source==='fallback' 记 diag)
29
+ return { mode: 'legacy', graphEnabled: false, source: 'fallback' }
30
+ }