@a9i5k4/dsh-auto-memory 2.5.3 → 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 (167) hide show
  1. package/README.md +189 -7
  2. package/README.zh-CN.md +189 -7
  3. package/docs/CONTRIBUTORS.html +471 -0
  4. package/docs/FRONTEND-CO-CREATION.md +191 -0
  5. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  6. package/docs/HANDOFF-CRITERIA.md +92 -0
  7. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  8. package/docs/INTEGRATION-ANALYSIS.md +350 -348
  9. package/docs/PROMO-PROMPT-3.0.md +100 -0
  10. package/docs/USER-GUIDE.en.md +58 -3
  11. package/docs/USER-GUIDE.zh-CN.md +59 -4
  12. package/docs/WHITEPAPER.md +207 -0
  13. package/docs/internal/ACCEPT-35-LIVE.md +143 -0
  14. package/docs/internal/ACCEPTANCE-20260914.md +90 -0
  15. package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
  16. package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
  17. package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
  18. package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
  19. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
  20. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
  21. package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
  22. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
  23. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
  24. package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
  25. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
  26. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
  27. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
  28. package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
  29. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
  30. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
  31. package/docs/internal/FEATURE-INVENTORY.md +531 -0
  32. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
  33. package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
  34. package/docs/internal/G3-DESIGN-20260918.md +82 -0
  35. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
  36. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
  37. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
  38. package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
  39. package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
  40. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
  41. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
  42. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
  43. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
  44. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
  45. package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
  46. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
  47. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
  48. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
  49. package/docs/internal/KICKOFF-P0.md +254 -0
  50. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
  51. package/docs/internal/MASTER-PLAN-3.0.md +411 -0
  52. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
  53. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
  54. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
  55. package/docs/internal/PENDING-FIXES-20260916.md +289 -0
  56. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
  57. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
  58. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
  59. package/docs/internal/PROGRESS-20260917.md +93 -0
  60. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
  61. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
  62. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
  63. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
  64. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
  65. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
  66. package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
  67. package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
  68. package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
  69. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
  70. package/docs/internal/RESUME-20260918.md +171 -0
  71. package/docs/internal/RESUME-20260919.md +104 -0
  72. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
  73. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
  74. package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
  75. package/docs/internal/ROADMAP.md +106 -0
  76. package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
  77. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
  78. package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
  79. package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
  80. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
  81. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
  82. package/docs/internal/T6-EXECUTION-20260920.md +130 -0
  83. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
  84. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
  85. package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
  86. package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
  87. package/docs/internal/TODO-BACKLOG.md +263 -142
  88. package/docs/internal/TODO-GRAPH.html +715 -0
  89. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
  90. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
  91. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
  92. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
  93. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
  94. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
  95. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
  96. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
  97. package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
  98. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
  99. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
  100. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
  101. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
  102. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
  103. package/docs/prompts/M8-3-enable-verify.md +49 -49
  104. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  105. package/lib/acceptance.js +71 -0
  106. package/lib/activation-host.js +153 -18
  107. package/lib/activation-inbox.js +25 -7
  108. package/lib/board-mode.js +30 -0
  109. package/lib/client.js +1758 -90
  110. package/lib/config-io.js +156 -0
  111. package/lib/context-bridge.js +5 -2
  112. package/lib/context-host.js +86 -15
  113. package/lib/degrade.js +385 -0
  114. package/lib/dsh-home.js +143 -0
  115. package/lib/engine-identity.js +149 -0
  116. package/lib/engine-switch.js +247 -0
  117. package/lib/episodic-store.js +63 -12
  118. package/lib/evidence-store.js +10 -3
  119. package/lib/fact-store.js +22 -3
  120. package/lib/fs-retry.js +46 -0
  121. package/lib/index-sync.js +13 -1
  122. package/lib/index.js +3446 -263
  123. package/lib/intent-clean-safe.js +258 -0
  124. package/lib/intent-clean.js +12 -16
  125. package/lib/l0-extract.js +478 -149
  126. package/lib/l0-index-sync.js +195 -0
  127. package/lib/l0-index.js +349 -239
  128. package/lib/ledger-criteria.js +142 -0
  129. package/lib/m4-corpus.js +8 -2
  130. package/lib/m7-index-sync-host.js +73 -5
  131. package/lib/m7-wire.js +3 -3
  132. package/lib/memory-anchor.js +56 -1
  133. package/lib/memory-envelope.js +257 -0
  134. package/lib/memory-hub.js +138 -13
  135. package/lib/memory-index.js +4 -2
  136. package/lib/memory-mutation.js +246 -0
  137. package/lib/memory-writer.js +204 -24
  138. package/lib/note-status-apply.js +118 -0
  139. package/lib/note-status.js +196 -0
  140. package/lib/procedure-observation.js +48 -0
  141. package/lib/procedure-store.js +118 -20
  142. package/lib/python-setup.js +1 -1
  143. package/lib/python-sidecar-client.js +29 -3
  144. package/lib/recall-fusion.js +83 -12
  145. package/lib/rerank-host.js +160 -0
  146. package/lib/rules-edit.js +159 -0
  147. package/lib/rules-layer.js +261 -0
  148. package/lib/semantic-decide.js +41 -8
  149. package/lib/semantic-js.js +66 -6
  150. package/lib/shadow-host.js +3 -5
  151. package/lib/shadow-retrieval.js +3 -3
  152. package/lib/skill-export-host.js +153 -0
  153. package/lib/skill-export.js +239 -0
  154. package/lib/state-commit.js +245 -0
  155. package/lib/storage-manage.js +6 -0
  156. package/lib/subagent-gc.js +4 -8
  157. package/lib/temporal-parse.js +191 -159
  158. package/lib/tier-layer-inject.js +650 -0
  159. package/lib/tier0-catalog.js +735 -0
  160. package/lib/water-window.js +263 -186
  161. package/lib/wb-contract.js +691 -0
  162. package/lib/wb-sidecar.js +890 -0
  163. package/lib/ws-overview-rank.js +2 -2
  164. package/package.json +1 -1
  165. package/python/m7_embedding_v1.py +5 -5
  166. package/python/worker_semantic_v1.py +17 -6
  167. package/python/worker_v1.py +38 -4
