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.
- package/AGENTS.md +8 -6
- package/README.md +50 -5
- package/README.zh.md +49 -5
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +6 -4
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +40 -11
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -366
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +635 -0
- package/lib/client.js +728 -58
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +115 -0
- package/lib/curator.js +81 -37
- package/lib/cycle.js +43 -6
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +232 -103
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +267 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +599 -57
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/mood.js +268 -39
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +450 -29
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +137 -172
- package/lib/tools.js +58 -7
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +968 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +7 -5
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +537 -0
- package/scripts/check-all.mjs +29 -1
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +212 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +536 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +208 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +733 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +164 -0
- 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
|
+
}
|