@a9i5k4/dsh-auto-memory 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +19 -7
  2. package/README.zh-CN.md +19 -7
  3. package/docs/FRONTEND-CO-CREATION.md +191 -0
  4. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  5. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  6. package/docs/PROMO-PROMPT-3.0.md +100 -0
  7. package/docs/USER-GUIDE.en.md +2 -2
  8. package/docs/USER-GUIDE.zh-CN.md +2 -2
  9. package/docs/WHITEPAPER.md +207 -0
  10. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
  11. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
  12. package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
  13. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
  14. package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
  15. package/docs/internal/FEATURE-INVENTORY.md +531 -0
  16. package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
  17. package/docs/internal/G3-DESIGN-20260918.md +82 -0
  18. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
  19. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
  20. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
  21. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
  22. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
  23. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
  24. package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
  25. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
  26. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
  27. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
  28. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
  29. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
  30. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
  31. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
  32. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
  33. package/docs/internal/PROGRESS-20260917.md +93 -0
  34. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
  35. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
  36. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
  37. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
  38. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
  39. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
  40. package/docs/internal/RESUME-20260918.md +171 -0
  41. package/docs/internal/RESUME-20260919.md +104 -0
  42. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
  43. package/docs/internal/ROADMAP-20260917-WEEK.md +134 -0
  44. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +13 -3
  45. package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
  46. package/docs/internal/T6-EXECUTION-20260920.md +130 -0
  47. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
  48. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
  49. package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
  50. package/docs/internal/THREE-LAYER-CONTRACT.md +10 -1
  51. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
  52. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
  53. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  54. package/lib/activation-host.js +63 -9
  55. package/lib/board-mode.js +1 -1
  56. package/lib/client.js +892 -27
  57. package/lib/config-io.js +156 -0
  58. package/lib/context-bridge.js +3 -0
  59. package/lib/context-host.js +16 -9
  60. package/lib/degrade.js +385 -0
  61. package/lib/dsh-home.js +143 -0
  62. package/lib/episodic-store.js +52 -2
  63. package/lib/evidence-store.js +8 -1
  64. package/lib/fact-store.js +21 -2
  65. package/lib/index-sync.js +13 -1
  66. package/lib/index.js +1507 -158
  67. package/lib/intent-clean-safe.js +258 -40
  68. package/lib/l0-extract.js +231 -16
  69. package/lib/m4-corpus.js +8 -2
  70. package/lib/m7-index-sync-host.js +8 -1
  71. package/lib/memory-envelope.js +6 -1
  72. package/lib/memory-hub.js +127 -12
  73. package/lib/memory-index.js +4 -2
  74. package/lib/note-status-apply.js +118 -0
  75. package/lib/note-status.js +196 -0
  76. package/lib/procedure-store.js +84 -3
  77. package/lib/python-sidecar-client.js +29 -3
  78. package/lib/recall-fusion.js +83 -12
  79. package/lib/rules-edit.js +159 -0
  80. package/lib/semantic-decide.js +41 -8
  81. package/lib/semantic-js.js +51 -6
  82. package/lib/shadow-host.js +3 -5
  83. package/lib/skill-export-host.js +153 -0
  84. package/lib/skill-export.js +239 -0
  85. package/lib/storage-manage.js +6 -0
  86. package/lib/temporal-parse.js +191 -159
  87. package/lib/tier0-catalog.js +45 -3
  88. package/lib/wb-contract.js +198 -2
  89. package/lib/wb-sidecar.js +54 -3
  90. package/package.json +1 -1
