@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
@@ -157,7 +157,7 @@ export function validateAccessEvidencePre(ev) {
157
157
 
158
158
  /** §4 canonical identity:observationId 由 sessionId+contextVersion+trigger digest+policy 决定。 */
159
159
  export function buildObservationId(sessionId, contextVersion, triggerDigest, policyVersion = CONTEXT_BRIDGE_POLICY_VERSION) {
160
- const parts = ['context-push-pre-v1', String(sessionId || ''), Number(contextVersion) || 0, String(triggerDigest || ''), policyVersion]
160
+ const parts = ['context-push-v1', String(sessionId || ''), Number(contextVersion) || 0, String(triggerDigest || ''), policyVersion]
161
161
  return OBSERVATION_PREFIX + first32(sha256Str(JSON.stringify(parts)))
162
162
  }
163
163
 
@@ -388,7 +388,7 @@ export function createFakeContextSinkPre(opts = {}) {
388
388
  frames.push(JSON.parse(JSON.stringify(frame)))
389
389
  if (frames.length > capacity) frames.shift()
390
390
  stats.accepted++
391
- return { observationId: id, accepted: true, workerEpoch: 'fake-epoch-pre-v1', reason: 'ok' }
391
+ return { observationId: id, accepted: true, workerEpoch: 'fake-epoch-v1', reason: 'ok' }
392
392
  },
393
393
  async closeSession() { stats.closedSessions++ },
394
394
  async dispose() { frames.length = 0 },
@@ -104,6 +104,12 @@ export function createContextHost(opts = {}) {
104
104
  let lastFrameIdentity = null // 最小投影:observationId 全值(本身是哈希)+计数,无任何文本
105
105
  let lastSegmentRuntimeKey = null
106
106
  let lastSegmentSessionRef = null
107
+ // C5/I7:最近一次"索引未就绪"降级(可读、可观测;含原因与时间,不含任何语料文本)
108
+ // ★多工作区适配(2026-09-17):改为**按会话分片**。旧实现是单变量,任何会话的 index-not-ready
109
+ // 都写进去,而读取方(`index.js` 的 buildTierLayerInjection)只判 10 分钟时间窗、**不判会话**
110
+ // ⇒ B 会话的"未就绪"会把 A 会话污染十分钟,表现为**跨会话假降级**(A 明明正常却被标注降级)。
111
+ const indexDegradeBySession = new Map()
112
+ let lastIndexDegrade = null // 兼容投影:最近一次(供 debugView 等旧读取方)
107
113
  let lastSinkMode = null
108
114
  let bridge = null
109
115
  let store = null
@@ -347,14 +353,31 @@ export function createContextHost(opts = {}) {
347
353
  ? Promise.resolve().then(() => engine._jsSemanticRank(corpusSnap, buildObserveWindowText(runtime, seg))).catch(() => null)
348
354
  : Promise.resolve(null)
349
355
  void Promise.all([readyPromise, c2RankPromise]).then(([readyRes, rank]) => {
356
+ // C5/I7(2026-09-14):索引未就绪**不再静默丢弃注入**。旧行为是 pushDrop('index-not-ready:…')
357
+ // 后直接 return —— 语义索引一卡死,注入面就整段消失且无人知(07:43–08:35 的违规现场)。
358
+ // 新行为 = 显式降级 + fail-open:记一条可读降级(reason 进注入层 `[降级]` 行),本轮按**词法命中**
359
+ // 继续组装并推送 envelope(Tier-0 目录层照常常驻),稠密臂本轮不参与融合(不装懂)。
360
+ let indexNotReady = ''
350
361
  if (!(readyRes && readyRes.ready)) {
351
- stats.pushesRejected++
352
- pushDrop('index-not-ready:' + ((readyRes && readyRes.reason) || 'unknown'), runtime.contextVersion, runtime.key)
353
- return
362
+ indexNotReady = String((readyRes && readyRes.reason) || 'unknown')
363
+ stats.indexDegraded = (stats.indexDegraded || 0) + 1
364
+ const sidDeg = String(runtime.sessionId || '')
365
+ const degradeRec = { reason: indexNotReady, at: Date.now(), sessionId: sidDeg }
366
+ // 多工作区适配(2026-09-17):按会话分片写入,读取方按会话取 ⇒ 不再跨会话污染。
367
+ indexDegradeBySession.set(sidDeg, degradeRec)
368
+ if (indexDegradeBySession.size > 32) {
369
+ const oldest = indexDegradeBySession.keys().next().value
370
+ indexDegradeBySession.delete(oldest)
371
+ }
372
+ lastIndexDegrade = degradeRec // 兼容投影(最近一次)
373
+ try { engine._lastIndexDegrade = degradeRec } catch (_) {}
374
+ try { engine._indexDegradeBySession = indexDegradeBySession } catch (_) {}
375
+ pushDrop('index-not-ready:degraded:' + indexNotReady, runtime.contextVersion, runtime.key)
376
+ diagCtx('ctx-host degrade: index-not-ready(' + indexNotReady + ') → 词法命中 + 常驻目录层继续注入')
354
377
  }
355
378
  if (st.disposed || runtime.disposed) return
356
379
  let keptList = ls.kept
357
- if (rank && rank.scores && rank.scores.size) {
380
+ if (!indexNotReady && rank && rank.scores && rank.scores.size) {
358
381
  // D6 融合候选池=词法 kept ∪ 稠密 top-K(语料中找回记录对象;语义强命中但词法零分者由此入选)
359
382
  const poolMap = new Map()
360
383
  for (const k of ls.kept) if (k && k.memoryId) poolMap.set(k.memoryId, k)
@@ -463,14 +486,21 @@ export function createContextHost(opts = {}) {
463
486
  const session = built.frame.session || {}
464
487
  const cursor = built.frame.cursor || {}
465
488
  const obs = built.frame.observationId || ''
489
+ // 2026-09-14 P1-⑮:注入侧 reason 串补 0-1 分值(intent/dense/margin)。
490
+ // 数值段置于 reasonCodes **之前**,160 字符截断只砍代码列表、不砍分值。
491
+ const num2 = (x) => (Number.isFinite(Number(x)) ? Number(x).toFixed(2) : '0.00')
492
+ const jsReason = ('js-decide lane=' + dec.lane + ' ' + (dec.decision || 'emit') +
493
+ ' intent=' + num2(dec.features && dec.features.intentProb) +
494
+ ' dense=' + num2(dec._denseTop) + ' margin=' + num2(dec._margin) +
495
+ ' ' + (dec.reasonCodes || []).join(',')).slice(0, 160)
466
496
  const act = {
467
497
  schemaVersion: 1, namespace: 'dsh-auto-memory', kind: 'activation_request',
468
498
  activationId: 'act_' + (obs.startsWith('obs_') ? obs.slice(8, 40) : 'jsdecide'),
469
- observationId: obs, workerEpoch: 'js-decide-pre-v1',
499
+ observationId: obs, workerEpoch: 'js-decide-v1',
470
500
  sessionId: session.sessionId || '', agentId: session.agentId || '',
471
501
  workspaceKey: session.workspaceKey || '', scope: session.scope || 'Workspace',
472
502
  contextVersion: cursor.contextVersion || 0, memoryIndexVersion: built.frame.index ? built.frame.index.memoryIndexVersion : '',
473
- threshold: { policyVersion: dec.features ? 'activation_policy_v2' : 'activation_policy_v2', score: (dec.features && dec.features.intentProb) || 0, threshold: 0.45, reason: ('js-decide lane=' + dec.lane + ' ' + (dec.reasonCodes || []).join(',')) },
503
+ threshold: { policyVersion: dec.features ? 'activation_policy_v2' : 'activation_policy_v2', score: (dec.features && dec.features.intentProb) || 0, threshold: 0.45, reason: jsReason },
474
504
  level: 'excerpt',
475
505
  candidates: (function () {
476
506
  // 2026-08-27 优化③:候选方案档位(balanced=3×40 / dense=6×20 / custom=自定义)
@@ -532,7 +562,39 @@ export function createContextHost(opts = {}) {
532
562
  }
533
563
  }
534
564
  } catch (_) {}
565
+ // C5 闸门:记下本轮 query(激活请求本身不带 query 文本),供注入层判"是问为什么还是只要索引"
566
+ try {
567
+ const qSid = String((act && act.sessionId) || '')
568
+ const qRec = {
569
+ at: Date.now(),
570
+ sessionId: qSid,
571
+ text: String(jsDecideQueryText || '').slice(0, 1200),
572
+ }
573
+ // 多工作区适配(2026-09-17):按会话分片。旧实现单槽无条件覆盖 ⇒ A 的 query 被 B 顶掉,
574
+ // 读取侧因会话不匹配而退回 triggerText(语义降级)。分片后各会话各记一份。
575
+ if (!engine._lastTierQueryBySession || typeof engine._lastTierQueryBySession.set !== 'function') {
576
+ engine._lastTierQueryBySession = new Map()
577
+ }
578
+ engine._lastTierQueryBySession.set(qSid, qRec)
579
+ if (engine._lastTierQueryBySession.size > 32) {
580
+ const oldest = engine._lastTierQueryBySession.keys().next().value
581
+ engine._lastTierQueryBySession.delete(oldest)
582
+ }
583
+ engine._lastTierQuery = qRec // 兼容投影(最近一次)
584
+ } catch (_) {}
535
585
  engine._activationHost.offerExternalActivation(act)
586
+ // 命中投影补上 query(记录发生在 offer 内部;此处补字段,顺序确定)
587
+ // 多工作区适配(2026-09-17):分片条目与兼容投影**都要补**,否则按会话取到的投影缺 question。
588
+ try {
589
+ const actSid = String((act && act.sessionId) || '')
590
+ const qText = String(jsDecideQueryText || '').slice(0, 1200)
591
+ const rec = engine._tierGateHitsBySession && typeof engine._tierGateHitsBySession.get === 'function'
592
+ ? engine._tierGateHitsBySession.get(actSid) : null
593
+ if (rec && String(rec.sessionId) === actSid) rec.question = qText
594
+ if (engine._tierGateHits && String(engine._tierGateHits.sessionId) === actSid) {
595
+ engine._tierGateHits.question = qText
596
+ }
597
+ } catch (_) {}
536
598
  }).catch(() => {})
537
599
  } catch (_) {}
538
600
  }
@@ -723,6 +785,8 @@ export function createContextHost(opts = {}) {
723
785
  durableEventsOnDisk: evidenceRootExists() ? storeFor().loadEvents().events.length : 0,
724
786
  stats: { ...stats, bridge: { ...b.stats } },
725
787
  storeStats: store ? { ...store.stats } : null,
788
+ // C5/I7:索引未就绪降级(latest) —— 供面板/注入层解释"为什么这轮只有词法命中"
789
+ indexDegrade: lastIndexDegrade,
726
790
  recentDrops: volatileDrops.slice(-4),
727
791
  }
728
792
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 引擎身份(engine_identity_v1)—— P2「引擎隔离」的地基(V2-P2 卡 / 评审 §3.5)。
3
+ *
4
+ * 为什么需要它(权威依据):
5
+ * V2-P2 卡明确指出:单个 `PROVIDER_ID_INT8`(`python-setup.js:55`)**不足以**标识
6
+ * 模型内容 / tokenizer / 预处理;两层缓存若只凭 `chunkId` 或**裸输入哈希**读取,
7
+ * **仍会串用两套向量**(e5 384 维与 bge-m3 1024 维混进同一次排序 = T2-9 判失败)。
8
+ * 因此身份必须够宽,至少覆盖:
9
+ * 模型与权重摘要 · tokenizer 版本 · 精度格式 · 维度 · 池化 · 归一化 · 输入处理版本。
10
+ *
11
+ * 本模块只做三件事(纯函数、零 IO、零依赖,便于宿主与测试共用):
12
+ * 1) `computeEngineIdentityPre(desc)` —— 宽身份描述 → 稳定身份串 `engid_<32hex>`
13
+ * (canonical 排序 JSON + sha256,同输入逐字节同输出;任一字段变化 → 身份必变)。
14
+ * 2) `aliasKeyPre(engineIdentity, chunkId)` —— 两级引用的**第一级**:
15
+ * `engineIdentity + chunkId → alias`。记录变化导致未改块重新编号(chunkId 变)时,
16
+ * 只更新 alias,**不重新编码相同输入**(不改现有 chunkId 公式)。
17
+ * 3) `vectorKeyPre(engineIdentity, exactEncoderInput)` —— **第二级**:
18
+ * `alias → hash(exactEncoderInput) → 向量对象`。相同输入且相同引擎 ⇒ 同一向量对象,
19
+ * 可直接复用;引擎不同 ⇒ vectorKey 必不同 ⇒ 不可能跨引擎串用(T2-9 的零成本实现)。
20
+ *
21
+ * 成本口径(评审 §3.5 采纳的措辞,不得写「成本为零」):
22
+ * **身份比较开销小;全量重建成本由本机承担。**
23
+ *
24
+ * UTF-8 无 BOM。
25
+ */
26
+ import { createHash } from 'node:crypto'
27
+
28
+ export const ENGINE_IDENTITY_VERSION = 'engine_identity_v1'
29
+
30
+ /** 身份串前缀:有意与 corpus 的 `idx_` / L0 的 `l0idx_` 区分,避免三套版本语义混淆。 */
31
+ export const ENGINE_IDENTITY_PREFIX = 'engid_'
32
+
33
+ /** 身份必须覆盖的字段(缺一即身份不完整;缺失字段按空串参与哈希,不给"省略=通过"的口子)。 */
34
+ export const ENGINE_IDENTITY_FIELDS = Object.freeze([
35
+ 'provider', // 通道/提供方标识(如 bge-m3-onnx-int8-v1 / js-e5-small-q8-v1)
36
+ 'model', // 模型名(如 bge-m3 / multilingual-e5-small)
37
+ 'weightsDigest', // 权重摘要(sha256 或廉价指纹;见 weightsDigestOfFilePre 的口径说明)
38
+ 'tokenizer', // tokenizer 版本/来源(如 xenova-bge-m3-fast / e5-small-tokenizer-v1)
39
+ 'precision', // 精度格式(int8 / q8 / fp32)
40
+ 'dim', // 向量维度(384 / 1024)
41
+ 'pooling', // 池化方式(cls / mean)
42
+ 'normalized', // 是否已归一化(true/false)
43
+ 'inputVersion', // 输入处理版本(前缀、截断、清洗等预处理口径)
44
+ ])
45
+
46
+ const sha256Hex = (s) => createHash('sha256').update(String(s == null ? '' : s), 'utf8').digest('hex')
47
+
48
+ /**
49
+ * 规范化身份描述:只保留白名单字段,值统一为字符串(数字/布尔 canonical 化),
50
+ * 键按固定顺序(ENGINE_IDENTITY_FIELDS,而非字母序)序列化 —— 顺序固定 = 哈希稳定。
51
+ */
52
+ export function canonicalEngineDescPre(desc) {
53
+ const d = desc && typeof desc === 'object' ? desc : {}
54
+ const out = {}
55
+ for (const k of ENGINE_IDENTITY_FIELDS) {
56
+ const v = d[k]
57
+ if (v === null || v === undefined) { out[k] = ''; continue }
58
+ if (typeof v === 'boolean') { out[k] = v ? 'true' : 'false'; continue }
59
+ if (typeof v === 'number') { out[k] = Number.isFinite(v) ? String(v) : ''; continue }
60
+ out[k] = String(v)
61
+ }
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * 宽身份计算:canonical 描述 → `engid_<first32hex(sha256)>`。
67
+ * 任一字段(含维度、精度、tokenizer、输入版本)变化 → 身份必变;同输入确定性。
68
+ */
69
+ export function computeEngineIdentityPre(desc) {
70
+ const canon = canonicalEngineDescPre(desc)
71
+ return ENGINE_IDENTITY_PREFIX + sha256Hex(JSON.stringify(canon)).slice(0, 32)
72
+ }
73
+
74
+ /** 身份合法性:必须是本模块产出的前缀形态(防把任意字符串当身份塞进缓存)。 */
75
+ export function isEngineIdentityPre(x) {
76
+ return typeof x === 'string' && /^engid_[0-9a-f]{32}$/.test(x)
77
+ }
78
+
79
+ /** 两个身份是否同一引擎。非法的身份一律视为不匹配(fail closed:宁可重建,不冒串用风险)。 */
80
+ export function engineIdentityMatchesPre(a, b) {
81
+ if (!isEngineIdentityPre(a) || !isEngineIdentityPre(b)) return false
82
+ return a === b
83
+ }
84
+
85
+ /**
86
+ * 第一级引用:`engineIdentity + chunkId → aliasKey`。
87
+ * 用途:记录内容变化 → memoryId/recordDigest 变化 → chunkId 变;此键随引擎与 chunkId 走,
88
+ * 是"记录 → 向量"的可更新指针(记录重编号时只改这一层)。
89
+ */
90
+ export function aliasKeyPre(engineIdentity, chunkId) {
91
+ return String(engineIdentity || '') + '|' + String(chunkId == null ? '' : chunkId)
92
+ }
93
+
94
+ /**
95
+ * 第二级引用:`engineIdentity + exactEncoderInput → vectorKey`。
96
+ * exactEncoderInput = **真正送进编码器的文本**(含引擎内部前缀之后的形态;调用方负责给出
97
+ * 与编码器输入一致的字符串)。相同引擎 + 相同输入 ⇒ 相同 vectorKey ⇒ 复用向量对象。
98
+ * 该键不含 chunkId,因此**块重新编号不会导致重复编码**(T2-2 的核心)。
99
+ */
100
+ export function vectorKeyPre(engineIdentity, exactEncoderInput) {
101
+ return String(engineIdentity || '') + '|' + sha256Hex(exactEncoderInput)
102
+ }
103
+
104
+ /**
105
+ * 权重摘要的**廉价指纹**(性能口径,必须如实标注):
106
+ * 对 2GB 级 onnx 做全量 sha256 代价过高,这里用 `size:mtimeMs:文件名` 的 sha256 作为
107
+ * **权重变更指纹**(同一份权重稳定、文件被替换/重下必变)。
108
+ * 说明:这是**指纹(fingerprint)而非内容摘要(digest)**;要真摘要需在切换时一次性计算
109
+ * 并落盘缓存(P4 若需要再补),本阶段不引入 2GB 级读盘开销。
110
+ */
111
+ export function weightsDigestOfFilePre(stat, name) {
112
+ const size = Number(stat && stat.size) || 0
113
+ const mtime = Number(stat && (stat.mtimeMs != null ? stat.mtimeMs : stat.mtime)) || 0
114
+ return 'fp_' + sha256Hex(size + ':' + mtime + ':' + String(name == null ? '' : name)).slice(0, 32)
115
+ }
116
+
117
+ /** 端侧 JS 引擎(e5-small q8,384 维已归一化)的规范身份描述。 */
118
+ export const JS_E5_IDENTITY_DESC_V1 = Object.freeze({
119
+ provider: 'js-e5-small-q8-v1',
120
+ model: 'multilingual-e5-small',
121
+ weightsDigest: 'manifest-e5-small-q8-v1', // 端侧资产清单版本;模型文件摘要由下载清单锁定
122
+ tokenizer: 'e5-small-tokenizer-v1',
123
+ precision: 'q8',
124
+ dim: 384,
125
+ pooling: 'mean',
126
+ normalized: true,
127
+ inputVersion: 'e5-passage-prefix-v1', // 引擎内部加 `passage: ` 前缀(见 semantic-js.js:351 注释)
128
+ })
129
+
130
+ /** Python 引擎(bge-m3 onnx int8,1024 维)的身份描述构造器(weightsDigest 由调用方给指纹)。 */
131
+ export function pyBgeM3IdentityDescPre(weightsDigest) {
132
+ return {
133
+ provider: 'bge-m3-onnx-int8-v1',
134
+ model: 'bge-m3',
135
+ weightsDigest: weightsDigest || '',
136
+ tokenizer: 'xenova-bge-m3-fast',
137
+ precision: 'int8',
138
+ dim: 1024,
139
+ pooling: 'cls',
140
+ normalized: true,
141
+ inputVersion: 'bge-m3-raw-v1',
142
+ }
143
+ }
144
+
145
+ /** 供诊断/测试的最小投影(不泄内容:只有身份串与字段读数)。 */
146
+ export function describeEngineIdentityPre(desc) {
147
+ const canon = canonicalEngineDescPre(desc)
148
+ return { identity: computeEngineIdentityPre(desc), fields: canon, version: ENGINE_IDENTITY_VERSION }
149
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * 引擎切换状态机(engine_switch_v1)—— P2 / T2-9 切档隔离与进度条(V2-P2 卡 points 4/5)。
3
+ *
4
+ * 权威依据(评审 §3.5 采纳条目 + V2-P2 卡):
5
+ * · **切档 = 强制全量重建 + 进度条**(用户裁定):每次**实际切换**创建**新 rebuild generation**,
6
+ * 目标重建**不复用旧代一二级缓存**(含 e5→BGE→e5);完成后恢复日常增量。
7
+ * · **进度只能有一个真实所有者**:本模块持有唯一进度状态;`semantic-status` 与向导只**投影**它,
8
+ * 不自行维护第二套计数(python-setup 的 status 只读投影,见 index.js semantic-status 接线)。
9
+ * · **保存后才启动切换**:调用方在配置落盘后调 `beginEngineSwitchPre`,不由下拉框未保存值驱动。
10
+ * · **进度只能来自实际完成量**:`done` 由 `reportScopeDone` 逐项累加;**manifest 发布前不得显示整体完成**
11
+ * (`phase` 未到 'ready' 前 `indexReady` 恒 false,即便 done==total 也须等 `publish`)。
12
+ * · **A 未完成又发起 B**:B 使 A 作废(superseded);**A 迟到不能发布 B 的 ready**(switchId 校验)。
13
+ * · **同一 switchId 重试不重复编码**:已完成(ready)的同 id 再次 begin 直接返回现状,不重跑。
14
+ * · **编码中暂停 / 校验失败 / 发布失败 / 重启恢复**:状态可持久化(IO 注入),重启后 running → interrupted,
15
+ * `done` 保留(来自实际完成量),下次 begin 或 resume 继续。
16
+ *
17
+ * 状态字段(最小投影,不泄内容):
18
+ * {switchId, generation, phase, fromIdentity, toIdentity, total, done, failed, error,
19
+ * startedAt, updatedAt, finishedAt, supersededBy, published}
20
+ * phase ∈ idle | running | interrupted | publishing | ready | failed | cancelled
21
+ *
22
+ * 零第三方依赖;IO 全注入(不传则纯内存);UTF-8 无 BOM。
23
+ */
24
+
25
+ export const ENGINE_SWITCH_VERSION = 'engine_switch_v1'
26
+
27
+ /** 终态:到达后不再变化(除 begin 开新代)。 */
28
+ const TERMINAL = new Set(['ready', 'failed', 'cancelled'])
29
+
30
+ function nowOf(opts) { return typeof opts.now === 'function' ? Number(opts.now()) || 0 : Date.now() }
31
+
32
+ /**
33
+ * @param {object} opts
34
+ * @param {()=>number} [opts.now] 时间注入(测试确定性)
35
+ * @param {{readJson?:(p:any)=>any, writeJson?:(p:any,o:any)=>void}} [opts.io] 持久化 IO(缺省纯内存)
36
+ * @param {string} [opts.persistPath] 状态文件路径(提供且 io 可用时才落盘)
37
+ * @param {(msg:string)=>void} [opts.diag]
38
+ */
39
+ export function createEngineSwitchPre(opts = {}) {
40
+ const io = opts.io || null
41
+ const persistPath = opts.persistPath ? String(opts.persistPath) : ''
42
+ const diagFn = typeof opts.diag === 'function' ? opts.diag : () => {}
43
+ const safeDiag = (m) => { try { diagFn(String(m).slice(0, 200)) } catch (_) {} }
44
+
45
+ let generation = 0
46
+ let state = {
47
+ switchId: '', generation: 0, phase: 'idle',
48
+ fromIdentity: '', toIdentity: '', total: 0, done: 0, failed: 0,
49
+ error: '', supersededBy: '', published: false,
50
+ startedAt: 0, updatedAt: 0, finishedAt: 0,
51
+ }
52
+ // 同代内"已处理单元"集合:同 switchId 重试/恢复时跳过(不重复编码)
53
+ let doneUnits = new Set()
54
+
55
+ function snapshot() {
56
+ return {
57
+ ...state,
58
+ doneUnits: doneUnits.size,
59
+ indexReady: state.phase === 'ready' && state.published === true,
60
+ version: ENGINE_SWITCH_VERSION,
61
+ }
62
+ }
63
+
64
+ function persist() {
65
+ if (!persistPath || !io || typeof io.writeJson !== 'function') return
66
+ try {
67
+ io.writeJson(persistPath, {
68
+ version: ENGINE_SWITCH_VERSION,
69
+ generation, state: { ...state },
70
+ doneUnits: [...doneUnits],
71
+ })
72
+ } catch (e) { safeDiag('engine-switch persist fail: ' + ((e && e.message) || e)) }
73
+ }
74
+
75
+ /** 启动恢复:running → interrupted(进度保留 = 实际完成量);ready 保持。 */
76
+ function restore() {
77
+ if (!persistPath || !io || typeof io.readJson !== 'function') return
78
+ let raw = null
79
+ try { raw = io.readJson(persistPath) } catch (_) { return }
80
+ if (!raw || typeof raw !== 'object') return
81
+ try {
82
+ const st = raw.state && typeof raw.state === 'object' ? raw.state : null
83
+ if (!st) return
84
+ generation = Number(raw.generation) || 0
85
+ state = {
86
+ switchId: String(st.switchId || ''), generation: Number(st.generation) || generation,
87
+ phase: String(st.phase || 'idle'),
88
+ fromIdentity: String(st.fromIdentity || ''), toIdentity: String(st.toIdentity || ''),
89
+ total: Number(st.total) || 0, done: Number(st.done) || 0, failed: Number(st.failed) || 0,
90
+ error: String(st.error || ''), supersededBy: String(st.supersededBy || ''),
91
+ published: st.published === true,
92
+ startedAt: Number(st.startedAt) || 0, updatedAt: Number(st.updatedAt) || 0, finishedAt: Number(st.finishedAt) || 0,
93
+ }
94
+ if (Array.isArray(raw.doneUnits)) doneUnits = new Set(raw.doneUnits.map(String))
95
+ // ★T2-9c 重启恢复:**未完成的 running 一律降级为 interrupted**(进程已换代),
96
+ // 进度保持"实际完成量",不假装完成;indexReady 仍为 false(published 未置位)。
97
+ if (state.phase === 'running' || state.phase === 'publishing') {
98
+ state.phase = 'interrupted'
99
+ state.error = state.error || 'interrupted-by-restart'
100
+ state.updatedAt = nowOf(opts)
101
+ persist()
102
+ }
103
+ } catch (e) { safeDiag('engine-switch restore fail: ' + ((e && e.message) || e)) }
104
+ }
105
+ restore()
106
+
107
+ /**
108
+ * 开始一次**实际**引擎切换(保存配置之后调用)。
109
+ * @returns {{ok:boolean, switchId:string, generation:number, reason?:string, resumed?:boolean}}
110
+ * - 同一 switchId 且已 ready → 直接返回现状(**不重复编码**,T2-9b 幂等)。
111
+ * - 同一 switchId 且 running/interrupted → 恢复续跑(done 保留)。
112
+ * - 新 switchId → generation+1,作废旧的在飞切换(supersededBy),清空进度重新计。
113
+ */
114
+ function beginEngineSwitchPre(input = {}) {
115
+ const switchId = String(input.switchId || '').trim()
116
+ const toIdentity = String(input.toIdentity || '').trim()
117
+ const fromIdentity = String(input.fromIdentity || state.toIdentity || '').trim()
118
+ const total = Number(input.total) > 0 ? Math.floor(Number(input.total)) : 0
119
+ if (!switchId) return { ok: false, switchId: '', generation, reason: 'no-switch-id' }
120
+ if (!/^engid_[0-9a-f]{32}$/.test(toIdentity)) return { ok: false, switchId, generation, reason: 'bad-identity' }
121
+ // 幂等:同 id 已完成 → 不重跑
122
+ if (state.switchId === switchId && state.phase === 'ready' && state.toIdentity === toIdentity) {
123
+ return { ok: true, switchId, generation: state.generation, reason: 'already-ready' }
124
+ }
125
+ // 续跑:同 id 未完成 → 保留 done/doneUnits
126
+ if (state.switchId === switchId && (state.phase === 'running' || state.phase === 'interrupted')) {
127
+ state.phase = 'running'
128
+ if (total > 0) state.total = total
129
+ state.updatedAt = nowOf(opts)
130
+ persist()
131
+ return { ok: true, switchId, generation: state.generation, resumed: true }
132
+ }
133
+ // 新切换:作废旧代(A 迟到不得发布 B 的 ready)
134
+ const prevSwitchId = state.switchId
135
+ if (prevSwitchId && !TERMINAL.has(state.phase)) state.supersededBy = switchId
136
+ generation = (Number(state.generation) || generation) + 1
137
+ doneUnits = new Set()
138
+ const t = nowOf(opts)
139
+ state = {
140
+ switchId, generation, phase: 'running',
141
+ fromIdentity, toIdentity, total, done: 0, failed: 0,
142
+ error: '', supersededBy: '', published: false,
143
+ startedAt: t, updatedAt: t, finishedAt: 0,
144
+ }
145
+ persist()
146
+ return { ok: true, switchId, generation, fresh: true, superseded: prevSwitchId || '' }
147
+ }
148
+
149
+ /**
150
+ * 报告一批实际完成的单元(由索引侧在**真实完成编码/同步**后调用)。
151
+ * 幂等:同一 unitKey 重复上报不重复计数(同一 switchId 重试不重复编码)。
152
+ */
153
+ function reportDone(switchId, unitKeys, opts2 = {}) {
154
+ if (state.switchId !== switchId) return { ok: false, reason: 'stale-switch' } // A 迟到不算数
155
+ if (state.phase !== 'running' && state.phase !== 'publishing') return { ok: false, reason: 'not-running' }
156
+ const keys = (Array.isArray(unitKeys) ? unitKeys : [unitKeys]).map(String).filter(Boolean)
157
+ let added = 0
158
+ for (const k of keys) { if (!doneUnits.has(k)) { doneUnits.add(k); added++ } }
159
+ state.done = doneUnits.size
160
+ if (Number(opts2.total) > 0) state.total = Math.floor(Number(opts2.total))
161
+ state.updatedAt = nowOf(opts)
162
+ persist()
163
+ return { ok: true, done: state.done, added }
164
+ }
165
+
166
+ /** 记录失败单元数(不影响 done;T2-9d:失败要让词法仍可用 + 原因可见)。 */
167
+ function reportFailed(switchId, n = 1, reason = '') {
168
+ if (state.switchId !== switchId) return { ok: false, reason: 'stale-switch' }
169
+ state.failed += Math.max(0, Math.floor(Number(n) || 0))
170
+ if (reason) state.error = String(reason).slice(0, 160)
171
+ state.updatedAt = nowOf(opts)
172
+ persist()
173
+ return { ok: true, failed: state.failed }
174
+ }
175
+
176
+ /**
177
+ * 发布 manifest → 进入 ready(**整体完成只能在这里之后显示**)。
178
+ * A 迟到发布:如果当前 state.switchId 不是它,直接拒绝(T2-9b)。
179
+ */
180
+ function publishReady(switchId) {
181
+ if (state.switchId !== switchId) return { ok: false, reason: 'stale-switch' }
182
+ if (state.phase !== 'running' && state.phase !== 'publishing' && state.phase !== 'interrupted') {
183
+ return { ok: false, reason: 'bad-phase' }
184
+ }
185
+ state.phase = 'ready'
186
+ state.published = true
187
+ state.updatedAt = nowOf(opts)
188
+ state.finishedAt = state.updatedAt
189
+ persist()
190
+ return { ok: true, indexReady: true }
191
+ }
192
+
193
+ /** 校验失败 / 发布失败:进入 failed,原因可见(词法侧不受影响)。 */
194
+ function failSwitch(switchId, reason = '') {
195
+ if (state.switchId !== switchId) return { ok: false, reason: 'stale-switch' }
196
+ state.phase = 'failed'
197
+ state.error = String(reason || 'failed').slice(0, 160)
198
+ state.published = false
199
+ state.updatedAt = nowOf(opts)
200
+ state.finishedAt = state.updatedAt
201
+ persist()
202
+ return { ok: true }
203
+ }
204
+
205
+ /** 用户取消(或切档前放弃):进入 cancelled;进度保留在读数里可审计。 */
206
+ function cancelEngineSwitchPre(switchId) {
207
+ const id = String(switchId || state.switchId || '')
208
+ if (!id || state.switchId !== id) return { ok: false, reason: 'stale-switch' }
209
+ if (TERMINAL.has(state.phase) && state.phase !== 'ready') return { ok: false, reason: 'bad-phase' }
210
+ state.phase = 'cancelled'
211
+ state.published = false
212
+ state.updatedAt = nowOf(opts)
213
+ state.finishedAt = state.updatedAt
214
+ persist()
215
+ return { ok: true }
216
+ }
217
+
218
+ /** 计划总量(调用方在拿到冻结快照后设定;只影响进度分母)。 */
219
+ function setTotal(switchId, total) {
220
+ if (state.switchId !== switchId) return { ok: false, reason: 'stale-switch' }
221
+ state.total = Math.max(0, Math.floor(Number(total) || 0))
222
+ if (state.total && state.done > state.total) state.done = doneUnits.size = Math.min(doneUnits.size, state.total)
223
+ state.updatedAt = nowOf(opts)
224
+ persist()
225
+ return { ok: true }
226
+ }
227
+
228
+ function getEngineSwitchStatusPre() { return snapshot() }
229
+
230
+ /** 当前代是否要求"强制全量重建"(running/interrupted 时 true;ready/cancelled/idle 时 false)。 */
231
+ function requiresFullRebuild() { return state.phase === 'running' || state.phase === 'interrupted' }
232
+
233
+ return {
234
+ version: ENGINE_SWITCH_VERSION,
235
+ beginEngineSwitchPre,
236
+ cancelEngineSwitchPre,
237
+ getEngineSwitchStatusPre,
238
+ reportDone,
239
+ reportFailed,
240
+ publishReady,
241
+ failSwitch,
242
+ setTotal,
243
+ requiresFullRebuild,
244
+ _stateForTest: () => state,
245
+ _doneUnitsForTest: () => doneUnits,
246
+ }
247
+ }
@@ -1,3 +1,4 @@
1
+ import { stripRuntimeIntentPre } from './intent-clean-safe.js'
1
2
  /**
2
3
  * M8-1 Episodic Store 纯核心(docs/proactive-associative-memory-system-map.html M-02 Episodic)。
3
4
  * 纯内存状态机,零 IO、零依赖(node:crypto 仅作确定性身份);持久化通过可注入 IO 接口,
@@ -99,7 +100,7 @@ export function createEpisodicStorePre(opts = {}) {
99
100
  const stats = { segmentsAppended: 0, consolidated: 0, droppedTooShort: 0, retained: 0 }
100
101
 
101
102
  function defaultEpisodeId(sessionRef, startedAt) {
102
- const h = createHash('sha256').update(['episode-pre-v1', String(sessionRef), String(startedAt)].join('\u0000')).digest('hex')
103
+ const h = createHash('sha256').update(['episode-v1', String(sessionRef), String(startedAt)].join('\u0000')).digest('hex')
103
104
  return EPISODE_ID_PREFIX + h.slice(0, 32)
104
105
  }
105
106
 
@@ -118,7 +119,8 @@ export function createEpisodicStorePre(opts = {}) {
118
119
  }
119
120
  }
120
121
  // 累积文本(只保留文本特征,不保留原文全文 —— 隐私最小化)
121
- const ut = typeof s.userText === 'string' ? s.userText : ''
122
+ // issue#30:入库时就剥离运行时信封,避免注入快照一路流到 intent/title(下游只做兜底清洗)。
123
+ const ut = stripRuntimeIntentPre(typeof s.userText === 'string' ? s.userText : '')
122
124
  const at = typeof s.assistantText === 'string' ? s.assistantText : ''
123
125
  if (ut.trim()) current.userTexts.push(ut.trim().slice(0, 200))
124
126
  if (at.trim()) current.assistantTexts.push(at.trim().slice(0, 200))
@@ -140,15 +142,14 @@ export function createEpisodicStorePre(opts = {}) {
140
142
  return { ok: true, segments: current.segments.length }
141
143
  }
142
144
 
143
- /** 从累积文本提取 intent(2026-08-28 提纯):跳过本插件注入快照污染的段
144
- * (「Current runtime context. This snapshot…」/auto-memory 标记),取第一条
145
- * 真实用户文本;全被污染则取最后一条(退化)。 */
145
+ /** 从累积文本提取 intent(2026-08-28 提纯;2026-09-16 issue#30 改为共享清洗器)。
146
+ * 旧实现用一条正则猜"像注入"的文本,而注入形态会演进(新增标签/换行拼法)⇒ 漏判后
147
+ * 污染文本进入 intent,再经 hub 变成"观察型 procedure"的 title。现在复用
148
+ * `stripRuntimeIntentPre`(与 intent-clean 同源),按**行边界**剥离运行时信封,
149
+ * 保留代码块/引用块内的字面示例,避免误删真实文本。 */
146
150
  function extractIntent(ep) {
147
- const INJECTED_RE = /^current runtime context\.|^this snapshot supersedes|auto-memory|\[自动沉淀\]/i
148
- const texts = (ep.userTexts || []).map((t) => String(t || '').trim()).filter((t) => t)
149
- const clean = texts.filter((t) => !INJECTED_RE.test(t))
150
- const pick = clean.length ? clean[0] : (texts.length ? texts[texts.length - 1] : '')
151
- return pick ? pick.slice(0, 60) : '(未提取)'
151
+ const clean = (ep.userTexts || []).map((t) => stripRuntimeIntentPre(t).trim()).filter(Boolean)
152
+ return clean.length ? clean[0].slice(0, 60) : ''
152
153
  }
153
154
 
154
155
  /** 从累积文本提取 entities(简单启发式:用户文本中的 CJK 2-4 字符 token,高频优先)。 */
@@ -32,12 +32,12 @@ export function canonicalWorkspaceKey(key) {
32
32
 
33
33
  /** §10 隐私投影:sessionId → plugin-local sessionRef(不可逆哈希;跨文件稳定)。 */
34
34
  export function sessionRefOf(sessionId) {
35
- return 'sesr_' + first32(sha256Str('evidence-sesref-pre-v1\u0000' + String(sessionId || '')))
35
+ return 'sesr_' + first32(sha256Str('evidence-sesref-v1\u0000' + String(sessionId || '')))
36
36
  }
37
37
 
38
38
  /** §10 隐私投影:workspace 绝对键 → 稳定 workspaceRef(不落盘任何绝对路径)。 */
39
39
  export function workspaceRefOf(workspaceKey) {
40
- return 'wsr_' + first32(sha256Str('evidence-wsref-pre-v1\u0000' + canonicalWorkspaceKey(workspaceKey)))
40
+ return 'wsr_' + first32(sha256Str('evidence-wsref-v1\u0000' + canonicalWorkspaceKey(workspaceKey)))
41
41
  }
42
42
 
43
43
  /**
package/lib/fact-store.js CHANGED
@@ -148,7 +148,7 @@ export function createFactStorePre(opts = {}) {
148
148
  // ---- 确定性 factId(与 memoryId/anchorId 同风格) ----
149
149
  function defaultFactId(subject, predicate, object, scope) {
150
150
  const h = createHash('sha256').update(
151
- ['fact-pre-v1', String(scope), String(subject), String(predicate), String(object == null ? '' : object)].join('\u0000')
151
+ ['fact-v1', String(scope), String(subject), String(predicate), String(object == null ? '' : object)].join('\u0000')
152
152
  ).digest('hex')
153
153
  return FACT_ID_PREFIX + h.slice(0, 32)
154
154
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 有界 rename 重试 —— 应对 Windows 下短暂的文件句柄争用(issue #48)。
3
+ *
4
+ * 背景:Windows 上 DSH 常持有会话/记忆文件句柄(子代理刚结束时仍在写),
5
+ * 此时 `fs.rename` 抛 EPERM/EACCES/EBUSY。旧实现是"一次失败即硬失败",
6
+ * 在并发子代理场景下会把本可成功的写入判死。
7
+ *
8
+ * 语义边界:
9
+ * - delays 是**每次尝试之前的等待时长**,不是绝对时间戳;首项必须为 0(立即试一次)。
10
+ * - **不做** unlink/copy 回退:调用方各自保留自己的原子性与失败策略
11
+ * (删源再拷会破坏原子性,一旦中途失败会同时丢源与目标)。
12
+ * - 仅对**瞬时**错误码重试;其它错误(如 ENOENT)立即抛出,不掩盖真实故障。
13
+ * - 用尽重试仍失败 → 抛出最后一次的原始错误(保留 code/path 等信息)。
14
+ */
15
+ import { promises as fsDefault } from 'node:fs'
16
+ import { setTimeout as sleepDefault } from 'node:timers/promises'
17
+
18
+ export const RENAME_RETRY_DELAYS = Object.freeze([0, 50, 150, 400, 1000])
19
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY'])
20
+
21
+ /**
22
+ * 带退避的 rename。
23
+ * @param {string} from 源路径
24
+ * @param {string} to 目标路径
25
+ * @param {{fs?:object, delays?:number[], sleep?:Function}} [opts] 注入点(fs/sleep 供故障注入测试)
26
+ * @returns {Promise<void>} 成功即 resolve;失败抛原始错误
27
+ */
28
+ export async function retryRename(from, to, opts = {}) {
29
+ const fsApi = opts.fs || fsDefault
30
+ const configuredDelays = opts.delays || RENAME_RETRY_DELAYS
31
+ const delays = Array.isArray(configuredDelays) ? configuredDelays.slice() : configuredDelays
32
+ const sleep = opts.sleep || sleepDefault
33
+ if (!Array.isArray(delays) || !delays.length || delays[0] !== 0 ||
34
+ delays.some((ms) => !Number.isFinite(ms) || ms < 0)) {
35
+ throw new TypeError('retryRename: delays must start with 0 and contain finite nonnegative waits')
36
+ }
37
+ for (let attempt = 0; attempt < delays.length; attempt++) {
38
+ if (delays[attempt]) await sleep(delays[attempt])
39
+ try {
40
+ await fsApi.rename(from, to)
41
+ return
42
+ } catch (error) {
43
+ if (!TRANSIENT_RENAME_CODES.has(error && error.code) || attempt === delays.length - 1) throw error
44
+ }
45
+ }
46
+ }