package/lib/l0-index.js CHANGED
@@ -1,239 +1,349 @@
1
- /**
2
- * L0 向量索引(l0_index_v1)—— 为 T1 产出的 L0 建立向量索引,供语义检索使用。
3
- *
4
- * 2026-09-09 建立(P1)。参照 OpenViking「Vector Index 只存 URI+向量+元数据,不含文件内容」:
5
- * 每条目仅 {id, vector, l0, source, l0Hash, updatedAt},**绝不存记忆原文**。
6
- *
7
- * 组成(工厂 createL0IndexPre,IO 与 embedding 全注入,模块层零 fs/零模型):
8
- * 1) buildFull —— 全量建索引:buildL0IndexPre(text) × embedPassages(l0s),一次性写盘
9
- * 2) update —— 增量:逐条比 l0Hash,新增/重算/跳过/移除,仅重算变化条
10
- * 3) remove —— 显式按 ids 移除失效条目
11
- * 4) load —— fail-soft 读取:文件缺失/schema 不符/条目非法 → {ok:false, entries:[]},绝不抛
12
- * 5) status —— 索引概况(count/version/updatedAt)
13
- *
14
- * 身份与版本约定(沿用仓库惯例):
15
- * - l0Hash = sha256(l0),增量重算的唯一判据(l0 不变 → 跳过该条)
16
- * - l0IndexVersion = 'l0idx_' + first32hex(sha256(canonical sorted [id,l0Hash] tuples))
17
- * (前缀 l0idx_ 有意区别于 corpus 的 idx_ memoryIndexVersion,避免两套版本语义混淆)
18
- * - 向量维度由 embedder 决定(真实 C2 引擎为 384 维已归一化),本模块不硬编码维度
19
- * - 向量以纯 number 数组存盘(JSON 安全);embedder 返回 Float32Array 时经 Array.from 转换
20
- *
21
- * 边界:纯函数 + IO 注入、零 npm 依赖;所有 API 在边界处 fail-soft 返回 {ok:false,error},
22
- * 不向调用方抛异常(不阻塞任何调用方);非法输入返回空结果。UTF-8 无 BOM。
23
- */
24
- import { createHash } from 'node:crypto'
25
- import { buildL0IndexPre } from './l0-extract.js'
26
-
27
- export const L0_INDEX_VERSION = 'l0_index_v1'
28
- export const L0_INDEX_SCHEMA_VERSION = 1
29
-
30
- const sha256Hex = (s) => createHash('sha256').update(String(s == null ? '' : s), 'utf8').digest('hex')
31
- const HEX64_RE = /^[0-9a-f]{64}$/
32
-
33
- /** 规范化向量:Float32Array/number[] → 纯 number 数组;非法输入返回 null。 */
34
- function normalizeVector(vec) {
35
- if (!(vec && (Array.isArray(vec) || vec instanceof Float32Array))) return null
36
- const out = []
37
- for (let i = 0; i < vec.length; i++) {
38
- const n = Number(vec[i])
39
- if (!Number.isFinite(n)) return null
40
- out.push(n)
41
- }
42
- return out.length ? out : null
43
- }
44
-
45
- /**
46
- * l0IndexVersion:canonical sorted [id,l0Hash] tuples → 'l0idx_' + first32hex(sha256)。
47
- * 同内容同版本(确定性);任一条目 l0 变化或条目增删 → 版本变化。
48
- */
49
- export function computeL0IndexVersionPre(entries) {
50
- const canon = (Array.isArray(entries) ? entries : [])
51
- .map((e) => [String(e && e.id) || '', String(e && e.l0Hash) || ''])
52
- .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
53
- return 'l0idx_' + sha256Hex(JSON.stringify(canon)).slice(0, 32)
54
- }
55
-
56
- /**
57
- * 工厂:创建 L0 索引操作器。
58
- * @param {object} opts
59
- * @param {{readJson(path:any):any, writeJson(path:any, obj:any):void, exists?(path:any):boolean}} opts.io
60
- * 磁盘 IO 全注入;readJson 对缺失文件应返回 null 或抛错(两种都被模块层容错)。
61
- * @param {{embedPassages(texts:string[]):Promise<Float32Array[]|number[][]>}} opts.embedder
62
- * embedding 注入(真实 C2 引擎或测试假 embedder);前缀由引擎内部负责,调用方传裸文本。
63
- * @param {()=>number} [opts.now] 时间注入(默认 Date.now;测试确定性用)。
64
- */
65
- export function createL0IndexPre(opts = {}) {
66
- const io = opts.io
67
- const embedder = opts.embedder
68
- const nowMs = () => (typeof opts.now === 'function' ? Number(opts.now()) || 0 : Date.now())
69
- if (!io || typeof io.readJson !== 'function' || typeof io.writeJson !== 'function') {
70
- // fail closed:工厂级配置错误直接抛(调用方组装错误,不属于运行期 fail-soft 范畴)
71
- throw new Error('l0-index: io.readJson/io.writeJson required')
72
- }
73
-
74
- /** fail-soft 读取+整文件校验。任何异常/不符 → {ok:false, reason, entries:[]}。 */
75
- function load({ path } = {}) {
76
- try {
77
- const obj = io.readJson(path)
78
- if (!obj || typeof obj !== 'object') return { ok: false, reason: 'missing', entries: [] }
79
- if (obj.schemaVersion !== L0_INDEX_SCHEMA_VERSION) return { ok: false, reason: 'schema', entries: [] }
80
- if (typeof obj.l0IndexVersion !== 'string' || !obj.l0IndexVersion.startsWith('l0idx_')) {
81
- return { ok: false, reason: 'version', entries: [] }
82
- }
83
- if (!Array.isArray(obj.entries)) return { ok: false, reason: 'entries', entries: [] }
84
- const out = []
85
- for (const e of obj.entries) {
86
- if (!e || typeof e !== 'object') return { ok: false, reason: 'entry', entries: [] }
87
- if (typeof e.id !== 'string' || !e.id) return { ok: false, reason: 'entry.id', entries: [] }
88
- if (typeof e.l0 !== 'string') return { ok: false, reason: 'entry.l0', entries: [] }
89
- if (typeof e.l0Hash !== 'string' || !HEX64_RE.test(e.l0Hash)) return { ok: false, reason: 'entry.l0Hash', entries: [] }
90
- if (typeof e.source !== 'string') return { ok: false, reason: 'entry.source', entries: [] }
91
- if (!Number.isFinite(Number(e.updatedAt))) return { ok: false, reason: 'entry.updatedAt', entries: [] }
92
- const vec = normalizeVector(e.vector)
93
- if (!vec) return { ok: false, reason: 'entry.vector', entries: [] }
94
- out.push({ id: e.id, vector: vec, l0: e.l0, source: e.source, l0Hash: e.l0Hash, updatedAt: Number(e.updatedAt) })
95
- }
96
- return { ok: true, entries: out, l0IndexVersion: obj.l0IndexVersion, updatedAt: Number(obj.updatedAt) || 0 }
97
- } catch (e) {
98
- return { ok: false, reason: 'read-error', error: String(e && e.message ? e.message : e), entries: [] }
99
- }
100
- }
101
-
102
- /** 由 buildL0IndexPre 的条目 + 批量 embedding 组装索引文件对象(不写盘)。 */
103
- async function assemble(items, prevById) {
104
- const texts = items.map((it) => it.l0)
105
- const vecs = await embedder.embedPassages(texts)
106
- if (!Array.isArray(vecs) || vecs.length !== items.length) {
107
- throw new Error('l0-index: embedder returned ' + (Array.isArray(vecs) ? vecs.length : 'non-array') + ' vectors for ' + items.length + ' passages')
108
- }
109
- const ts = nowMs()
110
- const entries = items.map((it, i) => {
111
- const vector = normalizeVector(vecs[i])
112
- if (!vector) throw new Error('l0-index: embedder produced invalid vector at index ' + i)
113
- const l0Hash = sha256Hex(it.l0)
114
- const prev = prevById ? prevById.get(it.id) : null
115
- // 复用语义:prev 的 l0Hash 一致才整条复用(保留原 updatedAt);否则按新条处理
116
- const reused = prev && prev.l0Hash === l0Hash
117
- return {
118
- id: it.id,
119
- vector: reused ? prev.vector : vector,
120
- l0: it.l0,
121
- source: it.source,
122
- l0Hash,
123
- updatedAt: reused ? prev.updatedAt : ts,
124
- }
125
- })
126
- entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
127
- return {
128
- schemaVersion: L0_INDEX_SCHEMA_VERSION,
129
- l0IndexVersion: computeL0IndexVersionPre(entries),
130
- updatedAt: ts,
131
- entries,
132
- }
133
- }
134
-
135
- async function writeIndex(path, fileObj) {
136
- io.writeJson(path, fileObj)
137
- return fileObj
138
- }
139
-
140
- return {
141
- version: L0_INDEX_VERSION,
142
-
143
- /** 全量建索引(整文件重建,所有条目重算)。返回 {ok, count, added, recomputed, removed, skipped, l0IndexVersion} 或 {ok:false, error}。 */
144
- async buildFull({ path, text, maxChars, minChars } = {}) {
145
- try {
146
- if (!embedder || typeof embedder.embedPassages !== 'function') throw new Error('embedder.embedPassages required')
147
- const items = buildL0IndexPre(typeof text === 'string' ? text : '', { maxChars, minChars })
148
- const fileObj = await assemble(items, null)
149
- await writeIndex(path, fileObj)
150
- return {
151
- ok: true, count: fileObj.entries.length, added: fileObj.entries.length,
152
- recomputed: fileObj.entries.length, removed: 0, skipped: 0,
153
- l0IndexVersion: fileObj.l0IndexVersion,
154
- }
155
- } catch (e) {
156
- return { ok: false, error: String(e && e.message ? e.message : e) }
157
- }
158
- },
159
-
160
- /**
161
- * 增量更新:新 L0 与旧索引逐条比 l0Hash——
162
- * 新增(旧无此 id)/变化(hash 不同)→ 仅这些条重算;不变 → 原样保留;消失(旧有新无)→ 移除。
163
- * 旧索引损坏/非法 → fail-soft 退化为全量重建(recovered:true),不阻塞调用方。
164
- */
165
- async update({ path, text, maxChars, minChars } = {}) {
166
- try {
167
- if (!embedder || typeof embedder.embedPassages !== 'function') throw new Error('embedder.embedPassages required')
168
- const prev = load({ path })
169
- const recovered = !prev.ok
170
- const prevById = new Map()
171
- if (prev.ok) for (const e of prev.entries) prevById.set(e.id, e)
172
- const items = buildL0IndexPre(typeof text === 'string' ? text : '', { maxChars, minChars })
173
- // 先做哈希分类,只为计数;实际组装仍走 assemble(其内部同样按 hash 决定复用)
174
- const nextById = new Map()
175
- let unchanged = 0
176
- let changed = 0
177
- for (const it of items) {
178
- const h = sha256Hex(it.l0)
179
- nextById.set(it.id, h)
180
- const p = prevById.get(it.id)
181
- if (p && p.l0Hash === h) unchanged++
182
- else changed++
183
- }
184
- let removed = 0
185
- for (const id of prevById.keys()) if (!nextById.has(id)) removed++
186
- const fileObj = await assemble(items, prevById)
187
- await writeIndex(path, fileObj)
188
- return {
189
- ok: true,
190
- count: fileObj.entries.length,
191
- added: items.filter((it) => !prevById.has(it.id)).length,
192
- recomputed: changed,
193
- removed,
194
- skipped: unchanged,
195
- recovered: recovered || undefined,
196
- l0IndexVersion: fileObj.l0IndexVersion,
197
- }
198
- } catch (e) {
199
- return { ok: false, error: String(e && e.message ? e.message : e) }
200
- }
201
- },
202
-
203
- /** 显式移除失效条目。ids 中不存在的自动忽略。 */
204
- async remove({ path, ids } = {}) {
205
- try {
206
- const prev = load({ path })
207
- if (!prev.ok) return { ok: false, error: 'index not loadable: ' + (prev.reason || '?'), removed: 0 }
208
- const drop = new Set((Array.isArray(ids) ? ids : []).map(String))
209
- const kept = prev.entries.filter((e) => !drop.has(e.id))
210
- const ts = nowMs()
211
- const fileObj = {
212
- schemaVersion: L0_INDEX_SCHEMA_VERSION,
213
- l0IndexVersion: computeL0IndexVersionPre(kept),
214
- updatedAt: ts,
215
- entries: kept,
216
- }
217
- await writeIndex(path, fileObj)
218
- return {
219
- ok: true,
220
- removed: prev.entries.length - kept.length,
221
- count: kept.length,
222
- l0IndexVersion: fileObj.l0IndexVersion,
223
- }
224
- } catch (e) {
225
- return { ok: false, error: String(e && e.message ? e.message : e), removed: 0 }
226
- }
227
- },
228
-
229
- /** fail-soft 读取(见 load)。 */
230
- load,
231
-
232
- /** 索引概况(不修改任何状态)。 */
233
- status({ path } = {}) {
234
- const r = load({ path })
235
- if (!r.ok) return { ok: false, count: 0, path, reason: r.reason }
236
- return { ok: true, count: r.entries.length, path, l0IndexVersion: r.l0IndexVersion, updatedAt: r.updatedAt }
237
- },
238
- }
239
- }
1
+ /**
2
+ * L0 向量索引(l0_index_v1)—— 为 T1 产出的 L0 建立向量索引,供语义检索使用。
3
+ *
4
+ * 2026-09-09 建立(P1)。参照 OpenViking「Vector Index 只存 URI+向量+元数据,不含文件内容」:
5
+ * 每条目仅 {id, vector, l0, source, l0Hash, updatedAt},**绝不存记忆原文**。
6
+ *
7
+ * 组成(工厂 createL0IndexPre,IO 与 embedding 全注入,模块层零 fs/零模型):
8
+ * 1) buildFull —— 全量建索引:buildL0IndexPre(text) × embedPassages(l0s),一次性写盘
9
+ * 2) update —— 增量:逐条比 l0Hash,新增/重算/跳过/移除,仅重算变化条
10
+ * 3) remove —— 显式按 ids 移除失效条目
11
+ * 4) load —— fail-soft 读取:文件缺失/schema 不符/条目非法 → {ok:false, entries:[]},绝不抛
12
+ * 5) status —— 索引概况(count/version/updatedAt)
13
+ *
14
+ * 身份与版本约定(沿用仓库惯例):
15
+ * - l0Hash = sha256(l0),增量重算的唯一判据(l0 不变 → 跳过该条)
16
+ * - l0IndexVersion = 'l0idx_' + first32hex(sha256(canonical sorted [id,l0Hash] tuples))
17
+ * (前缀 l0idx_ 有意区别于 corpus 的 idx_ memoryIndexVersion,避免两套版本语义混淆)
18
+ * - 向量维度由 embedder 决定(真实 C2 引擎为 384 维已归一化),本模块不硬编码维度
19
+ * - 向量以纯 number 数组存盘(JSON 安全);embedder 返回 Float32Array 时经 Array.from 转换
20
+ *
21
+ * 边界:纯函数 + IO 注入、零 npm 依赖;所有 API 在边界处 fail-soft 返回 {ok:false,error},
22
+ * 不向调用方抛异常(不阻塞任何调用方);非法输入返回空结果。UTF-8 无 BOM。
23
+ */
24
+ import { createHash } from 'node:crypto'
25
+ import { buildL0IndexPre, L0_LAYERS, L0_STATUSES, L0_DEFAULT_LAYER } from './l0-extract.js'
26
+ import { isEngineIdentityPre } from './engine-identity.js'
27
+
28
+ export const L0_INDEX_VERSION = 'l0_index_v1'
29
+ export const L0_INDEX_SCHEMA_VERSION = 1
30
+
31
+ /**
32
+ * 2026-09-14(三层契约 C3):**索引条目显式落 `layer` + `status` 两列**。
33
+ * 此前 assemble() 只写 {id,vector,l0,source,l0Hash,updatedAt} → 层与状态在索引里丢失,
34
+ * 检索侧只能看到原文块而无法按层/状态过滤(契约 I4「每条带 layer+status」在 L0 索引这一环断了)。
35
+ * 兼容口径:两列按「缺失即默认」处理(layer→log / status→current),**不因缺列拒绝整文件**,
36
+ * 这样既有索引文件(无这两列)仍可加载,不触发无谓的全量重建。
37
+ */
38
+ export const L0_INDEX_DEFAULT_STATUS = 'current'
39
+ const normLayerPre = (x) => (typeof x === 'string' && L0_LAYERS.includes(x) ? x : L0_DEFAULT_LAYER)
40
+ const normStatusPre = (x) => (typeof x === 'string' && L0_STATUSES.includes(x) ? x : L0_INDEX_DEFAULT_STATUS)
41
+
42
+ const sha256Hex = (s) => createHash('sha256').update(String(s == null ? '' : s), 'utf8').digest('hex')
43
+ const HEX64_RE = /^[0-9a-f]{64}$/
44
+
45
+ /** 规范化向量:Float32Array/number[] → 纯 number 数组;非法输入返回 null。 */
46
+ function normalizeVector(vec) {
47
+ if (!(vec && (Array.isArray(vec) || vec instanceof Float32Array))) return null
48
+ const out = []
49
+ for (let i = 0; i < vec.length; i++) {
50
+ const n = Number(vec[i])
51
+ if (!Number.isFinite(n)) return null
52
+ out.push(n)
53
+ }
54
+ return out.length ? out : null
55
+ }
56
+
57
+ /**
58
+ * l0IndexVersion:canonical sorted [id,l0Hash,**layer,status**] tuples → 'l0idx_' + first32hex(sha256)。
59
+ * 同内容同版本(确定性);任一条目 l0 变化、层归属或状态变化、条目增删 → 版本变化。
60
+ * (C3:层/状态进身份,否则「同一条记忆换层」不会触发版本变化 → 下游缓存会拿到过期归属。)
61
+ */
62
+ export function computeL0IndexVersionPre(entries) {
63
+ const canon = (Array.isArray(entries) ? entries : [])
64
+ .map((e) => [
65
+ String(e && e.id) || '',
66
+ String(e && e.l0Hash) || '',
67
+ normLayerPre(e && e.layer),
68
+ normStatusPre(e && e.status),
69
+ ])
70
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
71
+ return 'l0idx_' + sha256Hex(JSON.stringify(canon)).slice(0, 32)
72
+ }
73
+
74
+ /**
75
+ * 工厂:创建 L0 索引操作器。
76
+ * @param {object} opts
77
+ * @param {{readJson(path:any):any, writeJson(path:any, obj:any):void, exists?(path:any):boolean}} opts.io
78
+ * 磁盘 IO 全注入;readJson 对缺失文件应返回 null 或抛错(两种都被模块层容错)。
79
+ * @param {{embedPassages(texts:string[]):Promise<Float32Array[]|number[][]>}} opts.embedder
80
+ * embedding 注入(真实 C2 引擎或测试假 embedder);前缀由引擎内部负责,调用方传裸文本。
81
+ * @param {()=>number} [opts.now] 时间注入(默认 Date.now;测试确定性用)。
82
+ */
83
+ export function createL0IndexPre(opts = {}) {
84
+ const io = opts.io
85
+ const embedder = opts.embedder
86
+ const nowMs = () => (typeof opts.now === 'function' ? Number(opts.now()) || 0 : Date.now())
87
+ // ★P2 引擎隔离(T2-9):工厂级身份 = 当前写入/读取该索引所用的嵌入引擎身份。
88
+ // 未提供合法身份时不启用身份门(保持旧行为,便于渐进接线与测试)。
89
+ const engineIdentity = isEngineIdentityPre(opts.engineIdentity) ? String(opts.engineIdentity) : null
90
+ const identityGate = engineIdentity != null && opts.engineIdentityGate !== false
91
+ // 2B 跨 id 复用开关:false ⇒ 只按「同 id 同 hash」复用(严格旧行为,回滚用)。
92
+ const crossIdReuse = opts.crossIdReuse !== false
93
+ if (!io || typeof io.readJson !== 'function' || typeof io.writeJson !== 'function') {
94
+ // fail closed:工厂级配置错误直接抛(调用方组装错误,不属于运行期 fail-soft 范畴)
95
+ throw new Error('l0-index: io.readJson/io.writeJson required')
96
+ }
97
+
98
+ /** fail-soft 读取+整文件校验。任何异常/不符 → {ok:false, reason, entries:[]}。 */
99
+ function load({ path } = {}) {
100
+ try {
101
+ const obj = io.readJson(path)
102
+ if (!obj || typeof obj !== 'object') return { ok: false, reason: 'missing', entries: [] }
103
+ if (obj.schemaVersion !== L0_INDEX_SCHEMA_VERSION) return { ok: false, reason: 'schema', entries: [] }
104
+ if (typeof obj.l0IndexVersion !== 'string' || !obj.l0IndexVersion.startsWith('l0idx_')) {
105
+ return { ok: false, reason: 'version', entries: [] }
106
+ }
107
+ // ★P2 T2-9 引擎身份门:文件声明的引擎身份与当前引擎不一致 ⇒ 整文件判不可用,
108
+ // 调用方(update)随即走全量重建路径 —— 两套向量绝不可能参与同一次排序。
109
+ // 兼容口径:旧文件(无该字段)按当前引擎接受;身份门关闭时本检查整体跳过。
110
+ if (identityGate) {
111
+ const fileIdentity = obj.engineIdentity
112
+ if (isEngineIdentityPre(fileIdentity) && fileIdentity !== engineIdentity) {
113
+ return { ok: false, reason: 'engine-mismatch', entries: [], fileEngineIdentity: fileIdentity }
114
+ }
115
+ }
116
+ if (!Array.isArray(obj.entries)) return { ok: false, reason: 'entries', entries: [] }
117
+ const out = []
118
+ for (const e of obj.entries) {
119
+ if (!e || typeof e !== 'object') return { ok: false, reason: 'entry', entries: [] }
120
+ if (typeof e.id !== 'string' || !e.id) return { ok: false, reason: 'entry.id', entries: [] }
121
+ if (typeof e.l0 !== 'string') return { ok: false, reason: 'entry.l0', entries: [] }
122
+ if (typeof e.l0Hash !== 'string' || !HEX64_RE.test(e.l0Hash)) return { ok: false, reason: 'entry.l0Hash', entries: [] }
123
+ if (typeof e.source !== 'string') return { ok: false, reason: 'entry.source', entries: [] }
124
+ if (!Number.isFinite(Number(e.updatedAt))) return { ok: false, reason: 'entry.updatedAt', entries: [] }
125
+ const vec = normalizeVector(e.vector)
126
+ if (!vec) return { ok: false, reason: 'entry.vector', entries: [] }
127
+ out.push({
128
+ id: e.id, vector: vec, l0: e.l0, source: e.source, l0Hash: e.l0Hash, updatedAt: Number(e.updatedAt),
129
+ // C3:两列按「缺失即默认」归一化——既有索引文件(无这两列)照常加载,不触发全量重建;
130
+ // 非法值也不放行,绝不把脏层名/状态带进检索侧。
131
+ layer: normLayerPre(e.layer), status: normStatusPre(e.status),
132
+ })
133
+ }
134
+ return { ok: true, entries: out, l0IndexVersion: obj.l0IndexVersion, updatedAt: Number(obj.updatedAt) || 0 }
135
+ } catch (e) {
136
+ return { ok: false, reason: 'read-error', error: String(e && e.message ? e.message : e), entries: [] }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * 由 buildL0IndexPre 的条目 + 批量 embedding 组装索引文件对象(不写盘)。
142
+ *
143
+ * ★P2 真增量(T2-1/T2-2):**只把"找不到可复用向量"的条目送进 embedder**。
144
+ * 复用查找顺序(两层引用):
145
+ * ① 同 id 且 l0Hash 相同 —— 原行为(内容未变的同一条目);
146
+ * ② **不同 id 但 l0Hash 相同** —— 2B 跨 id 复用:记录变了导致未改块重新编号时,
147
+ * alias 指向新 id,但 vectorKey(= 引擎身份 + hash(exactEncoderInput))不变,
148
+ * 因此**不重新编码相同输入**(T2-2:固定三块只改末块 → 实际只编码 1 个输入)。
149
+ * 返回 `{fileObj, embedded, reusedById, reusedByHash}`,其中 `embedded` = 真实 embedder
150
+ * 输入数(T2-1 直接消费;**不是**"按返回计数推断",而是实际送进去的条数)。
151
+ */
152
+ async function assemble(items, prevById) {
153
+ const prevList = prevById ? [...prevById.values()] : []
154
+ // 索引:hash → 向量(跨 id 复用的第二级;同 hash 多条时取任意一条的向量即可 —— 输入相同则向量相同)
155
+ const vecByHash = new Map()
156
+ if (crossIdReuse && prevList.length) {
157
+ for (const e of prevList) if (e && e.l0Hash && Array.isArray(e.vector)) vecByHash.set(e.l0Hash, e.vector)
158
+ }
159
+ const resolveReuse = (it, hash) => {
160
+ const prev = prevById ? prevById.get(it.id) : null
161
+ if (prev && prev.l0Hash === hash && Array.isArray(prev.vector)) return { vector: prev.vector, via: 'id' }
162
+ if (crossIdReuse) {
163
+ const v = vecByHash.get(hash)
164
+ if (v) return { vector: v, via: 'hash' }
165
+ }
166
+ return null
167
+ }
168
+ // ① 先分类:可复用者直接拿向量,其余进待嵌入集合(同一 hash 只嵌入一次 —— 批内去重)
169
+ const needEmbed = []
170
+ const seenHash = new Set()
171
+ const plan = [] // {it, hash, reuse}
172
+ for (const it of items) {
173
+ const hash = sha256Hex(it.l0)
174
+ const reuse = resolveReuse(it, hash)
175
+ plan.push({ it, hash, reuse })
176
+ if (!reuse && !seenHash.has(hash)) { seenHash.add(hash); needEmbed.push({ hash, text: it.l0 }) }
177
+ }
178
+ // ② 只嵌入真需要的(空数组时不调用 embedder —— 不变更新的真实输入数 = 0)
179
+ let vecsByEmbeddedHash = new Map()
180
+ if (needEmbed.length) {
181
+ const vecs = await embedder.embedPassages(needEmbed.map((x) => x.text))
182
+ if (!Array.isArray(vecs) || vecs.length !== needEmbed.length) {
183
+ throw new Error('l0-index: embedder returned ' + (Array.isArray(vecs) ? vecs.length : 'non-array') + ' vectors for ' + needEmbed.length + ' passages')
184
+ }
185
+ needEmbed.forEach((x, i) => {
186
+ const v = normalizeVector(vecs[i])
187
+ if (!v) throw new Error('l0-index: embedder produced invalid vector at index ' + i)
188
+ vecsByEmbeddedHash.set(x.hash, v)
189
+ })
190
+ }
191
+ const ts = nowMs()
192
+ let reusedById = 0
193
+ let reusedByHash = 0
194
+ const entries = plan.map(({ it, hash, reuse }) => {
195
+ let vector
196
+ if (reuse) {
197
+ vector = reuse.vector
198
+ if (reuse.via === 'id') reusedById++
199
+ else reusedByHash++
200
+ } else {
201
+ vector = vecsByEmbeddedHash.get(hash)
202
+ if (!vector) throw new Error('l0-index: missing vector for ' + it.id)
203
+ }
204
+ const prev = prevById ? prevById.get(it.id) : null
205
+ // updatedAt 语义:整条(同 id 同 hash)复用时保留原时间;跨 id 复用属"新条目指向旧向量",按新条记时。
206
+ const sameEntryReused = !!(prev && prev.l0Hash === hash)
207
+ return {
208
+ id: it.id,
209
+ vector,
210
+ l0: it.l0,
211
+ source: it.source,
212
+ l0Hash: hash,
213
+ updatedAt: sameEntryReused ? prev.updatedAt : ts,
214
+ // C3:层与状态显式落盘(取自 buildL0IndexPre 的条目,缺失/非法即归一化)。
215
+ layer: normLayerPre(it.layer),
216
+ status: normStatusPre(it.status),
217
+ }
218
+ })
219
+ entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
220
+ return {
221
+ fileObj: {
222
+ schemaVersion: L0_INDEX_SCHEMA_VERSION,
223
+ // ★P2 T2-9:文件声明写入它的引擎身份(宽身份串;身份门开启时必填)。
224
+ ...(engineIdentity ? { engineIdentity } : {}),
225
+ l0IndexVersion: computeL0IndexVersionPre(entries),
226
+ updatedAt: ts,
227
+ entries,
228
+ },
229
+ embedded: needEmbed.length,
230
+ reusedById,
231
+ reusedByHash,
232
+ }
233
+ }
234
+
235
+ async function writeIndex(path, fileObj) {
236
+ io.writeJson(path, fileObj)
237
+ return fileObj
238
+ }
239
+
240
+ return {
241
+ version: L0_INDEX_VERSION,
242
+
243
+ /** 全量建索引(整文件重建,所有条目重算)。返回 {ok, count, added, recomputed, removed, skipped, embedded, l0IndexVersion} 或 {ok:false, error}。 */
244
+ async buildFull({ path, text, maxChars, minChars, layer } = {}) {
245
+ try {
246
+ if (!embedder || typeof embedder.embedPassages !== 'function') throw new Error('embedder.embedPassages required')
247
+ const items = buildL0IndexPre(typeof text === 'string' ? text : '', { maxChars, minChars, layer })
248
+ const built = await assemble(items, null)
249
+ const fileObj = built.fileObj
250
+ await writeIndex(path, fileObj)
251
+ return {
252
+ ok: true, count: fileObj.entries.length, added: fileObj.entries.length,
253
+ recomputed: fileObj.entries.length, removed: 0, skipped: 0,
254
+ embedded: built.embedded, // 全量重建 = 真实 embedder 输入数(= 条目数)
255
+ l0IndexVersion: fileObj.l0IndexVersion,
256
+ }
257
+ } catch (e) {
258
+ return { ok: false, error: String(e && e.message ? e.message : e) }
259
+ }
260
+ },
261
+
262
+ /**
263
+ * 增量更新:新 L0 与旧索引逐条比 l0Hash——
264
+ * 新增(旧无此 id)/变化(hash 不同)→ 仅这些条重算;不变 → 原样保留;消失(旧有新无)→ 移除。
265
+ * 旧索引损坏/非法/引擎身份不符 → fail-soft 退化为全量重建(recovered / engineMismatch),不阻塞调用方。
266
+ *
267
+ * ★P2:`embedded` = **实际送进 embedder 的输入数**(T2-1 判据:不变更新 0 / 单新增 1 / 状态变化 0)。
268
+ */
269
+ async update({ path, text, maxChars, minChars, layer } = {}) {
270
+ try {
271
+ if (!embedder || typeof embedder.embedPassages !== 'function') throw new Error('embedder.embedPassages required')
272
+ const prev = load({ path })
273
+ const recovered = !prev.ok
274
+ const engineMismatch = !prev.ok && prev.reason === 'engine-mismatch'
275
+ const prevById = new Map()
276
+ if (prev.ok) for (const e of prev.entries) prevById.set(e.id, e)
277
+ const items = buildL0IndexPre(typeof text === 'string' ? text : '', { maxChars, minChars, layer })
278
+ // 先做哈希分类,只为计数;实际组装仍走 assemble(其内部同样按 hash 决定复用)
279
+ const nextById = new Map()
280
+ let unchanged = 0
281
+ let changed = 0
282
+ for (const it of items) {
283
+ const h = sha256Hex(it.l0)
284
+ nextById.set(it.id, h)
285
+ const p = prevById.get(it.id)
286
+ if (p && p.l0Hash === h) unchanged++
287
+ else changed++
288
+ }
289
+ let removed = 0
290
+ for (const id of prevById.keys()) if (!nextById.has(id)) removed++
291
+ const built = await assemble(items, prevById)
292
+ await writeIndex(path, built.fileObj)
293
+ return {
294
+ ok: true,
295
+ count: built.fileObj.entries.length,
296
+ added: items.filter((it) => !prevById.has(it.id)).length,
297
+ recomputed: changed,
298
+ removed,
299
+ skipped: unchanged,
300
+ // ★P2 真增量读数(T2-1):真实 embedder 输入数,与"按返回计数推断"无关。
301
+ embedded: built.embedded,
302
+ reusedById: built.reusedById,
303
+ reusedByHash: built.reusedByHash,
304
+ recovered: recovered || undefined,
305
+ engineMismatch: engineMismatch || undefined,
306
+ l0IndexVersion: built.fileObj.l0IndexVersion,
307
+ }
308
+ } catch (e) {
309
+ return { ok: false, error: String(e && e.message ? e.message : e) }
310
+ }
311
+ },
312
+
313
+ /** 显式移除失效条目。ids 中不存在的自动忽略。 */
314
+ async remove({ path, ids } = {}) {
315
+ try {
316
+ const prev = load({ path })
317
+ if (!prev.ok) return { ok: false, error: 'index not loadable: ' + (prev.reason || '?'), removed: 0 }
318
+ const drop = new Set((Array.isArray(ids) ? ids : []).map(String))
319
+ const kept = prev.entries.filter((e) => !drop.has(e.id))
320
+ const ts = nowMs()
321
+ const fileObj = {
322
+ schemaVersion: L0_INDEX_SCHEMA_VERSION,
323
+ l0IndexVersion: computeL0IndexVersionPre(kept),
324
+ updatedAt: ts,
325
+ entries: kept,
326
+ }
327
+ await writeIndex(path, fileObj)
328
+ return {
329
+ ok: true,
330
+ removed: prev.entries.length - kept.length,
331
+ count: kept.length,
332
+ l0IndexVersion: fileObj.l0IndexVersion,
333
+ }
334
+ } catch (e) {
335
+ return { ok: false, error: String(e && e.message ? e.message : e), removed: 0 }
336
+ }
337
+ },
338
+
339
+ /** fail-soft 读取(见 load)。 */
340
+ load,
341
+
342
+ /** 索引概况(不修改任何状态)。含引擎身份读数(T2-9:可供向导/诊断判断"当前引擎的索引是否就绪")。 */
343
+ status({ path } = {}) {
344
+ const r = load({ path })
345
+ if (!r.ok) return { ok: false, count: 0, path, reason: r.reason, engineIdentity: engineIdentity || null, engineMatch: r.reason === 'engine-mismatch' ? false : null }
346
+ return { ok: true, count: r.entries.length, path, l0IndexVersion: r.l0IndexVersion, updatedAt: r.updatedAt, engineIdentity: engineIdentity || null, engineMatch: true }
347
+ },
348
+ }
349
+ }