dsh-memu-memory 1.1.2 → 1.2.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 (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,407 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import {
5
+ getEmbeddingCatalog,
6
+ getEmbeddingModel,
7
+ readOnnxGraphHeader,
8
+ resolveEmbeddingSources,
9
+ validateEmbeddingManifest,
10
+ } from './embedding-catalog.js'
11
+
12
+ // §14.4/§14.7 受管隔离下载器:
13
+ // staging → bytes/sha256 校验 → 模型 smoke(图头 + config/tokenizer 结构)→ 原子安装。
14
+ // 硬约束:
15
+ // 1. 只有用户在 UI 里明确确认(confirmed: true)才会联网;没有确认就绝不发请求。
16
+ // 2. 只从 manifest 里显式固定的 repo+revision 生成 URL;没有固定 revision 的来源不下载。
17
+ // 3. 失败、取消、hash 不符、smoke 不过一律不改动已安装目录——它代表"当前的 profiles",
18
+ // 写 active 是另一个模块的事,本模块永远不写 active/profile 指针(§14.8)。
19
+ // 4. 不做断点续传:重下必须从零重新验总 hash(§14.4 第 4 条),半成品不出 staging。
20
+
21
+ export const DOWNLOAD_STATES = Object.freeze(['unknown', 'absent', 'downloading', 'verifying', 'installed', 'failed', 'cancelled'])
22
+
23
+ const INSTALL_RECORD = 'installed.json'
24
+ const STAGING_DIR = '.staging'
25
+ const TRASH_DIR = '.trash'
26
+ const DEFAULT_MAX_FILE_BYTES = 512 * 1024 * 1024
27
+
28
+ function fail(code, message, extra) {
29
+ const error = new Error(message)
30
+ error.code = code
31
+ if (extra) Object.assign(error, extra)
32
+ return error
33
+ }
34
+
35
+ function isAbort(error, signal) {
36
+ return Boolean(signal?.aborted) || error?.name === 'AbortError' || error?.code === 'ABORT_ERR' || error?.code === 'UND_ERR_ABORTED' || error?.code === 'EMBEDDING_DOWNLOAD_ABORTED'
37
+ }
38
+
39
+ function readJsonFile(file) {
40
+ try {
41
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ function writeJsonAtomic(file, value) {
48
+ fs.mkdirSync(path.dirname(file), { recursive: true })
49
+ const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`
50
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: 0o600, flag: 'wx' })
51
+ fs.renameSync(temporary, file)
52
+ }
53
+
54
+ /**
55
+ * 默认模型 smoke:不需要 onnxruntime。读 config/tokenizer/图头,核对 manifest 声明的
56
+ * 维度、最大长度、输入输出名与 dtype、opset、自包含性、词表规模。真实推理 smoke 要等
57
+ * ORT 装好后再加(§14.11),这里不假装它已经跑过。
58
+ */
59
+ export async function verifyStagedArtifacts({ entry, dir }) {
60
+ const checks = []
61
+ const fileOf = role => (entry.files || []).find(file => file.role === role) || null
62
+ const bytesOf = role => {
63
+ const file = fileOf(role)
64
+ if (!file) return null
65
+ return fs.readFileSync(path.join(dir, ...String(file.path).split('/')))
66
+ }
67
+
68
+ const configBytes = bytesOf('config')
69
+ if (!configBytes) throw new Error('staged artifact has no config.json')
70
+ const config = JSON.parse(configBytes.toString('utf8'))
71
+ if (config.hidden_size !== entry.dimension) throw new Error(`config.hidden_size ${config.hidden_size} does not match manifest dimension ${entry.dimension}`)
72
+ checks.push('config.hidden_size=' + config.hidden_size)
73
+ if (entry.maxTokens !== null && config.max_position_embeddings && config.max_position_embeddings !== entry.maxTokens) {
74
+ throw new Error(`config.max_position_embeddings ${config.max_position_embeddings} does not match manifest maxTokens ${entry.maxTokens}`)
75
+ }
76
+ if (config.max_position_embeddings) checks.push('config.max_position_embeddings=' + config.max_position_embeddings)
77
+
78
+ const vocabBytes = bytesOf('vocab')
79
+ if (vocabBytes && Number.isInteger(config.vocab_size)) {
80
+ const vocabLines = vocabBytes.toString('utf8').split(/\r?\n/).filter(line => line.length > 0).length
81
+ if (vocabLines !== config.vocab_size) throw new Error(`vocab.txt has ${vocabLines} entries but config.vocab_size is ${config.vocab_size}`)
82
+ checks.push('vocab.txt=' + vocabLines)
83
+ }
84
+
85
+ const tokenizerBytes = bytesOf('tokenizer')
86
+ if (tokenizerBytes) {
87
+ const tokenizer = JSON.parse(tokenizerBytes.toString('utf8'))
88
+ const vocab = tokenizer?.model?.vocab
89
+ if (!vocab || typeof vocab !== 'object' || Object.keys(vocab).length < 2) throw new Error('tokenizer.json has no usable model.vocab')
90
+ checks.push('tokenizer.vocab=' + Object.keys(vocab).length)
91
+ }
92
+
93
+ const poolingBytes = bytesOf('pooling-config')
94
+ if (poolingBytes) {
95
+ const pooling = JSON.parse(poolingBytes.toString('utf8'))
96
+ if (entry.pooling === 'cls' && pooling.pooling_mode_cls_token !== true) throw new Error('pooling config is not CLS pooling')
97
+ if (Number.isInteger(pooling.word_embedding_dimension) && pooling.word_embedding_dimension !== entry.dimension) {
98
+ throw new Error(`pooling word_embedding_dimension ${pooling.word_embedding_dimension} does not match dimension ${entry.dimension}`)
99
+ }
100
+ checks.push('pooling=' + entry.pooling)
101
+ }
102
+
103
+ const graphFile = fileOf('graph')
104
+ if (!graphFile) throw new Error('staged artifact has no graph file')
105
+ const graphBytes = bytesOf('graph')
106
+ const header = readOnnxGraphHeader(graphBytes)
107
+ if (!header.selfContained) throw new Error('graph references external data or has no initializer bytes; this build only installs self-contained graphs')
108
+ checks.push('graph.selfContained=true', 'graph.initializerBytes=' + header.initializerBytes)
109
+ const declared = entry.graph || {}
110
+ const declaredInputs = (declared.inputs || []).map(io => io.name).sort()
111
+ const actualInputs = header.inputs.map(io => io.name).sort()
112
+ if (declaredInputs.join(',') !== actualInputs.join(',')) {
113
+ throw new Error(`graph inputs ${actualInputs.join(',')} do not match manifest ${declaredInputs.join(',')}`)
114
+ }
115
+ for (const io of declared.inputs || []) {
116
+ const actual = header.inputs.find(item => item.name === io.name)
117
+ if (actual.dtype !== io.dtype) throw new Error(`graph input ${io.name} dtype ${actual.dtype} does not match manifest ${io.dtype}`)
118
+ }
119
+ const declaredOutput = (declared.outputs || [])[0]
120
+ const actualOutput = header.outputs[0]
121
+ if (!actualOutput) throw new Error('graph has no outputs')
122
+ if (declaredOutput && actualOutput.name !== declaredOutput.name) throw new Error(`graph output ${actualOutput.name} does not match manifest ${declaredOutput.name}`)
123
+ if (actualOutput.dtype !== 'float32') throw new Error(`graph output dtype ${actualOutput.dtype} is not float32`)
124
+ const lastDim = Array.isArray(actualOutput.shape) ? actualOutput.shape[actualOutput.shape.length - 1] : null
125
+ if (lastDim !== entry.dimension) throw new Error(`graph output last dim ${lastDim} does not match dimension ${entry.dimension}`)
126
+ checks.push('graph.output=' + actualOutput.name + ':' + actualOutput.dtype + '[' + lastDim + ']')
127
+ if (Number.isInteger(declared.opset)) {
128
+ const defaultOpset = header.opsets.find(opset => !opset.domain)
129
+ if (!defaultOpset || defaultOpset.version !== declared.opset) {
130
+ throw new Error(`graph opset ${defaultOpset?.version} does not match manifest ${declared.opset}`)
131
+ }
132
+ checks.push('graph.opset=' + defaultOpset.version)
133
+ }
134
+ return { ok: true, checks }
135
+ }
136
+
137
+ /**
138
+ * createEmbeddingDownloadManager({ root, catalog?, fetchImpl?, verifyModel? })
139
+ * root: 本插件受管模型目录(复用 paths.js 的数据目录,不要放进宿主 app.asar)。
140
+ */
141
+ export function createEmbeddingDownloadManager(options = {}) {
142
+ const { root, fetchImpl = fetch, verifyModel, maxFileBytes = DEFAULT_MAX_FILE_BYTES, now = () => new Date().toISOString() } = options
143
+ if (typeof root !== 'string' || !root.trim()) throw new TypeError('createEmbeddingDownloadManager requires a root directory')
144
+ const baseDir = path.resolve(root)
145
+ const extraCatalog = Array.isArray(options.catalog) ? options.catalog : []
146
+ const jobs = new Map()
147
+ // 失败/取消结果单独记账:jobs 只表示「在途」,所以重试不会被误判成 BUSY,
148
+ // 但 UI 仍能读到上一次的失败原因(§14.7 的 failed 状态)。
149
+ const outcomes = new Map()
150
+
151
+ const lookup = id => extraCatalog.find(entry => entry && entry.id === id) || getEmbeddingModel(id)
152
+
153
+ function insideRoot(target) {
154
+ const resolved = path.resolve(target)
155
+ if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) {
156
+ throw fail('EMBEDDING_PATH_ESCAPE', `refusing to touch a path outside the model root: ${resolved}`)
157
+ }
158
+ return resolved
159
+ }
160
+
161
+ const modelDir = id => insideRoot(path.join(baseDir, id))
162
+ const recordPath = id => path.join(modelDir(id), INSTALL_RECORD)
163
+
164
+ function readInstall(entry) {
165
+ const dir = modelDir(entry.id)
166
+ const record = readJsonFile(recordPath(entry.id))
167
+ if (!record || typeof record !== 'object') return null
168
+ if (record.id !== entry.id) return { ok: false, code: 'EMBEDDING_INSTALL_MISMATCH' }
169
+ const files = Array.isArray(record.files) ? record.files : []
170
+ if (!files.length) return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' }
171
+ for (const file of files) {
172
+ const target = path.join(dir, ...String(file.path).split('/'))
173
+ let stat
174
+ try { stat = fs.statSync(target) } catch { return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' } }
175
+ if (!stat.isFile() || stat.size !== file.bytes) return { ok: false, code: 'EMBEDDING_INSTALL_INCOMPLETE' }
176
+ }
177
+ return { ok: true, record }
178
+ }
179
+
180
+ function deepVerify(entry, record) {
181
+ const dir = modelDir(entry.id)
182
+ for (const file of record.files || []) {
183
+ const target = path.join(dir, ...String(file.path).split('/'))
184
+ let hash
185
+ try { hash = crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex') } catch { return false }
186
+ if (hash !== file.sha256) return false
187
+ }
188
+ return true
189
+ }
190
+
191
+ function getStatus(id, statusOptions = {}) {
192
+ const key = typeof id === 'string' ? id : String(id || '')
193
+ const entry = lookup(key)
194
+ if (!entry) {
195
+ return { id: key, known: false, state: 'unknown', installed: false, active: false, downloadable: false, error: { code: 'EMBEDDING_MODEL_UNKNOWN', message: `unknown embedding model: ${key}` } }
196
+ }
197
+ const base = {
198
+ id: entry.id,
199
+ known: true,
200
+ availability: entry.availability,
201
+ downloadable: entry.availability === 'available',
202
+ active: false, // 下载器永远不激活 profile(§14.8);active 由迁移模块负责
203
+ dimension: entry.dimension,
204
+ maxTokens: entry.maxTokens,
205
+ pooling: entry.pooling,
206
+ normalization: entry.normalization,
207
+ totalBytes: entry.totalBytes,
208
+ fingerprint: entry.profileFingerprint,
209
+ license: entry.license?.base || null,
210
+ pendingReasons: [...(entry.pendingReasons || [])],
211
+ }
212
+ const job = jobs.get(entry.id)
213
+ if (job && (job.state === 'downloading' || job.state === 'verifying')) {
214
+ return { ...base, state: job.state, installed: false, provider: job.provider, revision: job.revision, startedAt: job.startedAt, progress: { ...job.progress } }
215
+ }
216
+ const install = readInstall(entry)
217
+ if (install?.ok) {
218
+ return {
219
+ ...base,
220
+ state: 'installed',
221
+ installed: true,
222
+ provider: install.record.provider || null,
223
+ revision: install.record.revision || null,
224
+ installedAt: install.record.installedAt || null,
225
+ fingerprintMatches: install.record.fingerprint === entry.profileFingerprint,
226
+ checks: Array.isArray(install.record.checks) ? [...install.record.checks] : [],
227
+ verified: statusOptions.deep ? deepVerify(entry, install.record) : null,
228
+ }
229
+ }
230
+ if (install && !install.ok) {
231
+ return { ...base, state: 'failed', installed: false, error: { code: install.code, message: 'existing install record does not match the files on disk' } }
232
+ }
233
+ const outcome = outcomes.get(entry.id)
234
+ if (outcome) return { ...base, state: outcome.state, installed: false, error: outcome.error || null }
235
+ return { ...base, state: 'absent', installed: false }
236
+ }
237
+
238
+ async function download(id, downloadOptions = {}) {
239
+ const { confirmed = false, signal } = downloadOptions || {}
240
+ const entry = lookup(typeof id === 'string' ? id : String(id || ''))
241
+ if (!entry) throw fail('EMBEDDING_MODEL_UNKNOWN', `unknown embedding model: ${id}`)
242
+ const report = validateEmbeddingManifest(entry)
243
+ if (!report.ok) {
244
+ throw fail('EMBEDDING_MANIFEST_INVALID', `${entry.id} manifest is not installable: ${report.errors.map(item => item.code).join(', ')}`, { errors: report.errors })
245
+ }
246
+ if (entry.availability !== 'available') {
247
+ throw fail('EMBEDDING_MODEL_UNAVAILABLE', `${entry.id} has no verified artifact; nothing will be downloaded`, { pendingReasons: [...(entry.pendingReasons || [])] })
248
+ }
249
+ if (confirmed !== true) {
250
+ throw fail('EMBEDDING_CONFIRMATION_REQUIRED', 'downloading a model needs explicit user confirmation: download(id, { confirmed: true })')
251
+ }
252
+ if (jobs.has(entry.id)) throw fail('EMBEDDING_DOWNLOAD_BUSY', `${entry.id} is already downloading`)
253
+ if (signal?.aborted) throw fail('EMBEDDING_DOWNLOAD_ABORTED', `download of ${entry.id} was aborted before it started`)
254
+ const resolved = resolveEmbeddingSources(entry)
255
+ const source = resolved.primary
256
+ if (!source) throw fail('EMBEDDING_SOURCE_UNAVAILABLE', `no source with a pinned revision is available for ${entry.id}`, { skipped: resolved.skipped })
257
+
258
+ const controller = new AbortController()
259
+ const onAbort = () => controller.abort()
260
+ signal?.addEventListener('abort', onAbort, { once: true })
261
+ const job = {
262
+ id: entry.id,
263
+ state: 'downloading',
264
+ provider: source.provider,
265
+ revision: source.revision,
266
+ startedAt: now(),
267
+ controller,
268
+ progress: { files: source.files.length, done: 0, bytes: 0, totalBytes: entry.totalBytes, current: null },
269
+ }
270
+ jobs.set(entry.id, job)
271
+ outcomes.delete(entry.id)
272
+ let staging = null
273
+ let installed = false
274
+ try {
275
+ const stagingRoot = insideRoot(path.join(baseDir, STAGING_DIR))
276
+ fs.mkdirSync(stagingRoot, { recursive: true })
277
+ staging = fs.mkdtempSync(path.join(stagingRoot, `${entry.id}-`))
278
+ for (const file of source.files) {
279
+ if (controller.signal.aborted) throw fail('EMBEDDING_DOWNLOAD_ABORTED', `download of ${entry.id} was aborted`)
280
+ if (!Number.isInteger(file.bytes) || file.bytes < 0) throw fail('EMBEDDING_MANIFEST_INVALID', `file ${file.path} has no exact byte count`)
281
+ if (file.bytes > maxFileBytes) throw fail('EMBEDDING_FILE_TOO_LARGE', `file ${file.path} declares ${file.bytes} bytes, above the ${maxFileBytes} byte cap`)
282
+ const target = path.join(staging, ...String(file.path).split('/'))
283
+ if (!path.resolve(target).startsWith(path.resolve(staging) + path.sep)) throw fail('EMBEDDING_PATH_ESCAPE', `file ${file.path} escapes the staging directory`)
284
+ fs.mkdirSync(path.dirname(target), { recursive: true })
285
+ job.progress.current = file.path
286
+ let response
287
+ try {
288
+ response = await fetchImpl(file.url, { signal: controller.signal, redirect: 'follow', headers: { 'accept-encoding': 'identity' } })
289
+ } catch (error) {
290
+ if (isAbort(error, controller.signal)) throw fail('EMBEDDING_DOWNLOAD_ABORTED', `download of ${entry.id} was aborted`, { cause: error })
291
+ throw fail('EMBEDDING_NETWORK_ERROR', `cannot fetch ${file.path} from ${source.provider}: ${error?.message || error}`, { cause: error })
292
+ }
293
+ if (!response || !response.ok) {
294
+ throw fail('EMBEDDING_HTTP_ERROR', `${source.provider} returned ${response?.status ?? 'no response'} for ${file.path}`)
295
+ }
296
+ const declaredLength = Number(response.headers?.get?.('content-length'))
297
+ if (Number.isFinite(declaredLength) && declaredLength !== file.bytes) {
298
+ throw fail('EMBEDDING_SIZE_MISMATCH', `${file.path} content-length ${declaredLength} does not match the pinned ${file.bytes} bytes`)
299
+ }
300
+ const hash = crypto.createHash('sha256')
301
+ let size = 0
302
+ const fd = fs.openSync(target, 'wx', 0o600)
303
+ try {
304
+ const body = response.body
305
+ if (body && typeof body[Symbol.asyncIterator] === 'function') {
306
+ for await (const chunk of body) {
307
+ if (controller.signal.aborted) throw fail('EMBEDDING_DOWNLOAD_ABORTED', `download of ${entry.id} was aborted`)
308
+ size += chunk.length
309
+ if (size > file.bytes) throw fail('EMBEDDING_SIZE_MISMATCH', `${file.path} exceeded the pinned ${file.bytes} bytes`)
310
+ hash.update(chunk)
311
+ fs.writeSync(fd, chunk)
312
+ job.progress.bytes += chunk.length
313
+ }
314
+ } else {
315
+ const buffer = Buffer.from(await response.arrayBuffer())
316
+ size = buffer.length
317
+ if (size > file.bytes) throw fail('EMBEDDING_SIZE_MISMATCH', `${file.path} exceeded the pinned ${file.bytes} bytes`)
318
+ hash.update(buffer)
319
+ fs.writeSync(fd, buffer)
320
+ job.progress.bytes += buffer.length
321
+ }
322
+ } finally {
323
+ fs.closeSync(fd)
324
+ }
325
+ if (size !== file.bytes) throw fail('EMBEDDING_SIZE_MISMATCH', `${file.path} is ${size} bytes, expected ${file.bytes}`)
326
+ const digest = hash.digest('hex')
327
+ if (digest !== file.sha256) throw fail('EMBEDDING_HASH_MISMATCH', `${file.path} sha256 ${digest} does not match the pinned ${file.sha256}`)
328
+ job.progress.done += 1
329
+ }
330
+ job.state = 'verifying'
331
+ job.progress.current = null
332
+ const verifier = typeof verifyModel === 'function' ? verifyModel : verifyStagedArtifacts
333
+ let smoke
334
+ try {
335
+ smoke = await verifier({ id: entry.id, dir: staging, entry, manifest: entry, provider: source.provider, revision: source.revision })
336
+ } catch (error) {
337
+ if (isAbort(error, controller.signal)) throw fail('EMBEDDING_DOWNLOAD_ABORTED', `download of ${entry.id} was aborted`, { cause: error })
338
+ throw fail('EMBEDDING_SMOKE_FAILED', `model smoke failed for ${entry.id}: ${error?.message || error}`, { cause: error })
339
+ }
340
+ if (smoke && smoke.ok === false) throw fail('EMBEDDING_SMOKE_FAILED', `model smoke rejected ${entry.id}`, { smoke })
341
+
342
+ const record = {
343
+ id: entry.id,
344
+ revision: source.revision,
345
+ repo: source.repo,
346
+ provider: source.provider,
347
+ fingerprint: entry.profileFingerprint,
348
+ installedAt: now(),
349
+ totalBytes: entry.totalBytes,
350
+ files: (entry.files || []).map(file => ({ path: file.path, role: file.role, bytes: file.bytes, sha256: file.sha256 })),
351
+ checks: Array.isArray(smoke?.checks) ? smoke.checks : [],
352
+ verifiedBy: typeof verifyModel === 'function' ? 'custom-verify-model' : 'sha256+structural-smoke',
353
+ // 明确记录:安装≠启用。active profile 由迁移模块的受验证切换流程负责。
354
+ active: false,
355
+ }
356
+ writeJsonAtomic(path.join(staging, INSTALL_RECORD), record)
357
+
358
+ const finalDir = modelDir(entry.id)
359
+ const previousExists = fs.existsSync(finalDir)
360
+ let trash = null
361
+ if (previousExists) {
362
+ const trashRoot = insideRoot(path.join(baseDir, TRASH_DIR))
363
+ fs.mkdirSync(trashRoot, { recursive: true })
364
+ trash = path.join(trashRoot, `${entry.id}-${crypto.randomUUID()}`)
365
+ fs.renameSync(finalDir, trash)
366
+ }
367
+ try {
368
+ fs.renameSync(staging, finalDir)
369
+ staging = null
370
+ installed = true
371
+ } catch (error) {
372
+ if (trash) { try { fs.renameSync(trash, finalDir) } catch {} }
373
+ throw fail('EMBEDDING_INSTALL_FAILED', `cannot move the verified artifact into place: ${error?.message || error}`, { cause: error })
374
+ }
375
+ if (trash) { try { fs.rmSync(trash, { recursive: true, force: true }) } catch {} }
376
+ job.state = 'installed'
377
+ jobs.delete(entry.id)
378
+ outcomes.delete(entry.id)
379
+ return getStatus(entry.id)
380
+ } catch (error) {
381
+ const aborted = isAbort(error, controller.signal)
382
+ const code = aborted ? 'EMBEDDING_DOWNLOAD_ABORTED' : (typeof error?.code === 'string' && error.code.startsWith('EMBEDDING_') ? error.code : 'EMBEDDING_DOWNLOAD_FAILED')
383
+ const failed = code === error?.code ? error : fail(code, `download of ${entry.id} failed: ${error?.message || error}`, { cause: error })
384
+ job.state = aborted ? 'cancelled' : 'failed'
385
+ job.error = { code, message: String(failed.message || error) }
386
+ jobs.delete(entry.id)
387
+ outcomes.set(entry.id, { state: job.state, error: { ...job.error } })
388
+ throw failed
389
+ } finally {
390
+ signal?.removeEventListener('abort', onAbort)
391
+ if (staging && !installed) { try { fs.rmSync(staging, { recursive: true, force: true }) } catch {} }
392
+ }
393
+ }
394
+
395
+ function dispose() {
396
+ // 只中止在途下载;状态与错误原因留给 getStatus 展示。
397
+ for (const job of jobs.values()) { try { job.controller?.abort() } catch {} }
398
+ }
399
+
400
+ return {
401
+ root: baseDir,
402
+ catalogIds: [...getEmbeddingCatalog().map(entry => entry.id), ...extraCatalog.map(entry => entry?.id).filter(Boolean)],
403
+ getStatus,
404
+ download,
405
+ dispose,
406
+ }
407
+ }