@@ -261,8 +261,24 @@ export function createProcedureStorePre(opts = {}) {
261
261
  /**
262
262
  * 晋升判定(M-04 元代码 promote 逐行)。
263
263
  * 返回 { decision: 'promote'|'keep'|'ask', procedure, reasonCodes }
264
+ *
265
+ * ★ T4(2026-09-19 用户拍板「让大模型来介入」):新增第三个参数 `opts`。
266
+ * `opts.authorizedBy`(如 'model')= **模型显式授权**,用于跳过两条**统计证据门**
267
+ * (`minSessionDiversity` / `minSuccessCount`)。
268
+ *
269
+ * 为什么需要它:这两条门是给**机械生成**的观察行用的防污染护栏 —— 它们靠"反复出现"累积证据。
270
+ * 但机械路径产出的条目 13/14 是空壳(`actions=['user','user','user']` 切出来的),
271
+ * 而**模型主动写出的技能**(带真 steps + successCriteria)**天生没有历史证据**,
272
+ * 若不放行则永远卡在 `diversity-below-3` ⇒ 模型通路等于白建。
273
+ *
274
+ * **仍然保留的护栏(授权也不放行)**:
275
+ * · `deprecated` 短路 —— 已弃用的不得复活;
276
+ * · `isObservationOnlyPre` 短路 —— 结构上不该晋升的行,授权也拦;
277
+ * · **`no-success-criteria` 必须有**(在下方)—— 没写验收标准的不算技能;
278
+ * · `correction-rate` / `has-correction` —— 有纠正记录说明这流程是错的。
279
+ * 并**留痕**:`p.authorizedBy` + reasonCodes 追加 `model-authorized`,供审计与前端展示。
264
280
  */
265
- function promote(procedureId, extraEvidence = {}) {
281
+ function promote(procedureId, extraEvidence = {}, opts = {}) {
266
282
  if (disposed) return { ok: false, reason: 'disposed' }
267
283
  const p = procedures.find((x) => x.procedureId === procedureId)
268
284
  if (!p) return { ok: false, reason: 'not-found' }
@@ -270,15 +286,21 @@ export function createProcedureStorePre(opts = {}) {
270
286
  // issue #30:纯 episode 观察行没有 successCriteria,本来就无法晋升;
271
287
  // 显式短路并给出 `observation-only` 原因码,而不是让它落到下面报 `no-success-criteria`
272
288
  // (后者会让使用者误以为"缺判据、补上就能晋升",其实是这行**结构上**不该晋升)。
289
+ // ★ T4:此短路**不受授权影响** —— 结构上不该晋升的行,模型授权也不放行。
273
290
  if (isObservationOnlyPre(p)) return { ok: true, decision: 'keep', procedure: p, reasonCodes: ['observation-only'] }
274
291
 
275
292
  const ev = p.evidence
276
293
  const reason = []
294
+ const authorizedBy = opts && opts.authorizedBy ? String(opts.authorizedBy) : ''
295
+ if (authorizedBy) reason.push('model-authorized')
277
296
  // 跨会话多样性
278
297
  const diversity = extraEvidence.distinctSessions != null ? extraEvidence.distinctSessions : ev.sessions
279
298
  const successCount = extraEvidence.successCount != null ? extraEvidence.successCount : ev.success
280
- if (diversity < gates.minSessionDiversity) { reason.push('diversity-below-' + gates.minSessionDiversity); return { ok: true, decision: 'keep', procedure: p, reasonCodes: reason } }
281
- if (successCount < gates.minSuccessCount) { reason.push('success-below-' + gates.minSuccessCount); return { ok: true, decision: 'keep', procedure: p, reasonCodes: reason } }
299
+ // ★ T4:两条**统计证据门**在模型授权下跳过(见上方函数注释);未授权时行为逐字节不变。
300
+ if (!authorizedBy) {
301
+ if (diversity < gates.minSessionDiversity) { reason.push('diversity-below-' + gates.minSessionDiversity); return { ok: true, decision: 'keep', procedure: p, reasonCodes: reason } }
302
+ if (successCount < gates.minSuccessCount) { reason.push('success-below-' + gates.minSuccessCount); return { ok: true, decision: 'keep', procedure: p, reasonCodes: reason } }
303
+ }
282
304
  // correction 率
283
305
  const total = ev.seen + ev.read + ev.cite + ev.reuse + ev.success + ev.correction
284
306
  const corrRate = total > 0 ? ev.correction / total : 0
@@ -302,11 +324,68 @@ export function createProcedureStorePre(opts = {}) {
302
324
  // 晋升
303
325
  p.stage = 'validated'
304
326
  p.updatedAt = nowFn()
327
+ // ★ T4:授权晋升留痕 —— 前端与审计面必须能看出「这条是模型授权跳门进来的」,
328
+ // 否则日后无法区分「统计证据充分」与「模型判断值得」两种来源。
329
+ if (authorizedBy) p.authorizedBy = authorizedBy
305
330
  stats.validated++
306
331
  void persist()
307
332
  return { ok: true, decision: 'promote', procedure: p, reasonCodes: reason }
308
333
  }
309
334
 
335
+ /**
336
+ * ★R2(2026-09-20 用户要求「晋升原因必须看得见」):**纯只读**晋升判定投影。
337
+ *
338
+ * 与 `promote()` 的关系:**逐行复制其门限判定,但不改任何状态**。
339
+ * 为什么不能直接调 `promote()`:它含真实副作用
340
+ * (`stats.approvalAsked++` / `p.stage='validated'` / `stats.validated++` / `persist()` 写盘),
341
+ * 在 `overview()` 这类只读轮询路径里调用会**污染统计并把候选悄悄提升**。
342
+ * ⇒ 只读面必须走本函数(用户 2026-09-18 冻结纪律)。
343
+ *
344
+ * ⚠️ 维护约定:改 `promote()` 的门限时**必须同步改这里**,否则前端会展示过时的判据。
345
+ * 两者的一致性由测试 `smoke-test-r2-promotion-pre.mjs` 用变异兜底。
346
+ *
347
+ * @returns {{ok:boolean, decision?:string, reasonCodes?:string[], detail?:object}}
348
+ */
349
+ function evaluatePromotion(procedureId) {
350
+ const p = procedures.find((x) => x.procedureId === procedureId)
351
+ if (!p) return { ok: false, reason: 'not-found' }
352
+ if (p.stage === 'deprecated') return { ok: true, decision: 'keep', reasonCodes: ['deprecated'] }
353
+ if (isObservationOnlyPre(p)) return { ok: true, decision: 'keep', reasonCodes: ['observation-only'] }
354
+ const ev = p.evidence || { seen: 0, read: 0, cite: 0, reuse: 0, success: 0, correction: 0, sessions: 0 }
355
+ const reason = []
356
+ const diversity = Number(ev.sessions) || 0
357
+ const successCount = Number(ev.success) || 0
358
+ const total = (Number(ev.seen) || 0) + (Number(ev.read) || 0) + (Number(ev.cite) || 0)
359
+ + (Number(ev.reuse) || 0) + successCount + (Number(ev.correction) || 0)
360
+ const corrRate = total > 0 ? (Number(ev.correction) || 0) / total : 0
361
+ // ⚠️ 顺序必须与 promote() **完全一致**(先返回的那个才决定前端文案)
362
+ if (diversity < gates.minSessionDiversity) {
363
+ reason.push('diversity-below-' + gates.minSessionDiversity)
364
+ return { ok: true, decision: 'keep', reasonCodes: reason, detail: { diversity, need: gates.minSessionDiversity } }
365
+ }
366
+ if (successCount < gates.minSuccessCount) {
367
+ reason.push('success-below-' + gates.minSuccessCount)
368
+ return { ok: true, decision: 'keep', reasonCodes: reason, detail: { successCount, need: gates.minSuccessCount } }
369
+ }
370
+ if (corrRate > gates.maxCorrectionRate) {
371
+ reason.push('correction-rate-' + corrRate.toFixed(2))
372
+ return { ok: true, decision: 'keep', reasonCodes: reason, detail: { corrRate, cap: gates.maxCorrectionRate } }
373
+ }
374
+ if (gates.maxContradictions === 0 && (Number(ev.correction) || 0) > 0) {
375
+ reason.push('has-correction')
376
+ return { ok: true, decision: 'keep', reasonCodes: reason, detail: { corrections: Number(ev.correction) || 0 } }
377
+ }
378
+ if (!p.successCriteria || !p.successCriteria.length) {
379
+ reason.push('no-success-criteria')
380
+ return { ok: true, decision: 'keep', reasonCodes: reason }
381
+ }
382
+ if (p.requiresApproval && !p.approved) {
383
+ reason.push('high-risk-awaiting-approval')
384
+ return { ok: true, decision: 'ask', reasonCodes: reason }
385
+ }
386
+ return { ok: true, decision: 'promote', reasonCodes: reason, detail: { diversity, successCount } }
387
+ }
388
+
310
389
  /** 激活: validated → active(可被召回)。 */
311
390
  function activate(procedureId) {
312
391
  const p = procedures.find((x) => x.procedureId === procedureId)
@@ -413,6 +492,8 @@ export function createProcedureStorePre(opts = {}) {
413
492
  }
414
493
 
415
494
  return {
495
+ // ★R2:只读判定投影(供 overview() 展示「为什么不能晋升」)
496
+ evaluatePromotion,
416
497
  observe, addEvidence, promote, activate, deprecate,
417
498
  touch, setPinned, applyAutomaticTransitions,
418
499
  query, activeProcedures, get, renderChecklist,
@@ -80,9 +80,14 @@ export function createPythonSidecarClientPre(opts = {}) {
80
80
  if (disposed) return { ok: false, code: 'disposed' }
81
81
  if (child && (child.killed || (child.stdin && child.stdin.destroyed))) {
82
82
  // 上一个进程正在收尾(exit 事件未到):按已死处理,允许立即重生
83
+ // ★ issue #72 修复(2026-09-19):此处必须**同时清空共享 buffer**。
84
+ // 旧实现只置 `epoch = null`(等 `:121` 的 exit 事件去清 buffer),但这条分支的前提正是
85
+ // **exit 尚未到达** ⇒ 旧 worker 残留在 `buffer` 里的**无换行尾字节**会被新 worker 的首帧
86
+ // `concat` 进去 ⇒ 该行 badJson ⇒ 应答被吞、请求白等到超时(默认 5000ms)。
83
87
  try { child.kill() } catch (_) {}
84
88
  child = null
85
89
  epoch = null
90
+ buffer = Buffer.alloc(0) // ★ 丢弃旧代残尾,避免污染新 worker 首帧
86
91
  }
87
92
  if (child) return { ok: true }
88
93
  const scriptPath = String(resolveOpt('scriptPath') || defaultWorkerScriptPathPre())
@@ -161,7 +166,16 @@ export function createPythonSidecarClientPre(opts = {}) {
161
166
  const v = validateTransportFramePre(obj, { direction: 'in' })
162
167
  if (!v.ok) { stats.dropped.badEnvelope++; return }
163
168
  const frame = v.frame
164
- if (epoch !== null && frame.workerEpoch !== epoch) { stats.dropped.staleEpoch++; return }
169
+ // ★ issue #72 修复(2026-09-19):把入站 epoch 门从 **fail-open 改为 fail-closed**,与文件头 :7 的承诺一致。
170
+ // 旧写法 `if (epoch !== null && frame.workerEpoch !== epoch)`:**`epoch === null` 时整条门失效**,
171
+ // 任何合法 envelope 的帧都会穿透到 handler。epoch 为 null 的窗口是真实存在的
172
+ // (未启动 / `exit` 事件已置 null 但旧 stdio 尚未排空 / restart 后到新 spawn 之间),
173
+ // 而 `activation_request` 上游 `context-host` **无二次校验**(同 activationId 有 dedup 兜底,
174
+ // 但**新 activationId 可穿透**)⇒ 幽灵激活。
175
+ // 新判据:**epoch 为空 ⇒ 一律丢弃**。理由:spawn 时同步赋 epoch(:91 `epoch = 'wk_' + …` 在
176
+ // `spawn()` **之前**执行),出站帧也就带上了 epoch;对端 worker 以 `--expect-epoch` 自证身份后才回帧
177
+ // ⇒ **正常路径不存在"帧先于 epoch"**,丢弃不会误伤任何合法流量。
178
+ if (!epoch || frame.workerEpoch !== epoch) { stats.dropped.staleEpoch++; return }
165
179
  if (frame.type === 'activation_request') {
166
180
  const activation = frame.payload && frame.payload.activation
167
181
  const aid = activation && activation.activationId
@@ -208,9 +222,18 @@ export function createPythonSidecarClientPre(opts = {}) {
208
222
  lastSentFrame = frame
209
223
  if (!child || !child.stdin || child.stdin.destroyed) return false
210
224
  const line = Buffer.from(JSON.stringify(frame) + '\n', 'utf8')
225
+ // ★ issue #75 CC-9 修复(2026-09-19):**建帧时捕获当时的 child 引用**,flush 时校验身份。
226
+ // 旧实现:`writeChain.then(...)` 回调里解引用**当时的**模块级 `child` 变量。
227
+ // 若 writeChain 排队期间发生 respawn(`ensureStarted` 的收尾中重生 / `restart()`),
228
+ // 回调会读到**新** child,把**旧 epoch 的帧**写进新 worker 的 stdin ⇒ 对端回帧 epoch-mismatch
229
+ // ⇒ 被判 `staleEpoch` 丢弃 ⇒ 该请求挂满自身超时(默认 5000ms)。
230
+ // 现改为闭包捕获 `sentTo`,且 flush 时 `child !== sentTo` 即视为该帧已随旧进程作废。
231
+ const sentTo = child
232
+ const sentStdin = child.stdin
211
233
  writeChain = writeChain.then(() => new Promise((done) => {
212
- if (!child || !child.stdin || child.stdin.destroyed) { done(); return }
213
- child.stdin.write(line, () => done())
234
+ if (child !== sentTo) { done(); return } // 已换代:本帧作废,不得写入新 worker
235
+ if (!sentStdin || sentStdin.destroyed) { done(); return }
236
+ sentStdin.write(line, () => done())
214
237
  }))
215
238
  writeChain = writeChain.catch(() => {})
216
239
  return true
@@ -282,6 +305,9 @@ export function createPythonSidecarClientPre(opts = {}) {
282
305
  if (child) { try { child.kill() } catch (_) {} }
283
306
  child = null
284
307
  epoch = null
308
+ // ★ issue #72 修复:restart 同样要清 buffer(kill 后 exit 事件可能滞后,
309
+ // 旧代残尾会污染新 worker 首帧 ⇒ 该行 badJson ⇒ 请求白等到超时)。
310
+ buffer = Buffer.alloc(0)
285
311
  stats.restarts++
286
312
  void reason
287
313
  }
@@ -21,6 +21,19 @@ export const FUSION_RRF_K_V1 = 60
21
21
  /** rank 尺度常数:rank/divisor 把秩归到 (0,1] 量级(默认与 k 同值)。 */
22
22
  export const FUSION_RRF_DIVISOR_V1 = 60
23
23
 
24
+ /**
25
+ * ★M2.5b:层次优先级序(与注入侧一致)。
26
+ *
27
+ * 出处:`tier-layer-inject.js:58 TIER_LAYER_ORDER_V1` —— **必须与之逐字相同**。
28
+ *
29
+ * 为什么不直接 import:本模块头注释承诺「**零依赖**、纯函数、零 IO」,
30
+ * 引入跨模块依赖会破坏该契约(且 tier-layer-inject 自身还有依赖链)。
31
+ * 为了既不破契约、又不产生「双源漂移」,此处**本地复刻**并由测试**断言两者相等**
32
+ * (见 smoke-test-m25b-layer-arm-pre.mjs 的「常量一致性」断言)——
33
+ * 任何一侧改动而另一侧未同步,测试立刻红。
34
+ */
35
+ export const FUSION_LAYER_ORDER_V1 = Object.freeze(['project', 'whiteboard', 'user', 'reflection', 'log'])
36
+
24
37
  /** 版本标识。 */
25
38
  export const RECALL_FUSION_VERSION = 'rrf_fusion_v1'
26
39
 
@@ -45,20 +58,63 @@ export function rankFusionRRFPre(pairs, opts = {}) {
45
58
  const list = Array.isArray(pairs) ? pairs.filter((p) => p && typeof p.memoryId === 'string') : []
46
59
 
47
60
  // 每臂独立排名:非空有限值按分数降序(平局 memoryId 升序)取秩(1 起);缺席者无秩。
48
- const rankArm = (key) => {
61
+ // ★M2.5b:新增 `strMode` —— 层次臂的值是**字符串**,默认的「只收有限数」过滤会把它全部滤掉
62
+ // (实测踩坑:layer 臂曾因此静默恒为 0,套件 [3] 抓到)。strMode=true 时接受非空字符串。
63
+ const rankArm = (key, cmp, strMode) => {
49
64
  const entries = []
50
65
  for (const p of list) {
51
66
  const v = p[key]
52
- if (typeof v === 'number' && Number.isFinite(v)) entries.push({ memoryId: p.memoryId, v })
67
+ if (strMode) {
68
+ if (typeof v === 'string' && v) entries.push({ memoryId: p.memoryId, v })
69
+ } else if (typeof v === 'number' && Number.isFinite(v)) {
70
+ entries.push({ memoryId: p.memoryId, v })
71
+ }
53
72
  }
54
- entries.sort((a, b) => (b.v !== a.v ? b.v - a.v : (a.memoryId < b.memoryId ? -1 : a.memoryId > b.memoryId ? 1 : 0)))
73
+ const tie = (a, b) => (a.memoryId < b.memoryId ? -1 : a.memoryId > b.memoryId ? 1 : 0)
74
+ entries.sort(cmp || ((a, b) => (b.v !== a.v ? b.v - a.v : tie(a, b))))
55
75
  const ranks = new Map()
56
76
  entries.forEach((e, i) => ranks.set(e.memoryId, i + 1))
57
77
  return ranks
58
78
  }
79
+
80
+ /**
81
+ * ★M2.5b:层次臂的**序** = 注入侧的层次优先级序(二者必须一致)。
82
+ * 出处:`tier-layer-inject.js:58 TIER_LAYER_ORDER_V1`
83
+ * = ['project','whiteboard','user','reflection','log']。
84
+ * 语义:**rank 1 = 最高优先级层**。未列出的层(含新增层)排在已知层之后,按名称升序(确定性)。
85
+ * 注意:这里只定义**偏好顺序**,不定义权重;权重问题走 rank-space(见下),
86
+ * 需要调权重时应改分档/序,而不是给分数乘系数(Hindsight #3956)。
87
+ */
88
+ const layerRankOf = (name) => {
89
+ const i = FUSION_LAYER_ORDER_V1.indexOf(name)
90
+ return i >= 0 ? i : FUSION_LAYER_ORDER_V1.length
91
+ }
92
+ const cmpLayer = (a, b) => {
93
+ const la = layerRankOf(String(a.v))
94
+ const lb = layerRankOf(String(b.v))
95
+ if (la !== lb) return la - lb
96
+ const sa = String(a.v), sb = String(b.v)
97
+ if (sa !== sb) return sa < sb ? -1 : 1
98
+ return a.memoryId < b.memoryId ? -1 : a.memoryId > b.memoryId ? 1 : 0
99
+ }
100
+
59
101
  const denseRanks = rankArm('dense')
60
102
  const lexRanks = rankArm('lex')
61
103
 
104
+ // ★M2.5b 层次臂(2026-09-18):可选第四臂。layer 为层次名('project'/'user'/'log'/
105
+ // 'reflection'/'whiteboard')或缺失。与时间臂同一条**退化安全**纪律:
106
+ // 所有 pair 均缺 layer **或 layer 全相等** → 不构建 layer 秩,行为与三臂版**逐字节一致**
107
+ // (fused 不加 0 项、输出对象不含新字段)。
108
+ // 为什么需要它(实测不一致,2026-09-18):Tier-0 **注入**侧有层次优先级
109
+ // (tier-layer-inject.js:58 TIER_LAYER_ORDER_V1 = ['project','whiteboard','user','reflection','log']
110
+ // + tier0-catalog.js:419-455 的 caps/floors),而 **排序**侧对层次完全无感
111
+ // ⇒ 结论层(project)与流水层(log)在融合里**平等竞争**,笔记层常被日志挤掉。
112
+ // rank-space(Hindsight #3956 禁止 score-space 加权):层次只贡献**秩**,不乘分。
113
+ const layers = []
114
+ for (const p of list) { if (typeof p.layer === 'string' && p.layer) layers.push(p.layer) }
115
+ const hasLayer = layers.length > 0 && layers.some((v) => v !== layers[0])
116
+ const layerRanks = hasLayer ? rankArm('layer', cmpLayer, true) : null
117
+
62
118
  // 时间臂(P12 后新增,2026-09-09):可选第三臂。temp 为 1(命中时间范围)/0(未命中)/
63
119
  // 缺失(无时间臂或非日期来源)。所有 pair 均缺 temp **或 temp 全相等**(全 0/全 1)→
64
120
  // 不构建 temp 秩,行为与两臂版逐字节一致(fused 不加 0 项、输出对象不含新字段)——
@@ -84,16 +140,31 @@ export function rankFusionRRFPre(pairs, opts = {}) {
84
140
  rankDense: rd === undefined ? null : rd,
85
141
  rankLex: rl === undefined ? null : rl,
86
142
  }
87
- if (!hasTemp) return base
88
- const rt = tempRanks.get(p.memoryId)
89
- const rrfTemp = rt === undefined ? 0 : 1 / (k + rt / divisor)
90
- return {
91
- ...base,
92
- fused: rrfDense + rrfLex + rrfTemp,
93
- rrfTemp,
94
- tempRaw: typeof p.temp === 'number' && Number.isFinite(p.temp) ? p.temp : null,
95
- rankTemp: rt === undefined ? null : rt,
143
+ // 时间臂与层次臂**互不依赖**:各自独立判 has*,可单独生效、可同时生效。
144
+ let out = base
145
+ if (hasTemp) {
146
+ const rt = tempRanks.get(p.memoryId)
147
+ const rrfTemp = rt === undefined ? 0 : 1 / (k + rt / divisor)
148
+ out = {
149
+ ...out,
150
+ fused: out.fused + rrfTemp,
151
+ rrfTemp,
152
+ tempRaw: typeof p.temp === 'number' && Number.isFinite(p.temp) ? p.temp : null,
153
+ rankTemp: rt === undefined ? null : rt,
154
+ }
155
+ }
156
+ if (hasLayer) {
157
+ const rlay = layerRanks.get(p.memoryId)
158
+ const rrfLayer = rlay === undefined ? 0 : 1 / (k + rlay / divisor)
159
+ out = {
160
+ ...out,
161
+ fused: out.fused + rrfLayer,
162
+ rrfLayer,
163
+ layerRaw: typeof p.layer === 'string' && p.layer ? p.layer : null,
164
+ rankLayer: rlay === undefined ? null : rlay,
165
+ }
96
166
  }
167
+ return out
97
168
  })
98
169
  .sort((x, y) => (y.fused !== x.fused ? y.fused - x.fused : (x.memoryId < y.memoryId ? -1 : x.memoryId > y.memoryId ? 1 : 0)))
99
170
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * rules-edit.js —— 「用户级硬性约束」的**条目级**解析与增删改(R7 需求)。
3
+ *
4
+ * ## 背景(用户 2026-09-20 明确要求「这个功能必须要落在前端」)
5
+ *
6
+ * `[规则 — 用户级硬性约束 · 必须遵守]` 这一段是**每轮无条件注入**的硬约束,
7
+ * 真源 = `~/.dsh/memory/MEMORY.md`(见 `rules-layer.js:12` 的真源纪律)。
8
+ * 它**不走语义层** —— 即没有检索/打分/淘汰环节 ⇒ **过时条目不会被自动清理**;
9
+ * 而 AI(`memory_user`)也会往里写错东西。
10
+ * ⇒ 用户必须能**自己增/删/改**,否则错一条就每轮都被误导。
11
+ *
12
+ * ## 与既有渲染层的关系(重要:本模块不改变注入语义)
13
+ *
14
+ * `rules-layer.js` 的 `extractRulesLayerPre()` 负责**读**(把 MEMORY.md 解析成规则段),
15
+ * `renderRulesSectionPre()` 负责**渲染**。本模块只做**编辑定位**:
16
+ * 给定原文,找出「哪一段是规则段、每条规则在第几行」,以及产出改后的新全文。
17
+ * 它**不碰**注入、不碰检索、不碰 store。
18
+ *
19
+ * ## 条目模型
20
+ *
21
+ * 用户级 MEMORY.md 由若干 `## YYYY-MM-DD` 日期段 + `- ` 条目行组成。
22
+ * `rules-layer.js` 的渲染器只取 `- ` 开头的行(且跳过日期段)。
23
+ * ⇒ **一条规则 = 一个 `- ` 行**。本模块据此提供:
24
+ * - `listRuleItemsPre(text)` → [{ line, indent, text, source }]
25
+ * - `updateRuleItemPre(text, i, next)` / `removeRuleItemPre(text, i)` / `appendRuleItemPre(text, body)`
26
+ *
27
+ * `source` 的判定:条目若落在 `## YYYY-MM-DD` 段内 ⇒ 归该日期(多为 AI 写入);
28
+ * 落在段外(文件头部)⇒ 记为 `user`(多为用户手写)。这是 R7-3「标注来源」的依据。
29
+ *
30
+ * ## 纪律
31
+ * - 纯函数:输入文本、输出文本,**不做任何 IO**(IO 由调用方走既有 writeFull 事务)。
32
+ * - **删除 = 真删**(见 R7-4):本层渲染器不认任何状态标记,软删标记会被当正文注入模型。
33
+ * - 零运行时依赖。CRLF、无 BOM。
34
+ */
35
+
36
+ /** 日期段标题(与 rules-layer.js 的 DATE_SECTION_RE 同口径)。 */
37
+ export const RULE_DATE_SECTION_RE_V1 = /^##\s*\d{4}-\d{2}-\d{2}\s*$/
38
+
39
+ /** 条目行:允许前导缩进 + `- `(渲染器同样只看 `- ` 行)。 */
40
+ export const RULE_ITEM_RE_V1 = /^(\s*)-\s+(.*)$/
41
+
42
+ /** 单条规则长度上限(与写闸门同量级,防止一次塞进整篇文档)。 */
43
+ export const RULE_ITEM_MAX_CHARS_V1 = 2000
44
+
45
+ /**
46
+ * 列出所有规则条目(**行号从 0 开始**,供后续增删改定位)。
47
+ *
48
+ * @param {string} text MEMORY.md 全文
49
+ * @returns {Array<{line:number, indent:string, text:string, source:string, dateSection:string|null}>}
50
+ */
51
+ export function listRuleItemsPre(text) {
52
+ const lines = String(text == null ? '' : text).split(/\r?\n/)
53
+ const out = []
54
+ let curDate = null
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const l = lines[i]
57
+ if (RULE_DATE_SECTION_RE_V1.test(l)) { curDate = l.replace(/^##\s*/, '').trim(); continue }
58
+ const m = RULE_ITEM_RE_V1.exec(l)
59
+ if (!m) continue
60
+ const body = m[2].trim()
61
+ if (!body) continue
62
+ out.push({
63
+ line: i,
64
+ indent: m[1] || '',
65
+ text: body,
66
+ // 落在日期段内 ⇒ 多为 AI(memory_user 会带日期标题)写入;否则视为用户手写
67
+ source: curDate ? 'ai' : 'user',
68
+ dateSection: curDate,
69
+ })
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** 内部:切行(保留原始行尾风格判定所需的信息交给 join 处理)。 */
75
+ function splitLinesPre(text) { return String(text == null ? '' : text).split(/\r?\n/) }
76
+
77
+ function joinLinesPre(lines) { return lines.join('\n') }
78
+
79
+ /**
80
+ * **替换**第 idx 条规则(按 listRuleItemsPre 的顺序)。
81
+ *
82
+ * @returns {{ok:boolean, text?:string, error?:string}}
83
+ */
84
+ export function updateRuleItemPre(text, idx, nextBody) {
85
+ const items = listRuleItemsPre(text)
86
+ const body = String(nextBody == null ? '' : nextBody).trim()
87
+ if (!Number.isInteger(idx) || idx < 0 || idx >= items.length) return { ok: false, error: 'index-out-of-range' }
88
+ if (!body) return { ok: false, error: 'empty-body' }
89
+ if (body.length > RULE_ITEM_MAX_CHARS_V1) return { ok: false, error: 'too-long' }
90
+ if (/\r|\n/.test(body)) return { ok: false, error: 'multiline-not-allowed' }
91
+ const lines = splitLinesPre(text)
92
+ const it = items[idx]
93
+ lines[it.line] = it.indent + '- ' + body
94
+ return { ok: true, text: joinLinesPre(lines) }
95
+ }
96
+
97
+ /**
98
+ * **删除**第 idx 条规则(真删,见 R7-4)。
99
+ *
100
+ * ⚠️ 本层渲染器(`renderRulesSectionPre`)不认任何状态标记 ⇒ 软删标记会被当正文注入模型;
101
+ * 故这里**直接移除该行**。不可撤销 —— 调用方必须做二次确认。
102
+ */
103
+ export function removeRuleItemPre(text, idx) {
104
+ const items = listRuleItemsPre(text)
105
+ if (!Number.isInteger(idx) || idx < 0 || idx >= items.length) return { ok: false, error: 'index-out-of-range' }
106
+ const lines = splitLinesPre(text)
107
+ lines.splice(items[idx].line, 1)
108
+ return { ok: true, text: joinLinesPre(lines) }
109
+ }
110
+
111
+ /**
112
+ * **追加**一条规则。
113
+ *
114
+ * 落点规则(与用户手写习惯一致,且保证渲染器一定能读到):
115
+ * - `dateSection` 为空 ⇒ 追加到**文件头部**(用户手写区,`source` 会是 `user`);
116
+ * - 给了 `dateSection` ⇒ 追加到该日期段的**末尾**(AI 写入区)。
117
+ */
118
+ export function appendRuleItemPre(text, body, opts = {}) {
119
+ const b = String(body == null ? '' : body).trim()
120
+ if (!b) return { ok: false, error: 'empty-body' }
121
+ if (b.length > RULE_ITEM_MAX_CHARS_V1) return { ok: false, error: 'too-long' }
122
+ if (/\r|\n/.test(b)) return { ok: false, error: 'multiline-not-allowed' }
123
+ const lines = splitLinesPre(text)
124
+ const want = opts && opts.dateSection ? String(opts.dateSection).trim() : ''
125
+ const newLine = '- ' + b
126
+ if (!want) {
127
+ // 头部插入:跳过开头的空行,避免把条目顶到文件最上面的空行之前
128
+ let at = 0
129
+ while (at < lines.length && lines[at].trim() === '') at++
130
+ lines.splice(at, 0, newLine)
131
+ return { ok: true, text: joinLinesPre(lines) }
132
+ }
133
+ // 找目标日期段,插到该段内最后一个非空行之后
134
+ let secAt = -1
135
+ for (let i = 0; i < lines.length; i++) {
136
+ if (RULE_DATE_SECTION_RE_V1.test(lines[i]) && lines[i].replace(/^##\s*/, '').trim() === want) { secAt = i; break }
137
+ }
138
+ if (secAt < 0) return { ok: false, error: 'date-section-not-found' }
139
+ let end = secAt + 1
140
+ while (end < lines.length && !RULE_DATE_SECTION_RE_V1.test(lines[end])) end++
141
+ let ins = end
142
+ while (ins > secAt + 1 && lines[ins - 1].trim() === '') ins--
143
+ lines.splice(ins, 0, newLine)
144
+ return { ok: true, text: joinLinesPre(lines) }
145
+ }
146
+
147
+ /**
148
+ * **预览**:把规则条目渲染成注入时会长的样子(R7-6)。
149
+ *
150
+ * 只做**只读**格式化,不引入 rules-layer.js 的其它依赖(避免循环 import 风险)。
151
+ */
152
+ export function previewRulesPre(text) {
153
+ const items = listRuleItemsPre(text)
154
+ return items.map((x) => '- ' + x.text).join('\n')
155
+ }
156
+
157
+ /** 空规则段判定(给 UI 显示空态用)。 */
158
+ export function isEmptyRulesPre(text) { return listRuleItemsPre(text).length === 0 }
159
+
@@ -27,16 +27,49 @@ const PLAN_TOKENS = ['准备', '打算', '计划', '之后', '接下来', '继
27
27
  const WS_RUN = /\s\s+/g
28
28
  // Python `(?u)\b\w\w+\b` 中 \w 含 CJK;JS 的 \w 默认只含 ASCII,须显式加 CJK
29
29
  // 否则中文词不被切分 → 无 gram → 中文意图全靠 intercept(严重偏差)。
30
- const WORD_RE = /[A-Za-z0-9_\u4e00-\u9fff]+/g
30
+ // ★ issue #68 修复(2026-09-19):**加 `{2,}` 下限** —— Python 侧是 `\b\w\w+\b`(**≥2 字**),
31
+ // 旧实现允许 1 字词(`+`)⇒ 中文单字(如「好」「是」)在 JS 侧会生成 gram、Python 侧不会,
32
+ // 两版对**同一输入**给出不同 gram 集合 ⇒ 判定分叉。
33
+ const WORD_RE = /[A-Za-z0-9_\u4e00-\u9fff]{2,}/g
34
+
35
+ /**
36
+ * 与 Python `normalize_text` 逐字对齐。
37
+ * ★ issue #68 修复(2026-09-19):旧实现有三处分叉,现全部按 **Python 为权威** 对齐:
38
+ * ① **整删非字母数字**:旧实现 `.replace(/[^…\s]/g, ' ')` 把标点变**空格** ⇒ `"a-b"` 得到 `"a b"`(两词),
39
+ * 而 Python 是 `''.join(ch for ch in text.lower() if ch.isalnum() or CJK)` ⇒ `"ab"`(**一词**);
40
+ * ② **`isalnum()` 语义**:Python 的 `str.isalnum()` 对**全角数字**(`123`)、带音标字母(`é`)为真,
41
+ * 而 JS 正则 `[a-z0-9]` 只认 ASCII ⇒ 这类字符在 JS 侧被丢弃、Python 侧保留;
42
+ * ③ **不再额外压空白**:Python 整删后**不插空格**,故 `"a b"` → `"a b"`(原样)而非 `"a b"`。
43
+ * 修法用 `codePointAt` + `isAlnumPythonish()` 复刻 `isalnum()`,而不是改正则字符类。
44
+ */
45
+ function isAlnumPythonish(cp) {
46
+ // ASCII 字母数字
47
+ if (cp >= 0x30 && cp <= 0x39) return true // 0-9
48
+ if (cp >= 0x61 && cp <= 0x7a) return true // a-z(已 lower)
49
+ if (cp >= 0x41 && cp <= 0x5a) return true // A-Z(防御:未 lower 时)
50
+ // CJK 统一表意文字(与既有 \u4e00-\u9fff 口径一致)
51
+ if (cp >= 0x4e00 && cp <= 0x9fff) return true
52
+ // 全角数字 0-9
53
+ if (cp >= 0xff10 && cp <= 0xff19) return true
54
+ // 全角字母 a-z / A-Z(★ 首轮遗漏:`toLowerCase()` 会把 A(U+FF21) 折叠成 a(U+FF41),
55
+ // 若只覆盖数字,全角字母会在折叠后**整段消失**,与 Python `isalnum()` 仍为真的语义分叉)
56
+ if (cp >= 0xff21 && cp <= 0xff3a) return true // A-Z(防御:未 lower 时)
57
+ if (cp >= 0xff41 && cp <= 0xff5a) return true // a-z(lower 后的实际落点)
58
+ // 拉丁补充字母(含 é 等带音标字符,覆盖 Latin-1 Supplement 的字母区)
59
+ if (cp >= 0x00c0 && cp <= 0x00ff && cp !== 0x00d7 && cp !== 0x00f7) return true
60
+ if (cp >= 0x0100 && cp <= 0x017f) return true // Latin Extended-A
61
+ return false
62
+ }
31
63
 
32
- /** 与 Python normalize_text 对齐(大小写折叠 + 保留 [a-z0-9]+CJK + 去其他)。 */
33
64
  function normalizeText(text) {
34
- return String(text || '')
35
- .toLowerCase()
36
- .replace(WS_RUN, ' ')
37
- // 保留字母数字与 CJK,其余变空格(近似 Python 的 keep [a-z0-9] and CJK)
38
- .replace(/[^a-z0-9\u4e00-\u9fff\s]/g, ' ')
39
- .trim()
65
+ const lower = String(text || '').toLowerCase()
66
+ let out = ''
67
+ // 按码点遍历(避免代理对拆断),复刻 Python 的 `if ch.isalnum()` 整删语义
68
+ for (const ch of lower) {
69
+ const cp = ch.codePointAt(0)
70
+ if (isAlnumPythonish(cp)) out += ch
71
+ }
72
+ return out
40
73
  }
41
74
 
42
75
  /** char_wb n-gram 计数(与 Python _char_wb_ngram_counts 对齐)。 */