@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,156 @@
1
+ /**
2
+ * config-io.js —— 配置文件的**原子写**与**损坏隔离**(上游 issue #82 修复)。
3
+ *
4
+ * ## 背景(#82;已在 pre 线实跑核验:两条路径在优化后的版本里**仍然存在**)
5
+ *
6
+ * ① **写侧非原子**:配置落盘是裸 `writeFileSync`(同步路径)/ 裸 `writeFile`(异步路径)。
7
+ * 进程写到一半被杀、或磁盘写满,会留下**半截 JSON**;下次启动 `JSON.parse` 抛错,
8
+ * 于是直接回落出厂默认 ⇒ **用户此前的设置静默丢失**。
9
+ * 同类裸写还出现在 `embedding-config.json`(语义引擎配置)。
10
+ *
11
+ * ② **读侧静默**:两个 load 函数的 catch 一律 `return _mergeConfigPre(null)`(出厂默认),
12
+ * 错误只塞进 `this._readError` —— **用户看不到「我的配置坏了、已被重置」**。
13
+ * 本仓铁律:fail-soft 必须返回**可观察**信号;静默回落等于「数据丢了但没人知道」。
14
+ *
15
+ * ## 本模块只做两件事,且**不改变既有语义**
16
+ *
17
+ * 1. `writeTextAtomicPreSync` / `writeTextAtomicPre`:写 tmp → `renameSync` 覆盖目标。
18
+ * 同目录 rename 在同一文件系统上是**原子**的 ⇒ 读方永远只见「旧的完整版」或
19
+ * 「新的完整版」,**永远不会看到半截文件**。
20
+ * 2. `readJsonQuarantinePreSync`:解析失败时**先把坏文件改名留存**
21
+ * (`<name>.corrupt-<ts>`)再返回结构化失败。这样 ①用户数据没被覆盖
22
+ * ②诊断面能看见「曾经坏过、坏在哪」。
23
+ *
24
+ * ## 纪律
25
+ * - 零运行时依赖:只用 `node:fs` / `node:fs/promises` / `node:path`。
26
+ * - **fail-soft 但必须留痕**:任何一步失败都不抛,返回结构化结果让调用方处理。
27
+ * - CRLF、无 BOM。
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
30
+ import { writeFile } from 'node:fs/promises'
31
+ import path from 'node:path'
32
+
33
+ /** 临时文件后缀。与既有 hub 落盘的 `.tmp` 同族,便于排查时一眼认出。 */
34
+ export const ATOMIC_TMP_SUFFIX_V1 = '.tmp'
35
+
36
+ /** 损坏留存的默认后缀前缀。 */
37
+ export const CORRUPT_QUARANTINE_PREFIX_V1 = '.corrupt-'
38
+
39
+ /** 时间戳串(用于损坏留存命名)。绝不影响主流程。 */
40
+ function tsPre(now) {
41
+ try {
42
+ const d = typeof now === 'number' ? new Date(now) : new Date()
43
+ const p = (n) => String(n).padStart(2, '0')
44
+ return String(d.getFullYear()) + p(d.getMonth() + 1) + p(d.getDate())
45
+ + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds())
46
+ } catch (_) {
47
+ return '0'
48
+ }
49
+ }
50
+
51
+ /**
52
+ * 把目标文件挪到同目录的旁路名(**不删除** —— 留证据)。
53
+ * @returns {string} 旁路路径;失败返回空串(fail-soft,绝不抛)。
54
+ */
55
+ export function quarantineFilePreSync(file, opts = {}) {
56
+ try {
57
+ const suffix = (opts && opts.suffix) || (CORRUPT_QUARANTINE_PREFIX_V1 + tsPre(opts && opts.now))
58
+ const dest = file + suffix
59
+ // 同一秒内坏两次 ⇒ 再加一段随机后缀,避免覆盖上一份证据
60
+ const finalPath = existsSync(dest) ? dest + '.' + Math.random().toString(36).slice(2, 6) : dest
61
+ renameSync(file, finalPath)
62
+ return finalPath
63
+ } catch (_) {
64
+ return ''
65
+ }
66
+ }
67
+
68
+ /**
69
+ * **同步原子写**:tmp → rename。
70
+ * @returns {{ok: boolean, path?: string, error?: string}} 结构化结果(fail-soft,不抛)。
71
+ */
72
+ export function writeTextAtomicPreSync(file, text) {
73
+ const tmp = file + ATOMIC_TMP_SUFFIX_V1
74
+ try {
75
+ mkdirSync(path.dirname(file), { recursive: true })
76
+ writeFileSync(tmp, String(text), 'utf8')
77
+ renameSync(tmp, file)
78
+ return { ok: true, path: file }
79
+ } catch (e) {
80
+ // 失败时清掉临时文件,避免在目标目录留垃圾(清理失败也不抛)
81
+ try { rmSync(tmp, { force: true }) } catch (_) {}
82
+ return { ok: false, error: String((e && e.message) || e) }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * **异步原子写**:tmp → rename。语义与同步版一致。
88
+ *
89
+ * 只把**可能较大**的正文写入交给异步 IO;rename 本身极快,保持同步调用。
90
+ * 这样与既有 `persistConfigPre` 的异步口径对齐,不引入额外的 await 链。
91
+ */
92
+ export async function writeTextAtomicPre(file, text) {
93
+ const tmp = file + ATOMIC_TMP_SUFFIX_V1
94
+ try {
95
+ mkdirSync(path.dirname(file), { recursive: true })
96
+ await writeFile(tmp, String(text), 'utf8')
97
+ renameSync(tmp, file)
98
+ return { ok: true, path: file }
99
+ } catch (e) {
100
+ try { rmSync(tmp, { force: true }) } catch (_) {}
101
+ return { ok: false, error: String((e && e.message) || e) }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * **读 JSON + 损坏隔离**(同步)。
107
+ *
108
+ * 行为矩阵:
109
+ * - 文件不存在 ⇒ `{ ok:false, missing:true, reason:'ENOENT' }`(不算损坏,不留存)
110
+ * - 解析/读取失败 ⇒ **先留存**坏文件,再 `{ ok:false, corrupted:true, quarantined, reason }`
111
+ * - 正常 ⇒ `{ ok:true, value }`
112
+ *
113
+ * ⚠️ 与旧行为唯一的差别:**解析失败时多了一次 rename**(把原文件挪走)。
114
+ * 之所以必须挪走而非原地不动,是因为调用方随后会回落出厂默认并**覆盖写回** ——
115
+ * 不挪走的话坏文件当场被新配置盖掉,用户数据无从取证。
116
+ */
117
+ export function readJsonQuarantinePreSync(file, opts = {}) {
118
+ let raw
119
+ try {
120
+ raw = readFileSync(file, 'utf8')
121
+ } catch (e) {
122
+ if (e && e.code === 'ENOENT') return { ok: false, missing: true, reason: 'ENOENT' }
123
+ return { ok: false, corrupted: false, reason: String((e && e.message) || e) }
124
+ }
125
+ try {
126
+ return { ok: true, value: JSON.parse(raw) }
127
+ } catch (e) {
128
+ const reason = String((e && e.message) || e)
129
+ const quarantined = quarantineFilePreSync(file, opts)
130
+ return { ok: false, corrupted: true, quarantined, reason }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 纯函数判据:一段文本是否**看起来像被截断的 JSON**。
136
+ *
137
+ * 用途:调用方先自行 readFileSync 再 parse、拿不到本模块的返回对象时,
138
+ * 可用它给出更准确的诊断文案(「疑似写入被中断」而不是笼统的 parse 失败)。
139
+ */
140
+ export function looksTruncatedJsonPre(text) {
141
+ const s = String(text == null ? '' : text).trim()
142
+ if (!s) return false
143
+ if (s[0] !== '{' && s[0] !== '[') return false
144
+ try { JSON.parse(s); return false } catch (_) { return true }
145
+ }
146
+
147
+ /** 是否是一个**非空**普通文件(区分「损坏但非空」与「空文件」用)。 */
148
+ export function isNonEmptyFilePreSync(file) {
149
+ try {
150
+ const st = statSync(file)
151
+ return st.isFile() && st.size > 0
152
+ } catch (_) {
153
+ return false
154
+ }
155
+ }
156
+
@@ -519,6 +519,9 @@ export class BoundedIdSet {
519
519
  constructor(capacity) { this.capacity = Math.max(1, Number(capacity) || 256); this._set = new Set() }
520
520
  has(id) { return this._set.has(id) }
521
521
  add(id) { this._set.add(id); if (this._set.size > this.capacity) { const first = this._set.values().next().value; this._set.delete(first) } }
522
+ /** issue#56(2026-09-19):显式撤销登记。用于「先登记后写盘」的幂等缓存——
523
+ * 写盘失败时必须释放 id,否则该 id 被永久判为重复 ⇒ 瞬时失败后重试恒被拒(证据静默丢失)。 */
524
+ delete(id) { return this._set.delete(id) }
522
525
  get size() { return this._set.size }
523
526
  clear() { this._set.clear() }
524
527
  }
@@ -15,6 +15,7 @@
15
15
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
16
16
  import { createHash } from 'node:crypto'
17
17
  import path from 'node:path'
18
+ import { resolveDshHomeForEnginePre, resolveDshHomePre } from './dsh-home.js'
18
19
  import {
19
20
  buildContextPushEnvelopePre, buildAuthorizedMemoryRefFromRecord, createAccessEvidencePre,
20
21
  createCiteEvidencesFromText, createCorrectionEvidencesFromText, computeReadCoverage,
@@ -27,6 +28,7 @@ import { createPythonContextSinkPre } from './context-sink-python.js'
27
28
  import { buildQueryPlan, lexicalSearch, GATE_POLICY_VERSION, LEXICAL_POLICY_VERSION } from './shadow-retrieval.js'
28
29
  import { fuseD6Pre } from './semantic-js.js'
29
30
  import { buildSourceCatalog, loadCorpusSnapshot, CorpusRegistry, canonicalize } from './m4-corpus.js'
31
+ import { resolveProcedureInjectEnabledPre } from './procedure-switch.js'
30
32
 
31
33
  const MAX_DROPS_RING = 64
32
34
  /** 同一 drop 原因的日志限流窗口(ms):窗口内只写首条,其余计数。防会话回放刷屏(2026-09-10)。 */
@@ -37,11 +39,14 @@ const dropLoggedSkip = new Map()
37
39
  /** M7.5 诊断:ctx-host drop/skip 直写 harness diagnose 日志(此前 try{diag()} 静默吞 ReferenceError)。 */
38
40
  function diagCtx(msg) {
39
41
  try {
40
- const env = process.env.DSH_HOME
41
- const base = env && env.trim() ? env.trim()
42
- : (process.env.USERPROFILE || process.env.HOME || '')
42
+ // ★#86-3:统一口径。原实现把 `USERPROFILE || HOME` **直接**当 DSH_HOME(不拼 .dsh)
43
+ // ⇒ 在 Windows 上得到 `C:\Users\X` 而非 `C:\Users\X\.dsh`,与其余 6 处**指向不同目录**。
44
+ // ★同时修掉一个**潜伏 bug**:原代码在 `DSH_HOME` 已设置时仍 `path.join(base, '.dsh', ...)`,
45
+ // 即落成 `$DSH_HOME/.dsh/dsh-auto-memory-diagnose.log`(**多拼一层 .dsh**);
46
+ // 未设置时才恰好正确。resolveDshHomePre() 返回的**已经是 dsh home 本身**,故不再拼 .dsh。
47
+ const base = resolveDshHomePre()
43
48
  if (!base) return
44
- appendFileSync(path.join(base, '.dsh', 'dsh-auto-memory-diagnose.log'),
49
+ appendFileSync(path.join(base, 'dsh-auto-memory-diagnose.log'),
45
50
  new Date().toISOString() + ' [ctx-host] ' + String(msg).slice(0, 300) + '\n', 'utf8')
46
51
  } catch (e) {}
47
52
  }
@@ -117,10 +122,8 @@ export function createContextHost(opts = {}) {
117
122
  const registry = new CorpusRegistry({ sidecarDir: path.join(dshHome(), 'memory', 'index', 'files') })
118
123
 
119
124
  function dshHome() {
120
- const env = process.env.DSH_HOME
121
- if (env && env.trim()) return env.trim()
122
- const base = engine.__homedirFn ? engine.__homedirFn() : (process.env.USERPROFILE || process.env.HOME || '')
123
- return base ? path.join(base, '.dsh') : '.'
125
+ // ★#86-3:统一口径(含 __homedirFn 注入点保留)
126
+ return resolveDshHomeForEnginePre(engine)
124
127
  }
125
128
  function effectiveEnabled() {
126
129
  return engine.config.associativeMemoryEnabled === true && engine.config.contextBridgeEnabled === true
@@ -364,6 +367,8 @@ export function createContextHost(opts = {}) {
364
367
  const sidDeg = String(runtime.sessionId || '')
365
368
  const degradeRec = { reason: indexNotReady, at: Date.now(), sessionId: sidDeg }
366
369
  // 多工作区适配(2026-09-17):按会话分片写入,读取方按会话取 ⇒ 不再跨会话污染。
370
+ // ★2026-09-20 移植(issue #94⑤ / PR #100):先 delete 再 set 刷新插入序。
371
+ indexDegradeBySession.delete(sidDeg)
367
372
  indexDegradeBySession.set(sidDeg, degradeRec)
368
373
  if (indexDegradeBySession.size > 32) {
369
374
  const oldest = indexDegradeBySession.keys().next().value
@@ -442,7 +447,10 @@ export function createContextHost(opts = {}) {
442
447
  // 2026-08-27 判定观测:按段类型计数(真实数据验证 CoT 触发)
443
448
  stats.jsDecideRuns++
444
449
  stats.jsDecideByKind[seg.kind] = (stats.jsDecideByKind[seg.kind] || 0) + 1
445
- const cd = Math.max(0, Number(engine.config.jsDecideCooldownRounds) || 5)
450
+ // ★2026-09-20 移植(issue #94④ / PR #100):`|| 5` 把用户显式配置的 0(不冷却)反转成 5。
451
+ // 仅缺失/非有限数才回落默认值。
452
+ const cdRaw = Number(engine.config.jsDecideCooldownRounds)
453
+ const cd = Number.isFinite(cdRaw) && cdRaw >= 0 ? cdRaw : 5
446
454
  const stNow = stateFor(runtime)
447
455
  if (cd > 0 && stNow._jsDecideNextAt && Date.now() < stNow._jsDecideNextAt) return
448
456
  var jsDecideQueryText = buildObserveWindowText(runtime, seg)
@@ -527,7 +535,12 @@ export function createContextHost(opts = {}) {
527
535
  // 集合不变则缓存命中);不可用回退词法 2-gram。阈值 0.6(e5 相关内容带)。单 offer 出口。
528
536
  try {
529
537
  const hub = engine._memoryHub
530
- const skillEnabled = engine.config.memoryHubEnabled === true && engine.config.procedurePromotionEnabled !== false
538
+ // B-2:旧键 procedurePromotionEnabled 是**语义错配的兼容别名**——它的键名与界面文案写的是
539
+ // 「技能固化与晋升」,但它实际控制的是「已激活的技能是否随本轮上下文注入」这道总闸,
540
+ // 与晋升流程本身无关。改由接口契约模块 lib/procedure-switch.js 统一解析:
541
+ // 新键 procedureInjectEnabled 优先,缺省回退旧键,两键都缺省按开启处理;
542
+ // 老用户显式关掉过总闸的,行为不翻面。此处只替换开关解析口径,不新增任何前置条件。
543
+ const skillEnabled = engine.config.memoryHubEnabled === true && resolveProcedureInjectEnabledPre(engine.config)
531
544
  if (hub && skillEnabled && hub.stores && hub.stores.procedures) {
532
545
  const actives = hub.stores.procedures.activeProcedures()
533
546
  let hit = null
package/lib/degrade.js ADDED
@@ -0,0 +1,385 @@
1
+ /**
2
+ * degrade.js · 降级留痕层(R3,2026-09-18)
3
+ *
4
+ * ── 要解决的问题 ──────────────────────────────────────────────
5
+ * 全仓普查发现 70 处 `catch` 只写 diag 不抛出,其中 10 处自述为「降级/回退/中性」。
6
+ * 检索链上**四条臂各自独立降级、各自静默** ⇒ 可同时失效而使用者只感到「检索不太对」。
7
+ * 实证案例(R2):evidence 读侧误判目录缺失 ⇒ importance 加权对某类用户**出厂即死**,
8
+ * 而表现只是每次 recall 写一行 diag。
9
+ *
10
+ * ── 定性 ─────────────────────────────────────────────────────
11
+ * fail-soft 本身是对的(记忆插件不得拖垮会话)。**缺陷在「降级不可见」**。
12
+ * 本模块不是要消灭降级,而是让「**哪条臂没在工作**」从推断变成**可查询的状态**。
13
+ *
14
+ * ── 与既有机制的边界(2026-09-18 前置检查已证实无重复)──────
15
+ * · `diag()` = 过程日志(滚动、即时、人读)—— **保留不变**
16
+ * · `debugView()` = 各 host 的**局部**状态投影(7 处,彼此分散)
17
+ * · `degrade`(本模块)= **跨臂统一台账**(聚合、可查询、回答"哪条臂失效")
18
+ * · `_lastIndexDegrade`(context-host.js:112)= 单值兼容投影,非收集器
19
+ *
20
+ * ── 判据(R1/R2 得出,本模块的最高纪律)──────────────────────
21
+ * **必须区分两类,绝不能一律报,否则噪音淹没信号**:
22
+ * · **预期内分支**:该状态是合法业务状态(无证据事件 / 查询无时间表达)⇒ **静默,不记**
23
+ * · **预期外失败**:该状态不该发生(引擎抛错 / 目录异常 / worker 拒绝)⇒ **记**
24
+ * 例:`parseTemporalQueryPre` 返回 null(查询含无时间表达)是预期内 ⇒ 不记;
25
+ * 但它**抛错**是预期外 ⇒ 记。调用方须把"返回 null"与"抛错"分开。
26
+ *
27
+ * ── 元规则(本模块自身的 fail-soft)──────────────────────────
28
+ * **留痕失败绝不可导致二次失败**:`record()` 内部整体 try/catch 吞掉一切。
29
+ * 宁可丢掉一条留痕,也绝不能因为留痕而打断检索。
30
+ */
31
+
32
+ /** 身份常量(枚举类常量须配断言兜底 —— 本仓纪律)。 */
33
+ export const DEGRADE_SCHEMA_V1 = 'degrade_v1'
34
+
35
+ /** 环形缓冲上限:防内存无界增长。 */
36
+ export const DEGRADE_CAP_V1 = 200
37
+
38
+ /** 单条 reason 截断长度:与既有 diag 同口径,且防长文本撑爆状态文件。 */
39
+ export const DEGRADE_REASON_MAX_V1 = 200
40
+
41
+ /**
42
+ * 臂状态枚举(fail-closed 校验)。
43
+ * - `active` 正常工作
44
+ * - `no-input` 无输入(**合法状态**,如尚无证据事件 / 查询无时间表达)
45
+ * - `degraded` 已降级(**预期外**,值应能在 counts 里找到对应 kind)
46
+ * - `disabled` 被配置关闭
47
+ * - `unknown` 无法判定(**不得**当作正常,面板应显式呈现)
48
+ */
49
+ export const ARM_STATES_V1 = Object.freeze(['active', 'no-input', 'degraded', 'disabled', 'unknown'])
50
+
51
+ /** 已知降级 kind(仅作文档/断言用,**不限制**调用方传入新 kind)。 */
52
+ export const DEGRADE_KINDS_V1 = Object.freeze([
53
+ 'semantic-arm', // 语义臂择优失败 → 回退词法
54
+ 'evidence-arm', // evidence 聚合失败 → importance 中性
55
+ 'l0-sync', // L0 索引同步失败 → 索引陈旧
56
+ ])
57
+
58
+ /**
59
+ * 创建降级台账。
60
+ *
61
+ * @param {object} [opts]
62
+ * @param {number} [opts.cap] 环形缓冲上限
63
+ * @param {Function} [opts.now] 取时函数(注入便于测试确定性)
64
+ */
65
+ export function createDegradeSinkPre(opts = {}) {
66
+ const cap = Number.isFinite(opts.cap) && opts.cap > 0 ? Math.floor(opts.cap) : DEGRADE_CAP_V1
67
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now()
68
+
69
+ /** @type {Map<string, number>} kind → 累计次数(不受环形淘汰影响) */
70
+ const counts = new Map()
71
+ /** @type {Array<{kind:string,reason:string,at:number}>} 最近条目(有界) */
72
+ let recent = []
73
+ /** 因超出 cap 而被淘汰的条数(保证"有界"这件事本身可见,不静默丢数据) */
74
+ let evicted = 0
75
+
76
+ /**
77
+ * 记录一次降级。**只用于预期外失败**(预期内分支请勿调用,直接静默)。
78
+ * 内部整体 fail-soft:任何异常都被吞掉,调用方无需 try/catch。
79
+ */
80
+ function record(kind, reason) {
81
+ try {
82
+ const k = String(kind == null ? 'unknown' : kind)
83
+ counts.set(k, (counts.get(k) || 0) + 1)
84
+ if (recent.length >= cap) { recent.shift(); evicted += 1 }
85
+ recent.push({
86
+ kind: k,
87
+ reason: String(reason == null ? '' : reason).slice(0, DEGRADE_REASON_MAX_V1),
88
+ at: now(),
89
+ })
90
+ } catch (_) { /* 元规则:留痕失败不得影响主流程 */ }
91
+ }
92
+
93
+ /** 读快照(不可变副本,防外部改内部状态)。 */
94
+ function snapshot() {
95
+ let out
96
+ try {
97
+ out = {
98
+ schemaVersion: DEGRADE_SCHEMA_V1,
99
+ updatedAt: new Date(now()).toISOString(),
100
+ counts: Object.fromEntries(counts),
101
+ recent: recent.map((r) => ({ ...r, at: new Date(r.at).toISOString() })),
102
+ evicted,
103
+ cap,
104
+ }
105
+ } catch (_) {
106
+ // 快照失败也要给出**结构性合法**的最小对象,不能让读取方拿到 undefined
107
+ out = { schemaVersion: DEGRADE_SCHEMA_V1, updatedAt: null, counts: {}, recent: [], evicted, cap }
108
+ }
109
+ return out
110
+ }
111
+
112
+ /** 某 kind 的累计次数(断言友好)。 */
113
+ function countOf(kind) { try { return counts.get(String(kind)) || 0 } catch (_) { return 0 } }
114
+
115
+ /** 是否发生过任何降级。 */
116
+ function isEmpty() { return counts.size === 0 }
117
+
118
+ function reset() { counts.clear(); recent = []; evicted = 0 }
119
+
120
+ return { record, snapshot, countOf, isEmpty, reset, _capForTest: cap }
121
+ }
122
+
123
+ /**
124
+ * 派生**臂健康快照**(E-3 落地)。
125
+ *
126
+ * 这不是"降级记录",而是**状态陈述** —— 目的是让「某条臂没在工作」可见,
127
+ * 而不必靠"每次 recall 都报错"来推断。例:`evidence: 'no-input'` 一眼可见。
128
+ *
129
+ * @param {Record<string, string>} states 臂名 → 状态(须属 ARM_STATES_V1;非法值归一为 'unknown')
130
+ */
131
+ export function deriveArmsHealthPre(states) {
132
+ const out = {}
133
+ try {
134
+ for (const [arm, st] of Object.entries(states || {})) {
135
+ const s = String(st == null ? '' : st)
136
+ out[String(arm)] = ARM_STATES_V1.includes(s) ? s : 'unknown'
137
+ }
138
+ } catch (_) { /* fail-soft */ }
139
+ return out
140
+ }
141
+
142
+ /**
143
+ * R3-②(2026-09-18):把台账**落盘**为可查询状态文件。
144
+ *
145
+ * 形态选择:**读驱动写**(由 `debugInfo()` 调用),不引入定时器、不新增常驻任务。
146
+ * 理由:① 降级是低频事件,无需实时落盘;② 用户查看诊断时正是"想知道发生了什么"的时刻,
147
+ * 此刻把最新快照写到磁盘 —— 既满足"可查询",又不增加空闲期 IO。
148
+ *
149
+ * ★ 与 record/snapshot 同一条元规则:**落盘失败绝不可影响调用方**
150
+ * —— 全程 try/catch,返回布尔而非抛错(调用方无需自行兜底)。
151
+ *
152
+ * @param {object} p
153
+ * @param {string} p.file 目标 JSON 文件绝对路径
154
+ * @param {object} p.snapshot 已算好的快照(通常来自 sink.snapshot())
155
+ * @param {Function} [p.mkdirSync] 注入的 fs.mkdirSync(便于测试与解耦)
156
+ * @param {Function} [p.writeFileSync] 注入的 fs.writeFileSync
157
+ * @returns {boolean} 是否成功写入(失败返回 false,**不抛**)
158
+ */
159
+ export function persistDegradeLedgerPre(p) {
160
+ try {
161
+ const { file, snapshot, mkdirSync, writeFileSync } = p || {}
162
+ if (!file || typeof file !== 'string') return false
163
+ if (typeof mkdirSync !== 'function' || typeof writeFileSync !== 'function') return false
164
+ const dir = file.replace(/[\\/][^\\/]*$/, '')
165
+ if (dir) mkdirSync(dir, { recursive: true })
166
+ writeFileSync(file, JSON.stringify(snapshot, null, 2), 'utf8')
167
+ return true
168
+ } catch (_) {
169
+ // 元规则:留痕层自身的持久化失败,绝不可打断 debugInfo / 检索主流程。
170
+ return false
171
+ }
172
+ }
173
+
174
+ // ══════════════════════════════════════════════════════════════════════
175
+ // R4(2026-09-18)· 配额测量闭环
176
+ // ══════════════════════════════════════════════════════════════════════
177
+ //
178
+ // ── 要解决的问题(用户原话)──────────────────────────────────────────
179
+ // 「配额这个问题也困扰我很久。有的时候配额太少,效果完全没有,或者有些大条目可能就被过滤掉了,
180
+ // 一点用都没有;有的时候配额多了,我又怕浪费 token」
181
+ // 「确实得基于长期的观察,科学的(测量),不能拍脑子。」
182
+ //
183
+ // ── 为什么放在本模块(而不是新建一个文件)──────────────────────────────
184
+ // S10.4「不新建状态源」:配额观测与降级台账**同属"跨轮可查询的观测面"**,
185
+ // 只是两个不同的消费者。故复用同一模块、同一落盘文件(多一个 `quota` 键),
186
+ // **不新增文件、不新增配置键、不新增常驻任务**。
187
+ //
188
+ // ── 与降级台账的判据边界(务必不要混)──────────────────────────────────
189
+ // · `record(kind, reason)` = **预期外失败**(引擎抛错、目录异常…)—— 只记异常
190
+ // · `observe(meta)`(本函数)= **常规业务观测**(本轮各层进了多少、丢了多少)—— 每轮都记
191
+ // 把常规观测塞进 `record` 会**污染降级判据**("有没有降级"将永远为非空),
192
+ // 故两者**并列而不混用**:各自的 counts/recent 互不干扰。
193
+
194
+ /** 配额探针的身份常量(枚举类常量须配断言兜底 —— 本仓纪律)。 */
195
+ export const QUOTA_PROBE_SCHEMA_V1 = 'quota_probe_v1'
196
+
197
+ /** 采样环上限:与降级台账同口径(有界,防内存无界增长)。 */
198
+ export const QUOTA_PROBE_CAP_V1 = 200
199
+
200
+ /**
201
+ * 配额判定结论枚举(fail-closed 校验)。
202
+ * - `under-quota` 某层被反复丢弃 ⇒ 配额偏小,该层内容进不来
203
+ * - `over-quota` 各层都不丢且远未用满 ⇒ 配额偏大,白花 token
204
+ * - `balanced` 既有丢弃但未持续、占用也合理
205
+ * - `insufficient-data` **样本不足,不猜**(这是默认值 —— 宁可说不知道)
206
+ */
207
+ export const QUOTA_VERDICTS_V1 = Object.freeze(['under-quota', 'over-quota', 'balanced', 'insufficient-data'])
208
+
209
+ /** 判定阈值(集中声明,便于断言锁定与后续按观测调参)。 */
210
+ export const QUOTA_THRESHOLDS_V1 = Object.freeze({
211
+ /** 至少这么多轮采样才敢下结论(少于它一律 insufficient-data)。 */
212
+ minSamples: 8,
213
+ /** 某层"出现丢弃"的轮数占比 ≥ 此值 ⇒ under-quota。 */
214
+ dropRateForUnder: 0.5,
215
+ /** 各层都不丢时,token 占用率 < 此值 ⇒ over-quota(远未用满)。 */
216
+ usageForOver: 0.5,
217
+ })
218
+
219
+ /**
220
+ * 创建**配额探针**:有界收集每轮 `tier0Meta` 的配额相关切片。
221
+ *
222
+ * 与降级台账同一元规则:**观测失败绝不可影响主流程**(全程 try/catch,返回布尔不抛)。
223
+ * 只保留判据所需字段(不整份存 tier0Meta),隐私面与降级台账一致(无正文、无路径)。
224
+ *
225
+ * @param {object} [opts]
226
+ * @param {number} [opts.cap] 采样环上限
227
+ * @param {Function} [opts.now] 取时函数(注入便于测试确定性)
228
+ */
229
+ export function createQuotaProbePre(opts = {}) {
230
+ const cap = Number.isFinite(opts.cap) && opts.cap > 0 ? Math.floor(opts.cap) : QUOTA_PROBE_CAP_V1
231
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now()
232
+ /** @type {Array<object>} 最近采样(有界) */
233
+ let samples = []
234
+ /** 因超上限被淘汰的条数("有界"这件事本身可见,不静默丢数据) */
235
+ let evicted = 0
236
+
237
+ function observe(meta) {
238
+ try {
239
+ if (!meta || typeof meta !== 'object') return false
240
+ const perLayer = {}
241
+ const src = meta.perLayer
242
+ if (src && typeof src === 'object') {
243
+ for (const [layer, m] of Object.entries(src)) {
244
+ if (!m || typeof m !== 'object') continue
245
+ perLayer[String(layer)] = {
246
+ candidates: Number(m.candidates) || 0,
247
+ picked: Number(m.picked) || 0,
248
+ dropped: Number(m.dropped) || 0,
249
+ tokens: Number(m.tokens) || 0,
250
+ cap: m.cap == null ? null : Number(m.cap),
251
+ }
252
+ }
253
+ }
254
+ if (samples.length >= cap) { samples.shift(); evicted += 1 }
255
+ samples.push({
256
+ at: now(),
257
+ tokens: Number(meta.tokens) || 0,
258
+ maxTokens: Number(meta.maxTokens) || 0,
259
+ items: Number(meta.items) || 0,
260
+ candidates: Number(meta.candidates) || 0,
261
+ dropped: Number(meta.dropped) || 0,
262
+ perLayer,
263
+ })
264
+ return true
265
+ } catch (_) { return false }
266
+ }
267
+
268
+ function snapshot() {
269
+ let out
270
+ try {
271
+ out = {
272
+ schemaVersion: QUOTA_PROBE_SCHEMA_V1,
273
+ updatedAt: new Date(now()).toISOString(),
274
+ samples: samples.map((s) => ({ ...s, at: new Date(s.at).toISOString(), perLayer: { ...s.perLayer } })),
275
+ evicted,
276
+ cap,
277
+ }
278
+ } catch (_) {
279
+ // 快照失败也要给出**结构性合法**的最小对象,不能让读取方拿到 undefined
280
+ out = { schemaVersion: QUOTA_PROBE_SCHEMA_V1, updatedAt: null, samples: [], evicted, cap }
281
+ }
282
+ return out
283
+ }
284
+
285
+ function reset() { samples = []; evicted = 0 }
286
+
287
+ return { observe, snapshot, reset, _capForTest: cap }
288
+ }
289
+
290
+ /**
291
+ * 从配额探针快照**推导判定结论**(R4 · 纯函数、零 IO、永不抛)。
292
+ *
293
+ * 这是「科学测量」的判据落点 —— 用户要求**不能拍脑袋**,所以:
294
+ * · 样本不足 ⇒ 一律 `insufficient-data`(**不猜**,这是默认值);
295
+ * · 某层**持续**被丢 ⇒ `under-quota`(该层内容长期进不来 ⇒ 配额偏小);
296
+ * · 各层都不丢、且 token **远未用满** ⇒ `over-quota`(配额偏大、白花 token);
297
+ * · 其余 ⇒ `balanced`。
298
+ *
299
+ * ★ 判据纪律(本仓):**宁可漏判,不可误伤** —— 阈值取保守值,
300
+ * 且把"凭什么这么判"的原始数据(dropRate/perLayer/usage)一并返回,供人复核。
301
+ *
302
+ * @param {object} snap `createQuotaProbePre().snapshot()` 的产物
303
+ * @param {object} [opts] 覆盖阈值(默认取 QUOTA_THRESHOLDS_V1)
304
+ * @returns {{version:string, verdict:string, samples:number, dropRate:number,
305
+ * usage:number, perLayer:object, reasons:string[]}}
306
+ */
307
+ export function deriveQuotaVerdictPre(snap, opts = {}) {
308
+ const th = { ...QUOTA_THRESHOLDS_V1, ...(opts || {}) }
309
+ const base = {
310
+ version: 'quota_verdict_v1',
311
+ verdict: 'insufficient-data',
312
+ samples: 0,
313
+ dropRate: 0,
314
+ usage: 0,
315
+ perLayer: {},
316
+ reasons: [],
317
+ }
318
+ try {
319
+ const list = snap && Array.isArray(snap.samples) ? snap.samples : []
320
+ base.samples = list.length
321
+ if (!list.length) { base.reasons.push('无采样'); return base }
322
+
323
+ // 逐层聚合:出现丢弃的轮数 / token 占用 / 候选与命中
324
+ const agg = {}
325
+ let tokSum = 0, maxSum = 0
326
+ for (const s of list) {
327
+ tokSum += Number(s.tokens) || 0
328
+ maxSum += Number(s.maxTokens) || 0
329
+ const pl = s && s.perLayer && typeof s.perLayer === 'object' ? s.perLayer : {}
330
+ for (const [layer, m] of Object.entries(pl)) {
331
+ if (!agg[layer]) agg[layer] = { rounds: 0, dropRounds: 0, candidates: 0, picked: 0, dropped: 0, tokens: 0 }
332
+ const a = agg[layer]
333
+ a.rounds += 1
334
+ if ((Number(m.dropped) || 0) > 0) a.dropRounds += 1
335
+ a.candidates += Number(m.candidates) || 0
336
+ a.picked += Number(m.picked) || 0
337
+ a.dropped += Number(m.dropped) || 0
338
+ a.tokens += Number(m.tokens) || 0
339
+ }
340
+ }
341
+ for (const [layer, a] of Object.entries(agg)) {
342
+ base.perLayer[layer] = {
343
+ rounds: a.rounds,
344
+ dropRounds: a.dropRounds,
345
+ dropRate: a.rounds ? Number((a.dropRounds / a.rounds).toFixed(3)) : 0,
346
+ candidates: a.candidates,
347
+ picked: a.picked,
348
+ dropped: a.dropped,
349
+ }
350
+ }
351
+ base.usage = maxSum > 0 ? Number((tokSum / maxSum).toFixed(3)) : 0
352
+
353
+ // ★ 样本不足 ⇒ 不猜(用户要的是"基于长期观察",不是几轮就下结论)
354
+ if (list.length < th.minSamples) {
355
+ base.reasons.push('样本不足(' + list.length + '/' + th.minSamples + '),不下结论')
356
+ return base
357
+ }
358
+
359
+ // 某层长期被丢 ⇒ 该层配额偏小
360
+ const underLayers = Object.entries(base.perLayer)
361
+ .filter(([, a]) => a.rounds > 0 && a.dropRate >= th.dropRateForUnder)
362
+ .map(([l]) => l)
363
+ if (underLayers.length) {
364
+ base.verdict = 'under-quota'
365
+ base.reasons.push('层 ' + underLayers.join('/') + ' 持续被丢(dropRate ≥ ' + th.dropRateForUnder + ')⇒ 配额偏小')
366
+ return base
367
+ }
368
+
369
+ // 各层都不丢 + token 远未用满 ⇒ 配额偏大(浪费)
370
+ if (base.usage < th.usageForOver) {
371
+ base.verdict = 'over-quota'
372
+ base.reasons.push('无任何层被丢,且 token 占用率 ' + base.usage + ' < ' + th.usageForOver + ' ⇒ 配额偏大、白花 token')
373
+ return base
374
+ }
375
+
376
+ base.verdict = 'balanced'
377
+ base.reasons.push('有丢弃但未持续,且占用率 ' + base.usage + ' 合理')
378
+ return base
379
+ } catch (_) {
380
+ // 判定失败也要给出**结构性合法**的对象(verdict 保持 insufficient-data,不猜)
381
+ base.reasons.push('判定异常,按样本不足处理')
382
+ return base
383
+ }
384
+ }
385
+