@modusensus/dsh-mneme 0.8.14 → 0.8.16

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 (279) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +660 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +691 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +275 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/DISTILL.md +162 -0
  12. package/docs/ENTITIES.md +245 -0
  13. package/docs/LOCAL_MODEL.md +222 -0
  14. package/docs/SEMANTIC.md +256 -0
  15. package/docs/SLEEP.md +164 -0
  16. package/docs/STORAGE.md +98 -0
  17. package/lib/api-standalone.js +656 -673
  18. package/lib/api.js +1605 -1433
  19. package/lib/bootstrap.js +211 -211
  20. package/lib/client.js +6354 -5296
  21. package/lib/commands.js +95 -95
  22. package/lib/config.js +947 -843
  23. package/lib/content-hash.js +54 -54
  24. package/lib/continuity.js +220 -220
  25. package/lib/document-index.js +94 -94
  26. package/lib/document.js +303 -303
  27. package/lib/dream/clustering.js +118 -118
  28. package/lib/dream/decisions.js +694 -674
  29. package/lib/dream/narratives.js +44 -44
  30. package/lib/dream/sleep.js +819 -819
  31. package/lib/dream.js +1503 -1501
  32. package/lib/embedding.js +177 -177
  33. package/lib/entities/extractor.js +252 -252
  34. package/lib/graph/anchoring.js +179 -179
  35. package/lib/guide.js +74 -74
  36. package/lib/heat.js +144 -144
  37. package/lib/hot-memory.js +108 -46
  38. package/lib/index.js +552 -503
  39. package/lib/inject.js +428 -450
  40. package/lib/lang.js +248 -229
  41. package/lib/local-embedder.js +358 -358
  42. package/lib/maintenance.js +162 -142
  43. package/lib/mirror.js +330 -330
  44. package/lib/organize.js +450 -450
  45. package/lib/peak-hours.js +117 -117
  46. package/lib/placement.js +13 -0
  47. package/lib/pre-inject-gate.js +340 -0
  48. package/lib/quality-filter.js +126 -126
  49. package/lib/recall-stats.js +190 -175
  50. package/lib/reclaim-http.js +150 -0
  51. package/lib/reranker.js +269 -269
  52. package/lib/runtime/adopt.js +176 -176
  53. package/lib/runtime/closure.js +172 -172
  54. package/lib/runtime/download.js +247 -247
  55. package/lib/runtime/layout.js +265 -265
  56. package/lib/runtime/loader.js +252 -252
  57. package/lib/runtime/model-fetch.js +361 -361
  58. package/lib/runtime/provision.js +237 -237
  59. package/lib/runtime/tarball.js +130 -130
  60. package/lib/runtime/verify.js +223 -204
  61. package/lib/safe-slice.js +18 -0
  62. package/lib/scope.js +146 -146
  63. package/lib/search/adaptive.js +60 -60
  64. package/lib/search/bm25.js +96 -96
  65. package/lib/semantic.js +229 -229
  66. package/lib/sensitive-scan.js +209 -209
  67. package/lib/serve.js +215 -215
  68. package/lib/service.js +2688 -2605
  69. package/lib/settings.js +528 -511
  70. package/lib/store.js +3196 -3007
  71. package/lib/summarize.js +1039 -856
  72. package/lib/tools.js +743 -737
  73. package/lib/vector-index.js +110 -110
  74. package/lib/version-check.js +122 -122
  75. package/lib/write-admission.js +402 -402
  76. package/locale/en.json +6 -6
  77. package/locale/zh.json +6 -6
  78. package/package.json +118 -118
  79. package/runtime-manifest.json +526 -526
  80. package/scripts/benchmark-embed.js +201 -201
  81. package/scripts/benchmark-recall.js +249 -249
  82. package/scripts/benchmark-rerank.js +166 -166
  83. package/scripts/build-runtime-manifest.mjs +224 -224
  84. package/scripts/changelog-prep.mjs +36 -36
  85. package/scripts/check-sync.js +42 -42
  86. package/scripts/e2e-dsh.js +220 -220
  87. package/scripts/mneme-runtime.mjs +277 -277
  88. package/scripts/stress-dsh.js +255 -255
  89. package/scripts/sync-lib.js +47 -47
  90. package/scripts/sync-test-badge.mjs +69 -69
  91. package/scripts/test-count-sync.mjs +44 -44
  92. package/src/api-standalone.js +656 -673
  93. package/src/api.js +1605 -1433
  94. package/src/bootstrap.js +211 -211
  95. package/src/commands.js +95 -95
  96. package/src/config.js +947 -843
  97. package/src/content-hash.js +54 -54
  98. package/src/continuity.js +220 -220
  99. package/src/document-index.js +94 -94
  100. package/src/document.js +303 -303
  101. package/src/dream/clustering.js +118 -118
  102. package/src/dream/decisions.js +694 -674
  103. package/src/dream/narratives.js +44 -44
  104. package/src/dream/sleep.js +819 -819
  105. package/src/dream.js +1503 -1501
  106. package/src/embedding.js +177 -177
  107. package/src/entities/extractor.js +252 -252
  108. package/src/graph/anchoring.js +179 -179
  109. package/src/guide.js +74 -74
  110. package/src/heat.js +144 -144
  111. package/src/hot-memory.js +108 -46
  112. package/src/index.js +552 -503
  113. package/src/inject.js +428 -450
  114. package/src/lang.js +248 -229
  115. package/src/local-embedder.js +358 -358
  116. package/src/maintenance.js +162 -142
  117. package/src/mirror.js +330 -330
  118. package/src/organize.js +450 -450
  119. package/src/peak-hours.js +117 -117
  120. package/src/placement.js +13 -0
  121. package/src/pre-inject-gate.js +340 -0
  122. package/src/quality-filter.js +126 -126
  123. package/src/recall-stats.js +190 -175
  124. package/src/reclaim-http.js +150 -0
  125. package/src/reranker.js +269 -269
  126. package/src/runtime/adopt.js +176 -176
  127. package/src/runtime/closure.js +172 -172
  128. package/src/runtime/download.js +247 -247
  129. package/src/runtime/layout.js +265 -265
  130. package/src/runtime/loader.js +252 -252
  131. package/src/runtime/model-fetch.js +361 -361
  132. package/src/runtime/provision.js +237 -237
  133. package/src/runtime/tarball.js +130 -130
  134. package/src/runtime/verify.js +223 -204
  135. package/src/safe-slice.js +18 -0
  136. package/src/scope.js +146 -146
  137. package/src/search/adaptive.js +60 -60
  138. package/src/search/bm25.js +96 -96
  139. package/src/semantic.js +229 -229
  140. package/src/sensitive-scan.js +209 -209
  141. package/src/serve.js +215 -215
  142. package/src/service.js +2688 -2605
  143. package/src/settings.js +528 -511
  144. package/src/store.js +3196 -3007
  145. package/src/summarize.js +1039 -856
  146. package/src/tools.js +743 -737
  147. package/src/vector-index.js +110 -110
  148. package/src/version-check.js +122 -122
  149. package/src/write-admission.js +402 -402
  150. package/test/adaptive-inject-budget.test.js +85 -85
  151. package/test/api-routes-gaps.test.js +899 -218
  152. package/test/api.test.js +1725 -1690
  153. package/test/audit.test.js +448 -448
  154. package/test/baseline-persist.test.js +248 -248
  155. package/test/benchmark.test.js +94 -94
  156. package/test/bootstrap.test.js +220 -220
  157. package/test/changelog-prep.test.js +60 -60
  158. package/test/client.test.js +1582 -1169
  159. package/test/clustering.test.js +100 -100
  160. package/test/commands.test.js +106 -106
  161. package/test/config.test.js +66 -66
  162. package/test/conflict-freeze.test.js +300 -300
  163. package/test/content-hash.test.js +204 -204
  164. package/test/continuity.test.js +333 -333
  165. package/test/contrast.test.js +314 -0
  166. package/test/document-dir.test.js +233 -233
  167. package/test/document.test.js +671 -671
  168. package/test/dream-archive-guardrail.test.js +116 -116
  169. package/test/dream-candidate-hybrid.test.js +281 -281
  170. package/test/dream-coverage-degrade.test.js +86 -86
  171. package/test/dream-failure-backoff.test.js +173 -173
  172. package/test/dream-merge-guardrail.test.js +219 -0
  173. package/test/dream-peak-hours.test.js +194 -194
  174. package/test/dream.test.js +1385 -1383
  175. package/test/embedding-ready.test.js +196 -196
  176. package/test/entities.test.js +627 -547
  177. package/test/entity-recall.test.js +179 -179
  178. package/test/epistemic.test.js +325 -325
  179. package/test/fnew-0112.test.js +311 -311
  180. package/test/fnew-03.test.js +422 -422
  181. package/test/graph-anchoring.test.js +184 -184
  182. package/test/graph-api.test.js +175 -175
  183. package/test/graph-inject-budget.test.js +103 -103
  184. package/test/graph-passive-confirm.test.js +91 -91
  185. package/test/graph-weight-evolution.test.js +104 -104
  186. package/test/heat.test.js +175 -175
  187. package/test/helpers/dream-mock.js +82 -82
  188. package/test/helpers/peer-worker.mjs +25 -25
  189. package/test/helpers/runtime-source.js +60 -60
  190. package/test/helpers/tar-builder.js +80 -80
  191. package/test/helpers/write-admission-samples.js +308 -308
  192. package/test/hot-memory.test.js +211 -145
  193. package/test/inject-content-cap.test.js +91 -91
  194. package/test/inject-first-round-bm25.test.js +89 -89
  195. package/test/inject-heat.test.js +77 -77
  196. package/test/inject-parent-gate.test.js +106 -106
  197. package/test/inject-pools.test.js +194 -194
  198. package/test/inject-recall.test.js +144 -105
  199. package/test/inject-rotation.test.js +161 -161
  200. package/test/inject.test.js +443 -420
  201. package/test/lang.test.js +87 -87
  202. package/test/lastrunat-persist.test.js +101 -101
  203. package/test/lib-smoke.test.js +96 -96
  204. package/test/llm-audit.test.js +696 -598
  205. package/test/llm-message-source.test.js +207 -207
  206. package/test/local-embedder.test.js +292 -292
  207. package/test/maintenance.test.js +501 -391
  208. package/test/mcp-stdio.test.js +385 -385
  209. package/test/mirror-dirty.test.js +424 -424
  210. package/test/mirror-edit-digest.test.js +187 -187
  211. package/test/mirror-generation.test.js +499 -499
  212. package/test/mirror-types.test.js +208 -208
  213. package/test/mirror.test.js +249 -249
  214. package/test/narrative-bars.test.js +162 -162
  215. package/test/organize.test.js +418 -418
  216. package/test/peer-blockers.test.js +193 -193
  217. package/test/placement.test.js +36 -0
  218. package/test/policy-epoch.test.js +259 -259
  219. package/test/pre-inject-gate.test.js +444 -0
  220. package/test/quality-filter.test.js +186 -186
  221. package/test/reasoning-effort.test.js +752 -752
  222. package/test/recall-anti-update.test.js +82 -82
  223. package/test/recall-evals.test.js +253 -253
  224. package/test/recall-layer.test.js +326 -326
  225. package/test/recall-stats.test.js +274 -189
  226. package/test/receipt-chain.test.js +451 -451
  227. package/test/reclaim-http.test.js +209 -0
  228. package/test/reflection.test.js +226 -226
  229. package/test/reindex-backfill.test.js +62 -62
  230. package/test/reranker-single-logit.test.js +73 -73
  231. package/test/reranker.test.js +197 -197
  232. package/test/resident-status.test.js +83 -83
  233. package/test/runtime-adopt.test.js +206 -206
  234. package/test/runtime-cli.test.js +89 -89
  235. package/test/runtime-closure.test.js +193 -193
  236. package/test/runtime-download.test.js +302 -302
  237. package/test/runtime-layout.test.js +163 -163
  238. package/test/runtime-loader.test.js +255 -255
  239. package/test/runtime-manifest.test.js +159 -159
  240. package/test/runtime-model-fetch.test.js +348 -348
  241. package/test/runtime-provision.test.js +101 -101
  242. package/test/runtime-tarball.test.js +81 -81
  243. package/test/runtime-verify.test.js +319 -279
  244. package/test/scope-candidates.test.js +239 -239
  245. package/test/scope-explicit.test.js +452 -452
  246. package/test/scope-inject-soft.test.js +106 -106
  247. package/test/scope-retrieval.test.js +197 -197
  248. package/test/scope-storage.test.js +255 -255
  249. package/test/scope-strict.test.js +367 -367
  250. package/test/search-fusion.test.js +90 -90
  251. package/test/semantic.test.js +124 -124
  252. package/test/sensitive-scan.test.js +207 -207
  253. package/test/serve-bin.test.js +121 -121
  254. package/test/serve.test.js +196 -196
  255. package/test/service-search.test.js +174 -174
  256. package/test/service.test.js +410 -405
  257. package/test/settings.test.js +269 -261
  258. package/test/skip-invalid-restore.test.js +262 -262
  259. package/test/sleep-action-set.test.js +422 -422
  260. package/test/sleep-heat.test.js +126 -126
  261. package/test/sleep-scheduler-rearm.test.js +89 -89
  262. package/test/sleep.test.js +527 -527
  263. package/test/standalone-api.test.js +751 -751
  264. package/test/store-vector-cache.test.js +98 -98
  265. package/test/store.test.js +504 -396
  266. package/test/stress.test.js +209 -209
  267. package/test/summarize-occurred-at.test.js +127 -0
  268. package/test/summarize-reasoning-effort.test.js +189 -189
  269. package/test/summarize.test.js +1639 -1465
  270. package/test/supersede-state-tracking.test.js +394 -0
  271. package/test/test-count-sync.test.js +60 -60
  272. package/test/tools-dto-schema-matrix.test.js +325 -325
  273. package/test/tools-memory-get-schema.test.js +65 -65
  274. package/test/tools.test.js +428 -374
  275. package/test/updated-at-semantics.test.js +113 -113
  276. package/test/vector-index.test.js +221 -221
  277. package/test/version-check.test.js +152 -152
  278. package/test/webServer-optional.test.js +82 -82
  279. package/test/write-admission.test.js +668 -668
@@ -1,358 +1,358 @@
1
- // Fully-local embedding backends for dsh-mneme: ONNX via transformers.js,
2
- // Ollama's HTTP API, and the OpenAI-compatible HTTP API (extracted from the
3
- // old embedding.js logic). All classes share one interface so the orchestrator
4
- // can pick a backend by provider name and degrade gracefully on failure.
5
- // Methods throw on error — the caller decides the fallback chain.
6
- import { defaultModelCacheDir, defaultRuntimeDir } from "./runtime/layout.js";
7
- import { installResilientFetch } from "./runtime/model-fetch.js";
8
- import { loadTransformers } from "./runtime/loader.js";
9
-
10
- const DEFAULT_TIMEOUT_MS = 15000;
11
-
12
- /** djb2 — stable, fast fingerprint for a provider/model string. */
13
- function hashString(s) {
14
- let h = 5381;
15
- for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
16
- return h.toString(16);
17
- }
18
-
19
- /** Full provider+model fingerprint used for index-consistency checks. */
20
- function modelHash(model) {
21
- return `${model}#${hashString(model)}`;
22
- }
23
-
24
- /**
25
- * Model families whose embedding is the CLS token rather than a mean over tokens.
26
- *
27
- * BGE (BAAI/bge-*) is trained that way: the model's own `1_Pooling/config.json`
28
- * declares `pooling_mode_cls_token: true` / `pooling_mode_mean_tokens: false`, and
29
- * the official README says "select the last hidden state of the first token" plus
30
- * L2 normalization. transformers.js's `feature-extraction` defaults to `mean`, so
31
- * feeding it a BGE model silently produces slightly-off vectors — no error, just a
32
- * worse ranking. This table is the extension point: a family goes in only once its
33
- * pooling is confirmed against the model's own `1_Pooling` (or equivalent) config.
34
- */
35
- const CLS_POOLED_MODEL_PATTERNS = [/bge/i];
36
-
37
- /**
38
- * Resolve the pooling to embed with.
39
- *
40
- * `auto` (default) picks by model family — BGE → `cls`, everything else → `mean`,
41
- * so models that were never affected keep their exact previous behavior. An
42
- * explicit `cls` / `mean` wins over the family table.
43
- */
44
- export function resolvePooling(pooling, model) {
45
- const mode = String(pooling ?? "auto").trim().toLowerCase();
46
- if (mode === "cls" || mode === "mean") return mode;
47
- return CLS_POOLED_MODEL_PATTERNS.some((re) => re.test(String(model ?? ""))) ? "cls" : "mean";
48
- }
49
-
50
- /**
51
- * Lazy default loader: dynamic import keeps module load cheap.
52
- *
53
- * 经 runtime/loader.js 的三层解析取运行时(自管 payload 优先,其次宿主裸 specifier):
54
- * 这样 PR-B 把 @huggingface/transformers 从 dependencies 摘掉、pnpm 把宿主那份 prune
55
- * 掉之后,本地嵌入仍然可用;而在那之前,宿主那份照样能被第 ② 层兜住。
56
- * runtimeDir 从 options 里取走后再传 pipeline,免得把插件自己的配置项混进
57
- * transformers 的选项里。
58
- */
59
- async function defaultPipelineLoader(task, model, options) {
60
- const { runtimeDir, remoteHost, resilientModelDownload, logger, ...pipelineOptions } = options ?? {};
61
- const { module } = await loadTransformers({
62
- runtimeDir: runtimeDir || defaultRuntimeDir()
63
- });
64
- const { env, pipeline } = module;
65
- // issue #13: transformers.js's get_tokenizer_files() drops the caller's
66
- // cache_dir when it pre-checks tokenizer_config.json metadata, so the HEAD
67
- // request falls back to env.cacheDir and hits the network even when the
68
- // model is fully cached locally. Mirroring the cache_dir onto env.cacheDir
69
- // makes that pre-check resolve locally too — fully offline loading.
70
- if (pipelineOptions.cache_dir) env.cacheDir = pipelineOptions.cache_dir;
71
- // issue #188 补充:embedModelMirror 此前是死配置(定义了但全仓无人读取)。
72
- // transformers.js 不认 HF_ENDPOINT(那是 Python huggingface_hub 的变量),
73
- // 模型下载源由 env.remoteHost 决定——把镜像接到真正的开关上,否则
74
- // huggingface.co 不可达的网络连模型都下不下来。
75
- if (remoteHost) env.remoteHost = remoteHost;
76
- // issue #194:模型文件(几百 MB~1GB)下载中断后从断点续传。开关关 = env.fetch 一字不动。
77
- installResilientFetch(env, {
78
- enabled: resilientModelDownload === true,
79
- cacheDir: pipelineOptions.cache_dir,
80
- logger
81
- });
82
- return pipeline(task, model, pipelineOptions);
83
- }
84
-
85
- /** Flatten a transformers.js Tensor [batch, dim] into number[][]. */
86
- function tensorToRows(tensor) {
87
- const { data, dims } = tensor;
88
- const rowLen = dims[dims.length - 1] || 0;
89
- const rows = [];
90
- for (let i = 0; i < data.length; i += rowLen) {
91
- rows.push(Array.from(data.subarray(i, i + rowLen)));
92
- }
93
- // Single-text input may come back without the batch axis.
94
- if (rows.length === 0 && rowLen > 0) rows.push(Array.from(data));
95
- return rows;
96
- }
97
-
98
- /**
99
- * ONNX text embedder backed by transformers.js (onnxruntime-node underneath).
100
- * Runs fully offline; pooling is resolved per model family (BGE → CLS, else mean —
101
- * see resolvePooling) and vectors are L2-normalized. `engineFactory` is injectable
102
- * for tests.
103
- */
104
- export class LocalEmbedder {
105
- constructor(opts = {}) {
106
- this.model = opts.model || "Xenova/bge-small-zh-v1.5";
107
- this._dimension = opts.dimension || 512;
108
- this.device = opts.device || "cpu";
109
- this.batchSize = opts.batchSize || 8;
110
- this.cacheDir = String(opts.cacheDir ?? "").trim() || defaultModelCacheDir();
111
- // 自管运行时根目录(issue #131):空表示用默认位置,具体解析交给 loader。
112
- this.runtimeDir = String(opts.runtimeDir ?? "").trim();
113
- // issue #194:模型文件下载断点续传。只在显式传 true 时启用——直连构造的旧调用方
114
- // (含测试)保持裸 fetch 的现状,配置默认值由 index.js 传进来。
115
- this.resilientModelDownload = opts.resilientModelDownload === true;
116
- this.useDtype = opts.useDtype || "q8";
117
- // 池化:配置值(auto/cls/mean)→ 生效值(cls/mean)。解析结果既进 embed(),
118
- // 也进 modelHash —— 换池化就是换向量空间,旧索引必须被判失配后重建。
119
- this.poolingMode = String(opts.pooling ?? "auto").trim().toLowerCase();
120
- this.pooling = resolvePooling(this.poolingMode, this.model);
121
- // 模型镜像下载源(embedModelMirror,#188 补充):空 = transformers 默认。
122
- this.remoteHost = String(opts.remoteHost ?? "").trim();
123
- this.logger = opts.logger ?? null;
124
- // Test hook: replace the pipeline factory without touching modules.
125
- this.engineFactory = opts.engineFactory || defaultPipelineLoader;
126
- this.extractor = null;
127
- // issue #6: readiness flag for the service's scheduleEmbed gate. False until
128
- // init() succeeds, so "ready" in embedder is observable even pre-init.
129
- this.ready = false;
130
- }
131
-
132
- /** Load the model; throws when it cannot be loaded. Idempotent. */
133
- async init() {
134
- if (this.extractor) return this; // already initialized: no-op
135
- const options = {
136
- dtype: this.useDtype,
137
- device: this.device
138
- };
139
- if (this.cacheDir) options.cache_dir = this.cacheDir;
140
- // 传给注入的 engineFactory,由 defaultPipelineLoader 取走后交给三层解析。
141
- if (this.runtimeDir) options.runtimeDir = this.runtimeDir;
142
- if (this.remoteHost) options.remoteHost = this.remoteHost;
143
- options.resilientModelDownload = this.resilientModelDownload;
144
- options.logger = this.logger;
145
- this.extractor = await this.engineFactory("feature-extraction", this.model, options);
146
- this.ready = true; // service reads this to flush queued re-embeds
147
- this.logger?.info?.(
148
- `[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device}, pooling=${this.pooling})`
149
- );
150
- return this;
151
- }
152
-
153
- /** Embed many texts with the model's pooling; chunks at batchSize. */
154
- async embed(texts) {
155
- if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
156
- if (!this.extractor) throw new Error("LocalEmbedder not initialized");
157
- const out = [];
158
- for (let i = 0; i < texts.length; i += this.batchSize) {
159
- const chunk = texts.slice(i, i + this.batchSize);
160
- const tensor = await this.extractor(chunk, { pooling: this.pooling, normalize: true });
161
- out.push(...tensorToRows(tensor));
162
- }
163
- return out;
164
- }
165
-
166
- async embedSingle(text) {
167
- const rows = await this.embed([String(text)]);
168
- return rows[0];
169
- }
170
-
171
- get dimension() {
172
- return this._dimension;
173
- }
174
-
175
- get modelHash() {
176
- // Pooling is part of the vector space: a CLS index cannot be queried with mean
177
- // vectors (or vice versa). Keep the historical `model#hash` shape for `mean` so
178
- // indexes that were never affected are not invalidated needlessly; every other
179
- // pooling gets a fingerprint of its own, which makes the index-consistency gate
180
- // flag the stale mean-space index and rebuild it.
181
- return this.pooling === "mean"
182
- ? modelHash(this.model)
183
- : modelHash(`${this.model}@${this.pooling}`);
184
- }
185
-
186
- dispose() {
187
- try {
188
- this.extractor?.dispose?.();
189
- } catch {
190
- // best-effort: some engines free resources on GC
191
- }
192
- this.extractor = null;
193
- this.ready = false;
194
- }
195
- }
196
-
197
- /** Chunk texts into batches of at most `size`. */
198
- function chunk(texts, size) {
199
- const out = [];
200
- for (let i = 0; i < texts.length; i += size) out.push(texts.slice(i, i + size));
201
- return out;
202
- }
203
-
204
- /**
205
- * Ollama embedder over its native HTTP API. `dimension` is inferred from the
206
- * first response. init() verifies reachability and that the model exists.
207
- */
208
- export class OllamaEmbedder {
209
- constructor(opts = {}) {
210
- this.baseUrl = String(opts.baseUrl ?? "http://localhost:11434").trim().replace(/\/+$/, "");
211
- this.model = String(opts.model ?? "nomic-embed-text").trim();
212
- this.logger = opts.logger ?? null;
213
- this._dimension = null;
214
- // #118: async-init embedders expose `ready` so the service queues re-embeds
215
- // until init lands and the status card can show "initializing".
216
- this.ready = false;
217
- }
218
-
219
- async _post(body) {
220
- return fetch(`${this.baseUrl}/api/embeddings`, {
221
- method: "POST",
222
- headers: { "Content-Type": "application/json" },
223
- body: JSON.stringify(body),
224
- signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
225
- });
226
- }
227
-
228
- /** Probe the server with a tiny prompt; throws when unreachable/missing. */
229
- async init() {
230
- const res = await this._post({ model: this.model, prompt: "ping" });
231
- if (!res.ok) throw new Error(`Ollama ${this.model} unavailable: HTTP ${res.status}`);
232
- const body = await res.json();
233
- if (!Array.isArray(body?.embedding)) throw new Error(`Ollama ${this.model} returned no embedding`);
234
- this._dimension = body.embedding.length;
235
- this.ready = true;
236
- this.logger?.info?.(
237
- `[dsh-mneme] ollama embedder ready: ${this.model} (dim=${this._dimension})`
238
- );
239
- return this;
240
- }
241
-
242
- async embed(texts) {
243
- if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
244
- const out = [];
245
- for (const text of texts) out.push(await this.embedSingle(text));
246
- return out;
247
- }
248
-
249
- async embedSingle(text) {
250
- const res = await this._post({ model: this.model, prompt: String(text).slice(0, 8000) });
251
- if (!res.ok) throw new Error(`Ollama embed failed: HTTP ${res.status}`);
252
- const body = await res.json();
253
- const vec = body?.embedding;
254
- if (!Array.isArray(vec) || !vec.length) throw new Error("Ollama returned no embedding");
255
- if (this._dimension == null) this._dimension = vec.length;
256
- return Array.from(vec);
257
- }
258
-
259
- get dimension() {
260
- return this._dimension ?? 0;
261
- }
262
-
263
- get modelHash() {
264
- return modelHash(this.model);
265
- }
266
-
267
- dispose() {
268
- this._dimension = null;
269
- }
270
- }
271
-
272
- /**
273
- * OpenAI-compatible embedder (OpenAI, SiliconFlow, Zhipu, local proxies).
274
- * Backward-compatible behavior lifted from embedding.js, but batchable and
275
- * throwing on failure instead of returning null.
276
- */
277
- export class OpenAIEmbedder {
278
- constructor(opts = {}) {
279
- this.baseUrl = String(opts.baseUrl ?? "").trim().replace(/\/+$/, "");
280
- this.apiKey = String(opts.apiKey ?? "").trim();
281
- this.model = String(opts.model ?? "").trim();
282
- this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
283
- this.logger = opts.logger ?? null;
284
- this._dimension = null;
285
- // Accept both "https://host/v1" and a full path ending in /embeddings.
286
- this._url = /\/embeddings$/i.test(this.baseUrl)
287
- ? this.baseUrl
288
- : this.baseUrl ? `${this.baseUrl}/embeddings` : "";
289
- }
290
-
291
- async init() {
292
- if (!this._url || !this.apiKey || !this.model) {
293
- throw new Error("OpenAI embedder requires baseUrl, apiKey and model");
294
- }
295
- this.logger?.info?.(`[dsh-mneme] openai embedder ready: ${this.model}`);
296
- return this;
297
- }
298
-
299
- async embed(texts) {
300
- if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
301
- const out = [];
302
- for (const batch of chunk(texts, 32)) {
303
- const res = await fetch(this._url, {
304
- method: "POST",
305
- headers: {
306
- "Content-Type": "application/json",
307
- "Authorization": `Bearer ${this.apiKey}`
308
- },
309
- body: JSON.stringify({ model: this.model, input: batch.map((t) => String(t).slice(0, 8000)) }),
310
- signal: AbortSignal.timeout(this.timeoutMs)
311
- });
312
- if (!res.ok) throw new Error(`Embedding API failed: HTTP ${res.status}`);
313
- const body = await res.json();
314
- const list = body?.data;
315
- if (!Array.isArray(list) || list.length !== batch.length) {
316
- throw new Error("Embedding API returned unexpected payload");
317
- }
318
- for (const item of list) {
319
- const vec = item?.embedding;
320
- if (!Array.isArray(vec) || !vec.length) throw new Error("Embedding API returned empty vector");
321
- if (this._dimension == null) this._dimension = vec.length;
322
- out.push(Array.from(vec));
323
- }
324
- }
325
- return out;
326
- }
327
-
328
- async embedSingle(text) {
329
- const rows = await this.embed([String(text)]);
330
- return rows[0];
331
- }
332
-
333
- get dimension() {
334
- return this._dimension ?? 0;
335
- }
336
-
337
- get modelHash() {
338
- return modelHash(this.model);
339
- }
340
-
341
- dispose() {
342
- this._dimension = null;
343
- }
344
- }
345
-
346
- /** Pick a backend instance by provider name. Throws on unknown providers. */
347
- export function createEmbedderByProvider(provider, opts) {
348
- switch (String(provider ?? "").toLowerCase()) {
349
- case "local":
350
- return new LocalEmbedder(opts);
351
- case "ollama":
352
- return new OllamaEmbedder(opts);
353
- case "openai":
354
- return new OpenAIEmbedder(opts);
355
- default:
356
- throw new Error(`Unknown embedding provider: ${provider}`);
357
- }
358
- }
1
+ // Fully-local embedding backends for dsh-mneme: ONNX via transformers.js,
2
+ // Ollama's HTTP API, and the OpenAI-compatible HTTP API (extracted from the
3
+ // old embedding.js logic). All classes share one interface so the orchestrator
4
+ // can pick a backend by provider name and degrade gracefully on failure.
5
+ // Methods throw on error — the caller decides the fallback chain.
6
+ import { defaultModelCacheDir, defaultRuntimeDir } from "./runtime/layout.js";
7
+ import { installResilientFetch } from "./runtime/model-fetch.js";
8
+ import { loadTransformers } from "./runtime/loader.js";
9
+
10
+ const DEFAULT_TIMEOUT_MS = 15000;
11
+
12
+ /** djb2 — stable, fast fingerprint for a provider/model string. */
13
+ function hashString(s) {
14
+ let h = 5381;
15
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
16
+ return h.toString(16);
17
+ }
18
+
19
+ /** Full provider+model fingerprint used for index-consistency checks. */
20
+ function modelHash(model) {
21
+ return `${model}#${hashString(model)}`;
22
+ }
23
+
24
+ /**
25
+ * Model families whose embedding is the CLS token rather than a mean over tokens.
26
+ *
27
+ * BGE (BAAI/bge-*) is trained that way: the model's own `1_Pooling/config.json`
28
+ * declares `pooling_mode_cls_token: true` / `pooling_mode_mean_tokens: false`, and
29
+ * the official README says "select the last hidden state of the first token" plus
30
+ * L2 normalization. transformers.js's `feature-extraction` defaults to `mean`, so
31
+ * feeding it a BGE model silently produces slightly-off vectors — no error, just a
32
+ * worse ranking. This table is the extension point: a family goes in only once its
33
+ * pooling is confirmed against the model's own `1_Pooling` (or equivalent) config.
34
+ */
35
+ const CLS_POOLED_MODEL_PATTERNS = [/bge/i];
36
+
37
+ /**
38
+ * Resolve the pooling to embed with.
39
+ *
40
+ * `auto` (default) picks by model family — BGE → `cls`, everything else → `mean`,
41
+ * so models that were never affected keep their exact previous behavior. An
42
+ * explicit `cls` / `mean` wins over the family table.
43
+ */
44
+ export function resolvePooling(pooling, model) {
45
+ const mode = String(pooling ?? "auto").trim().toLowerCase();
46
+ if (mode === "cls" || mode === "mean") return mode;
47
+ return CLS_POOLED_MODEL_PATTERNS.some((re) => re.test(String(model ?? ""))) ? "cls" : "mean";
48
+ }
49
+
50
+ /**
51
+ * Lazy default loader: dynamic import keeps module load cheap.
52
+ *
53
+ * 经 runtime/loader.js 的三层解析取运行时(自管 payload 优先,其次宿主裸 specifier):
54
+ * 这样 PR-B 把 @huggingface/transformers 从 dependencies 摘掉、pnpm 把宿主那份 prune
55
+ * 掉之后,本地嵌入仍然可用;而在那之前,宿主那份照样能被第 ② 层兜住。
56
+ * runtimeDir 从 options 里取走后再传 pipeline,免得把插件自己的配置项混进
57
+ * transformers 的选项里。
58
+ */
59
+ async function defaultPipelineLoader(task, model, options) {
60
+ const { runtimeDir, remoteHost, resilientModelDownload, logger, ...pipelineOptions } = options ?? {};
61
+ const { module } = await loadTransformers({
62
+ runtimeDir: runtimeDir || defaultRuntimeDir()
63
+ });
64
+ const { env, pipeline } = module;
65
+ // issue #13: transformers.js's get_tokenizer_files() drops the caller's
66
+ // cache_dir when it pre-checks tokenizer_config.json metadata, so the HEAD
67
+ // request falls back to env.cacheDir and hits the network even when the
68
+ // model is fully cached locally. Mirroring the cache_dir onto env.cacheDir
69
+ // makes that pre-check resolve locally too — fully offline loading.
70
+ if (pipelineOptions.cache_dir) env.cacheDir = pipelineOptions.cache_dir;
71
+ // issue #188 补充:embedModelMirror 此前是死配置(定义了但全仓无人读取)。
72
+ // transformers.js 不认 HF_ENDPOINT(那是 Python huggingface_hub 的变量),
73
+ // 模型下载源由 env.remoteHost 决定——把镜像接到真正的开关上,否则
74
+ // huggingface.co 不可达的网络连模型都下不下来。
75
+ if (remoteHost) env.remoteHost = remoteHost;
76
+ // issue #194:模型文件(几百 MB~1GB)下载中断后从断点续传。开关关 = env.fetch 一字不动。
77
+ installResilientFetch(env, {
78
+ enabled: resilientModelDownload === true,
79
+ cacheDir: pipelineOptions.cache_dir,
80
+ logger
81
+ });
82
+ return pipeline(task, model, pipelineOptions);
83
+ }
84
+
85
+ /** Flatten a transformers.js Tensor [batch, dim] into number[][]. */
86
+ function tensorToRows(tensor) {
87
+ const { data, dims } = tensor;
88
+ const rowLen = dims[dims.length - 1] || 0;
89
+ const rows = [];
90
+ for (let i = 0; i < data.length; i += rowLen) {
91
+ rows.push(Array.from(data.subarray(i, i + rowLen)));
92
+ }
93
+ // Single-text input may come back without the batch axis.
94
+ if (rows.length === 0 && rowLen > 0) rows.push(Array.from(data));
95
+ return rows;
96
+ }
97
+
98
+ /**
99
+ * ONNX text embedder backed by transformers.js (onnxruntime-node underneath).
100
+ * Runs fully offline; pooling is resolved per model family (BGE → CLS, else mean —
101
+ * see resolvePooling) and vectors are L2-normalized. `engineFactory` is injectable
102
+ * for tests.
103
+ */
104
+ export class LocalEmbedder {
105
+ constructor(opts = {}) {
106
+ this.model = opts.model || "Xenova/bge-small-zh-v1.5";
107
+ this._dimension = opts.dimension || 512;
108
+ this.device = opts.device || "cpu";
109
+ this.batchSize = opts.batchSize || 8;
110
+ this.cacheDir = String(opts.cacheDir ?? "").trim() || defaultModelCacheDir();
111
+ // 自管运行时根目录(issue #131):空表示用默认位置,具体解析交给 loader。
112
+ this.runtimeDir = String(opts.runtimeDir ?? "").trim();
113
+ // issue #194:模型文件下载断点续传。只在显式传 true 时启用——直连构造的旧调用方
114
+ // (含测试)保持裸 fetch 的现状,配置默认值由 index.js 传进来。
115
+ this.resilientModelDownload = opts.resilientModelDownload === true;
116
+ this.useDtype = opts.useDtype || "q8";
117
+ // 池化:配置值(auto/cls/mean)→ 生效值(cls/mean)。解析结果既进 embed(),
118
+ // 也进 modelHash —— 换池化就是换向量空间,旧索引必须被判失配后重建。
119
+ this.poolingMode = String(opts.pooling ?? "auto").trim().toLowerCase();
120
+ this.pooling = resolvePooling(this.poolingMode, this.model);
121
+ // 模型镜像下载源(embedModelMirror,#188 补充):空 = transformers 默认。
122
+ this.remoteHost = String(opts.remoteHost ?? "").trim();
123
+ this.logger = opts.logger ?? null;
124
+ // Test hook: replace the pipeline factory without touching modules.
125
+ this.engineFactory = opts.engineFactory || defaultPipelineLoader;
126
+ this.extractor = null;
127
+ // issue #6: readiness flag for the service's scheduleEmbed gate. False until
128
+ // init() succeeds, so "ready" in embedder is observable even pre-init.
129
+ this.ready = false;
130
+ }
131
+
132
+ /** Load the model; throws when it cannot be loaded. Idempotent. */
133
+ async init() {
134
+ if (this.extractor) return this; // already initialized: no-op
135
+ const options = {
136
+ dtype: this.useDtype,
137
+ device: this.device
138
+ };
139
+ if (this.cacheDir) options.cache_dir = this.cacheDir;
140
+ // 传给注入的 engineFactory,由 defaultPipelineLoader 取走后交给三层解析。
141
+ if (this.runtimeDir) options.runtimeDir = this.runtimeDir;
142
+ if (this.remoteHost) options.remoteHost = this.remoteHost;
143
+ options.resilientModelDownload = this.resilientModelDownload;
144
+ options.logger = this.logger;
145
+ this.extractor = await this.engineFactory("feature-extraction", this.model, options);
146
+ this.ready = true; // service reads this to flush queued re-embeds
147
+ this.logger?.info?.(
148
+ `[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device}, pooling=${this.pooling})`
149
+ );
150
+ return this;
151
+ }
152
+
153
+ /** Embed many texts with the model's pooling; chunks at batchSize. */
154
+ async embed(texts) {
155
+ if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
156
+ if (!this.extractor) throw new Error("LocalEmbedder not initialized");
157
+ const out = [];
158
+ for (let i = 0; i < texts.length; i += this.batchSize) {
159
+ const chunk = texts.slice(i, i + this.batchSize);
160
+ const tensor = await this.extractor(chunk, { pooling: this.pooling, normalize: true });
161
+ out.push(...tensorToRows(tensor));
162
+ }
163
+ return out;
164
+ }
165
+
166
+ async embedSingle(text) {
167
+ const rows = await this.embed([String(text)]);
168
+ return rows[0];
169
+ }
170
+
171
+ get dimension() {
172
+ return this._dimension;
173
+ }
174
+
175
+ get modelHash() {
176
+ // Pooling is part of the vector space: a CLS index cannot be queried with mean
177
+ // vectors (or vice versa). Keep the historical `model#hash` shape for `mean` so
178
+ // indexes that were never affected are not invalidated needlessly; every other
179
+ // pooling gets a fingerprint of its own, which makes the index-consistency gate
180
+ // flag the stale mean-space index and rebuild it.
181
+ return this.pooling === "mean"
182
+ ? modelHash(this.model)
183
+ : modelHash(`${this.model}@${this.pooling}`);
184
+ }
185
+
186
+ dispose() {
187
+ try {
188
+ this.extractor?.dispose?.();
189
+ } catch {
190
+ // best-effort: some engines free resources on GC
191
+ }
192
+ this.extractor = null;
193
+ this.ready = false;
194
+ }
195
+ }
196
+
197
+ /** Chunk texts into batches of at most `size`. */
198
+ function chunk(texts, size) {
199
+ const out = [];
200
+ for (let i = 0; i < texts.length; i += size) out.push(texts.slice(i, i + size));
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Ollama embedder over its native HTTP API. `dimension` is inferred from the
206
+ * first response. init() verifies reachability and that the model exists.
207
+ */
208
+ export class OllamaEmbedder {
209
+ constructor(opts = {}) {
210
+ this.baseUrl = String(opts.baseUrl ?? "http://localhost:11434").trim().replace(/\/+$/, "");
211
+ this.model = String(opts.model ?? "nomic-embed-text").trim();
212
+ this.logger = opts.logger ?? null;
213
+ this._dimension = null;
214
+ // #118: async-init embedders expose `ready` so the service queues re-embeds
215
+ // until init lands and the status card can show "initializing".
216
+ this.ready = false;
217
+ }
218
+
219
+ async _post(body) {
220
+ return fetch(`${this.baseUrl}/api/embeddings`, {
221
+ method: "POST",
222
+ headers: { "Content-Type": "application/json" },
223
+ body: JSON.stringify(body),
224
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
225
+ });
226
+ }
227
+
228
+ /** Probe the server with a tiny prompt; throws when unreachable/missing. */
229
+ async init() {
230
+ const res = await this._post({ model: this.model, prompt: "ping" });
231
+ if (!res.ok) throw new Error(`Ollama ${this.model} unavailable: HTTP ${res.status}`);
232
+ const body = await res.json();
233
+ if (!Array.isArray(body?.embedding)) throw new Error(`Ollama ${this.model} returned no embedding`);
234
+ this._dimension = body.embedding.length;
235
+ this.ready = true;
236
+ this.logger?.info?.(
237
+ `[dsh-mneme] ollama embedder ready: ${this.model} (dim=${this._dimension})`
238
+ );
239
+ return this;
240
+ }
241
+
242
+ async embed(texts) {
243
+ if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
244
+ const out = [];
245
+ for (const text of texts) out.push(await this.embedSingle(text));
246
+ return out;
247
+ }
248
+
249
+ async embedSingle(text) {
250
+ const res = await this._post({ model: this.model, prompt: String(text).slice(0, 8000) });
251
+ if (!res.ok) throw new Error(`Ollama embed failed: HTTP ${res.status}`);
252
+ const body = await res.json();
253
+ const vec = body?.embedding;
254
+ if (!Array.isArray(vec) || !vec.length) throw new Error("Ollama returned no embedding");
255
+ if (this._dimension == null) this._dimension = vec.length;
256
+ return Array.from(vec);
257
+ }
258
+
259
+ get dimension() {
260
+ return this._dimension ?? 0;
261
+ }
262
+
263
+ get modelHash() {
264
+ return modelHash(this.model);
265
+ }
266
+
267
+ dispose() {
268
+ this._dimension = null;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * OpenAI-compatible embedder (OpenAI, SiliconFlow, Zhipu, local proxies).
274
+ * Backward-compatible behavior lifted from embedding.js, but batchable and
275
+ * throwing on failure instead of returning null.
276
+ */
277
+ export class OpenAIEmbedder {
278
+ constructor(opts = {}) {
279
+ this.baseUrl = String(opts.baseUrl ?? "").trim().replace(/\/+$/, "");
280
+ this.apiKey = String(opts.apiKey ?? "").trim();
281
+ this.model = String(opts.model ?? "").trim();
282
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
283
+ this.logger = opts.logger ?? null;
284
+ this._dimension = null;
285
+ // Accept both "https://host/v1" and a full path ending in /embeddings.
286
+ this._url = /\/embeddings$/i.test(this.baseUrl)
287
+ ? this.baseUrl
288
+ : this.baseUrl ? `${this.baseUrl}/embeddings` : "";
289
+ }
290
+
291
+ async init() {
292
+ if (!this._url || !this.apiKey || !this.model) {
293
+ throw new Error("OpenAI embedder requires baseUrl, apiKey and model");
294
+ }
295
+ this.logger?.info?.(`[dsh-mneme] openai embedder ready: ${this.model}`);
296
+ return this;
297
+ }
298
+
299
+ async embed(texts) {
300
+ if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
301
+ const out = [];
302
+ for (const batch of chunk(texts, 32)) {
303
+ const res = await fetch(this._url, {
304
+ method: "POST",
305
+ headers: {
306
+ "Content-Type": "application/json",
307
+ "Authorization": `Bearer ${this.apiKey}`
308
+ },
309
+ body: JSON.stringify({ model: this.model, input: batch.map((t) => String(t).slice(0, 8000)) }),
310
+ signal: AbortSignal.timeout(this.timeoutMs)
311
+ });
312
+ if (!res.ok) throw new Error(`Embedding API failed: HTTP ${res.status}`);
313
+ const body = await res.json();
314
+ const list = body?.data;
315
+ if (!Array.isArray(list) || list.length !== batch.length) {
316
+ throw new Error("Embedding API returned unexpected payload");
317
+ }
318
+ for (const item of list) {
319
+ const vec = item?.embedding;
320
+ if (!Array.isArray(vec) || !vec.length) throw new Error("Embedding API returned empty vector");
321
+ if (this._dimension == null) this._dimension = vec.length;
322
+ out.push(Array.from(vec));
323
+ }
324
+ }
325
+ return out;
326
+ }
327
+
328
+ async embedSingle(text) {
329
+ const rows = await this.embed([String(text)]);
330
+ return rows[0];
331
+ }
332
+
333
+ get dimension() {
334
+ return this._dimension ?? 0;
335
+ }
336
+
337
+ get modelHash() {
338
+ return modelHash(this.model);
339
+ }
340
+
341
+ dispose() {
342
+ this._dimension = null;
343
+ }
344
+ }
345
+
346
+ /** Pick a backend instance by provider name. Throws on unknown providers. */
347
+ export function createEmbedderByProvider(provider, opts) {
348
+ switch (String(provider ?? "").toLowerCase()) {
349
+ case "local":
350
+ return new LocalEmbedder(opts);
351
+ case "ollama":
352
+ return new OllamaEmbedder(opts);
353
+ case "openai":
354
+ return new OpenAIEmbedder(opts);
355
+ default:
356
+ throw new Error(`Unknown embedding provider: ${provider}`);
357
+ }
358
+ }