@modusensus/dsh-mneme 0.6.7 → 0.6.9

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 (139) hide show
  1. package/.github/workflows/test.yml +32 -0
  2. package/.release-notes-v0.6.9.md +13 -0
  3. package/CHANGELOG.md +89 -0
  4. package/LICENSE +21 -21
  5. package/README.md +219 -463
  6. package/SECURITY.md +544 -0
  7. package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +247 -0
  8. package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +145 -0
  9. package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +56 -0
  10. package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +77 -0
  11. package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +64 -0
  12. package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +90 -0
  13. package/dsh-mneme/CHANGELOG.md +248 -0
  14. package/dsh-mneme/LICENSE +21 -0
  15. package/dsh-mneme/README.md +465 -0
  16. package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +15 -15
  17. package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +183 -0
  18. package/dsh-mneme/docs/ENTITIES.md +245 -0
  19. package/dsh-mneme/docs/LOCAL_MODEL.md +141 -0
  20. package/dsh-mneme/docs/MIGRATION.md +127 -0
  21. package/dsh-mneme/docs/SEMANTIC.md +256 -0
  22. package/dsh-mneme/docs/SLEEP.md +163 -0
  23. package/{lib → dsh-mneme/lib}/api.js +783 -783
  24. package/{lib → dsh-mneme/lib}/client.js +1757 -1757
  25. package/{src → dsh-mneme/lib}/commands.js +64 -64
  26. package/{lib → dsh-mneme/lib}/config.js +298 -288
  27. package/{lib → dsh-mneme/lib}/dream/clustering.js +118 -118
  28. package/{src → dsh-mneme/lib}/dream/decisions.js +488 -439
  29. package/{lib → dsh-mneme/lib}/dream/sleep.js +561 -554
  30. package/{src → dsh-mneme/lib}/dream/tag-extractor.js +156 -156
  31. package/{lib → dsh-mneme/lib}/dream.js +958 -929
  32. package/{src → dsh-mneme/lib}/embedding.js +154 -154
  33. package/{src → dsh-mneme/lib}/entities/extractor.js +242 -242
  34. package/{src → dsh-mneme/lib}/hot-memory.js +53 -53
  35. package/{lib → dsh-mneme/lib}/index.js +361 -361
  36. package/{src → dsh-mneme/lib}/inject.js +208 -208
  37. package/{lib → dsh-mneme/lib}/local-embedder.js +282 -282
  38. package/{lib → dsh-mneme/lib}/mirror.js +170 -170
  39. package/{lib → dsh-mneme/lib}/parser/tag.js +59 -59
  40. package/{lib → dsh-mneme/lib}/parser/wiki-link.js +38 -38
  41. package/{src → dsh-mneme/lib}/quality-filter.js +123 -123
  42. package/{lib → dsh-mneme/lib}/reranker.js +218 -218
  43. package/{lib → dsh-mneme/lib}/search/adaptive.js +22 -22
  44. package/{lib → dsh-mneme/lib}/search/bm25.js +96 -96
  45. package/{src → dsh-mneme/lib}/search/tag-boost.js +61 -61
  46. package/{src → dsh-mneme/lib}/service.js +1726 -1726
  47. package/{lib → dsh-mneme/lib}/settings.js +172 -172
  48. package/{lib → dsh-mneme/lib}/store.js +2238 -2238
  49. package/{lib → dsh-mneme/lib}/summarize.js +236 -236
  50. package/{lib → dsh-mneme/lib}/tools.js +290 -290
  51. package/{lib → dsh-mneme/lib}/vector-index.js +116 -116
  52. package/dsh-mneme/package-lock.json +1936 -0
  53. package/dsh-mneme/package.json +80 -0
  54. package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +201 -201
  55. package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +133 -133
  56. package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +166 -166
  57. package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +218 -218
  58. package/{scripts → dsh-mneme/scripts}/stress-dsh.js +255 -255
  59. package/{scripts → dsh-mneme/scripts}/sync-lib.js +52 -52
  60. package/{src → dsh-mneme/src}/api.js +783 -783
  61. package/{lib → dsh-mneme/src}/commands.js +64 -64
  62. package/{src → dsh-mneme/src}/config.js +298 -288
  63. package/{src → dsh-mneme/src}/dream/clustering.js +118 -118
  64. package/{lib → dsh-mneme/src}/dream/decisions.js +488 -439
  65. package/{src → dsh-mneme/src}/dream/sleep.js +561 -554
  66. package/{lib → dsh-mneme/src}/dream/tag-extractor.js +156 -156
  67. package/{src → dsh-mneme/src}/dream.js +958 -929
  68. package/{lib → dsh-mneme/src}/embedding.js +154 -154
  69. package/{lib → dsh-mneme/src}/entities/extractor.js +242 -242
  70. package/{lib → dsh-mneme/src}/hot-memory.js +53 -53
  71. package/{src → dsh-mneme/src}/index.js +361 -361
  72. package/{lib → dsh-mneme/src}/inject.js +208 -208
  73. package/{src → dsh-mneme/src}/local-embedder.js +282 -282
  74. package/{src → dsh-mneme/src}/mirror.js +170 -170
  75. package/{src → dsh-mneme/src}/parser/tag.js +59 -59
  76. package/{src → dsh-mneme/src}/parser/wiki-link.js +38 -38
  77. package/{lib → dsh-mneme/src}/quality-filter.js +123 -123
  78. package/{src → dsh-mneme/src}/reranker.js +218 -218
  79. package/{src → dsh-mneme/src}/search/adaptive.js +22 -22
  80. package/{src → dsh-mneme/src}/search/bm25.js +96 -96
  81. package/{lib → dsh-mneme/src}/search/tag-boost.js +61 -61
  82. package/{lib → dsh-mneme/src}/service.js +1726 -1726
  83. package/{src → dsh-mneme/src}/settings.js +172 -172
  84. package/{src → dsh-mneme/src}/store.js +2238 -2238
  85. package/{src → dsh-mneme/src}/summarize.js +236 -236
  86. package/{src → dsh-mneme/src}/tools.js +290 -290
  87. package/{src → dsh-mneme/src}/vector-index.js +116 -116
  88. package/{test → dsh-mneme/test}/api.test.js +594 -594
  89. package/{test → dsh-mneme/test}/audit.test.js +448 -448
  90. package/{test → dsh-mneme/test}/benchmark.test.js +35 -35
  91. package/{test → dsh-mneme/test}/boundary-v0625.test.js +82 -82
  92. package/{test → dsh-mneme/test}/client.test.js +368 -368
  93. package/{test → dsh-mneme/test}/clustering.test.js +100 -100
  94. package/{test → dsh-mneme/test}/commands.test.js +69 -69
  95. package/{test → dsh-mneme/test}/config.test.js +50 -50
  96. package/{test → dsh-mneme/test}/conflict-freeze.test.js +290 -290
  97. package/{test → dsh-mneme/test}/directory.test.js +134 -134
  98. package/{test → dsh-mneme/test}/dream.test.js +1060 -901
  99. package/{test → dsh-mneme/test}/entities.test.js +522 -522
  100. package/{test → dsh-mneme/test}/epistemic.test.js +298 -298
  101. package/{test → dsh-mneme/test}/fnew-0112.test.js +311 -311
  102. package/{test → dsh-mneme/test}/fnew-03.test.js +422 -422
  103. package/{test → dsh-mneme/test}/graph-api.test.js +175 -175
  104. package/{test → dsh-mneme/test}/helpers/dream-mock.js +82 -82
  105. package/{test → dsh-mneme/test}/hot-memory.test.js +174 -174
  106. package/{test → dsh-mneme/test}/inject.test.js +103 -103
  107. package/{test → dsh-mneme/test}/llm-audit.test.js +279 -279
  108. package/{test → dsh-mneme/test}/local-embedder.test.js +227 -227
  109. package/{test → dsh-mneme/test}/mirror-dirty.test.js +424 -424
  110. package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +187 -187
  111. package/{test → dsh-mneme/test}/mirror-generation.test.js +499 -499
  112. package/{test → dsh-mneme/test}/mirror.test.js +249 -249
  113. package/{test → dsh-mneme/test}/normalize-decisions.test.js +120 -120
  114. package/{test → dsh-mneme/test}/peer-blockers.test.js +190 -190
  115. package/{test → dsh-mneme/test}/policy-epoch.test.js +259 -259
  116. package/{test → dsh-mneme/test}/provenance.test.js +103 -103
  117. package/{test → dsh-mneme/test}/quality-filter.test.js +118 -118
  118. package/{test → dsh-mneme/test}/reasoning-effort.test.js +199 -199
  119. package/{test → dsh-mneme/test}/recall-evals.test.js +235 -235
  120. package/{test → dsh-mneme/test}/recall-layer.test.js +315 -315
  121. package/{test → dsh-mneme/test}/receipt-chain.test.js +451 -451
  122. package/{test → dsh-mneme/test}/reflection.test.js +226 -226
  123. package/{test → dsh-mneme/test}/reranker.test.js +240 -240
  124. package/{test → dsh-mneme/test}/search-fusion.test.js +90 -90
  125. package/{test → dsh-mneme/test}/semantic.test.js +124 -124
  126. package/{test → dsh-mneme/test}/service-search.test.js +199 -199
  127. package/{test → dsh-mneme/test}/service.test.js +435 -435
  128. package/{test → dsh-mneme/test}/settings.test.js +118 -118
  129. package/{test → dsh-mneme/test}/sleep.test.js +365 -365
  130. package/{test → dsh-mneme/test}/store.test.js +436 -436
  131. package/{test → dsh-mneme/test}/stress.test.js +209 -209
  132. package/{test → dsh-mneme/test}/summarize.test.js +191 -191
  133. package/{test → dsh-mneme/test}/tag-boost.test.js +125 -125
  134. package/{test → dsh-mneme/test}/tag.test.js +312 -312
  135. package/{test → dsh-mneme/test}/tools.test.js +285 -285
  136. package/{test → dsh-mneme/test}/vector-index.test.js +221 -221
  137. package/{test → dsh-mneme/test}/wiki-link.test.js +332 -332
  138. package/package.json +18 -40
  139. package//346/250/252/345/271/205.png +0 -0
@@ -1,282 +1,282 @@
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 os from "node:os";
7
- import path from "node:path";
8
-
9
- const DEFAULT_TIMEOUT_MS = 15000;
10
-
11
- /** djb2 — stable, fast fingerprint for a provider/model string. */
12
- function hashString(s) {
13
- let h = 5381;
14
- for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
15
- return h.toString(16);
16
- }
17
-
18
- /** Full provider+model fingerprint used for index-consistency checks. */
19
- function modelHash(model) {
20
- return `${model}#${hashString(model)}`;
21
- }
22
-
23
- /** Lazy default loader: dynamic import keeps module load cheap. */
24
- async function defaultPipelineLoader(task, model, options) {
25
- const { env, pipeline } = await import("@huggingface/transformers");
26
- // issue #13: transformers.js's get_tokenizer_files() drops the caller's
27
- // cache_dir when it pre-checks tokenizer_config.json metadata, so the HEAD
28
- // request falls back to env.cacheDir and hits the network even when the
29
- // model is fully cached locally. Mirroring the cache_dir onto env.cacheDir
30
- // makes that pre-check resolve locally too — fully offline loading.
31
- if (options?.cache_dir) env.cacheDir = options.cache_dir;
32
- return pipeline(task, model, options);
33
- }
34
-
35
- /** Flatten a transformers.js Tensor [batch, dim] into number[][]. */
36
- function tensorToRows(tensor) {
37
- const { data, dims } = tensor;
38
- const rowLen = dims[dims.length - 1] || 0;
39
- const rows = [];
40
- for (let i = 0; i < data.length; i += rowLen) {
41
- rows.push(Array.from(data.subarray(i, i + rowLen)));
42
- }
43
- // Single-text input may come back without the batch axis.
44
- if (rows.length === 0 && rowLen > 0) rows.push(Array.from(data));
45
- return rows;
46
- }
47
-
48
- /**
49
- * ONNX text embedder backed by transformers.js (onnxruntime-node underneath).
50
- * Runs fully offline with mean pooling + L2 normalization for BERT-style
51
- * models like bge-small-zh. `engineFactory` is injectable for tests.
52
- */
53
- export class LocalEmbedder {
54
- constructor(opts = {}) {
55
- this.model = opts.model || "Xenova/bge-small-zh-v1.5";
56
- this._dimension = opts.dimension || 512;
57
- this.device = opts.device || "cpu";
58
- this.batchSize = opts.batchSize || 8;
59
- this.cacheDir =
60
- String(opts.cacheDir ?? "").trim() ||
61
- path.join(os.homedir(), ".dsh", "mneme", "models");
62
- this.useDtype = opts.useDtype || "q8";
63
- this.logger = opts.logger ?? null;
64
- // Test hook: replace the pipeline factory without touching modules.
65
- this.engineFactory = opts.engineFactory || defaultPipelineLoader;
66
- this.extractor = null;
67
- // issue #6: readiness flag for the service's scheduleEmbed gate. False until
68
- // init() succeeds, so "ready" in embedder is observable even pre-init.
69
- this.ready = false;
70
- }
71
-
72
- /** Load the model; throws when it cannot be loaded. Idempotent. */
73
- async init() {
74
- if (this.extractor) return this; // already initialized: no-op
75
- const options = {
76
- dtype: this.useDtype,
77
- device: this.device
78
- };
79
- if (this.cacheDir) options.cache_dir = this.cacheDir;
80
- this.extractor = await this.engineFactory("feature-extraction", this.model, options);
81
- this.ready = true; // service reads this to flush queued re-embeds
82
- this.logger?.info?.(
83
- `[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device})`
84
- );
85
- return this;
86
- }
87
-
88
- /** Embed many texts with mean pooling; chunks at batchSize. */
89
- async embed(texts) {
90
- if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
91
- if (!this.extractor) throw new Error("LocalEmbedder not initialized");
92
- const out = [];
93
- for (let i = 0; i < texts.length; i += this.batchSize) {
94
- const chunk = texts.slice(i, i + this.batchSize);
95
- const tensor = await this.extractor(chunk, { pooling: "mean", normalize: true });
96
- out.push(...tensorToRows(tensor));
97
- }
98
- return out;
99
- }
100
-
101
- async embedSingle(text) {
102
- const rows = await this.embed([String(text)]);
103
- return rows[0];
104
- }
105
-
106
- get dimension() {
107
- return this._dimension;
108
- }
109
-
110
- get modelHash() {
111
- return modelHash(this.model);
112
- }
113
-
114
- dispose() {
115
- try {
116
- this.extractor?.dispose?.();
117
- } catch {
118
- // best-effort: some engines free resources on GC
119
- }
120
- this.extractor = null;
121
- this.ready = false;
122
- }
123
- }
124
-
125
- /** Chunk texts into batches of at most `size`. */
126
- function chunk(texts, size) {
127
- const out = [];
128
- for (let i = 0; i < texts.length; i += size) out.push(texts.slice(i, i + size));
129
- return out;
130
- }
131
-
132
- /**
133
- * Ollama embedder over its native HTTP API. `dimension` is inferred from the
134
- * first response. init() verifies reachability and that the model exists.
135
- */
136
- export class OllamaEmbedder {
137
- constructor(opts = {}) {
138
- this.baseUrl = String(opts.baseUrl ?? "http://localhost:11434").trim().replace(/\/+$/, "");
139
- this.model = String(opts.model ?? "nomic-embed-text").trim();
140
- this.logger = opts.logger ?? null;
141
- this._dimension = null;
142
- }
143
-
144
- async _post(body) {
145
- return fetch(`${this.baseUrl}/api/embeddings`, {
146
- method: "POST",
147
- headers: { "Content-Type": "application/json" },
148
- body: JSON.stringify(body),
149
- signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
150
- });
151
- }
152
-
153
- /** Probe the server with a tiny prompt; throws when unreachable/missing. */
154
- async init() {
155
- const res = await this._post({ model: this.model, prompt: "ping" });
156
- if (!res.ok) throw new Error(`Ollama ${this.model} unavailable: HTTP ${res.status}`);
157
- const body = await res.json();
158
- if (!Array.isArray(body?.embedding)) throw new Error(`Ollama ${this.model} returned no embedding`);
159
- this._dimension = body.embedding.length;
160
- this.logger?.info?.(
161
- `[dsh-mneme] ollama embedder ready: ${this.model} (dim=${this._dimension})`
162
- );
163
- return this;
164
- }
165
-
166
- async embed(texts) {
167
- if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
168
- const out = [];
169
- for (const text of texts) out.push(await this.embedSingle(text));
170
- return out;
171
- }
172
-
173
- async embedSingle(text) {
174
- const res = await this._post({ model: this.model, prompt: String(text).slice(0, 8000) });
175
- if (!res.ok) throw new Error(`Ollama embed failed: HTTP ${res.status}`);
176
- const body = await res.json();
177
- const vec = body?.embedding;
178
- if (!Array.isArray(vec) || !vec.length) throw new Error("Ollama returned no embedding");
179
- if (this._dimension == null) this._dimension = vec.length;
180
- return Array.from(vec);
181
- }
182
-
183
- get dimension() {
184
- return this._dimension ?? 0;
185
- }
186
-
187
- get modelHash() {
188
- return modelHash(this.model);
189
- }
190
-
191
- dispose() {
192
- this._dimension = null;
193
- }
194
- }
195
-
196
- /**
197
- * OpenAI-compatible embedder (OpenAI, SiliconFlow, Zhipu, local proxies).
198
- * Backward-compatible behavior lifted from embedding.js, but batchable and
199
- * throwing on failure instead of returning null.
200
- */
201
- export class OpenAIEmbedder {
202
- constructor(opts = {}) {
203
- this.baseUrl = String(opts.baseUrl ?? "").trim().replace(/\/+$/, "");
204
- this.apiKey = String(opts.apiKey ?? "").trim();
205
- this.model = String(opts.model ?? "").trim();
206
- this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
207
- this.logger = opts.logger ?? null;
208
- this._dimension = null;
209
- // Accept both "https://host/v1" and a full path ending in /embeddings.
210
- this._url = /\/embeddings$/i.test(this.baseUrl)
211
- ? this.baseUrl
212
- : this.baseUrl ? `${this.baseUrl}/embeddings` : "";
213
- }
214
-
215
- async init() {
216
- if (!this._url || !this.apiKey || !this.model) {
217
- throw new Error("OpenAI embedder requires baseUrl, apiKey and model");
218
- }
219
- this.logger?.info?.(`[dsh-mneme] openai embedder ready: ${this.model}`);
220
- return this;
221
- }
222
-
223
- async embed(texts) {
224
- if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
225
- const out = [];
226
- for (const batch of chunk(texts, 32)) {
227
- const res = await fetch(this._url, {
228
- method: "POST",
229
- headers: {
230
- "Content-Type": "application/json",
231
- "Authorization": `Bearer ${this.apiKey}`
232
- },
233
- body: JSON.stringify({ model: this.model, input: batch.map((t) => String(t).slice(0, 8000)) }),
234
- signal: AbortSignal.timeout(this.timeoutMs)
235
- });
236
- if (!res.ok) throw new Error(`Embedding API failed: HTTP ${res.status}`);
237
- const body = await res.json();
238
- const list = body?.data;
239
- if (!Array.isArray(list) || list.length !== batch.length) {
240
- throw new Error("Embedding API returned unexpected payload");
241
- }
242
- for (const item of list) {
243
- const vec = item?.embedding;
244
- if (!Array.isArray(vec) || !vec.length) throw new Error("Embedding API returned empty vector");
245
- if (this._dimension == null) this._dimension = vec.length;
246
- out.push(Array.from(vec));
247
- }
248
- }
249
- return out;
250
- }
251
-
252
- async embedSingle(text) {
253
- const rows = await this.embed([String(text)]);
254
- return rows[0];
255
- }
256
-
257
- get dimension() {
258
- return this._dimension ?? 0;
259
- }
260
-
261
- get modelHash() {
262
- return modelHash(this.model);
263
- }
264
-
265
- dispose() {
266
- this._dimension = null;
267
- }
268
- }
269
-
270
- /** Pick a backend instance by provider name. Throws on unknown providers. */
271
- export function createEmbedderByProvider(provider, opts) {
272
- switch (String(provider ?? "").toLowerCase()) {
273
- case "local":
274
- return new LocalEmbedder(opts);
275
- case "ollama":
276
- return new OllamaEmbedder(opts);
277
- case "openai":
278
- return new OpenAIEmbedder(opts);
279
- default:
280
- throw new Error(`Unknown embedding provider: ${provider}`);
281
- }
282
- }
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 os from "node:os";
7
+ import path from "node:path";
8
+
9
+ const DEFAULT_TIMEOUT_MS = 15000;
10
+
11
+ /** djb2 — stable, fast fingerprint for a provider/model string. */
12
+ function hashString(s) {
13
+ let h = 5381;
14
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
15
+ return h.toString(16);
16
+ }
17
+
18
+ /** Full provider+model fingerprint used for index-consistency checks. */
19
+ function modelHash(model) {
20
+ return `${model}#${hashString(model)}`;
21
+ }
22
+
23
+ /** Lazy default loader: dynamic import keeps module load cheap. */
24
+ async function defaultPipelineLoader(task, model, options) {
25
+ const { env, pipeline } = await import("@huggingface/transformers");
26
+ // issue #13: transformers.js's get_tokenizer_files() drops the caller's
27
+ // cache_dir when it pre-checks tokenizer_config.json metadata, so the HEAD
28
+ // request falls back to env.cacheDir and hits the network even when the
29
+ // model is fully cached locally. Mirroring the cache_dir onto env.cacheDir
30
+ // makes that pre-check resolve locally too — fully offline loading.
31
+ if (options?.cache_dir) env.cacheDir = options.cache_dir;
32
+ return pipeline(task, model, options);
33
+ }
34
+
35
+ /** Flatten a transformers.js Tensor [batch, dim] into number[][]. */
36
+ function tensorToRows(tensor) {
37
+ const { data, dims } = tensor;
38
+ const rowLen = dims[dims.length - 1] || 0;
39
+ const rows = [];
40
+ for (let i = 0; i < data.length; i += rowLen) {
41
+ rows.push(Array.from(data.subarray(i, i + rowLen)));
42
+ }
43
+ // Single-text input may come back without the batch axis.
44
+ if (rows.length === 0 && rowLen > 0) rows.push(Array.from(data));
45
+ return rows;
46
+ }
47
+
48
+ /**
49
+ * ONNX text embedder backed by transformers.js (onnxruntime-node underneath).
50
+ * Runs fully offline with mean pooling + L2 normalization for BERT-style
51
+ * models like bge-small-zh. `engineFactory` is injectable for tests.
52
+ */
53
+ export class LocalEmbedder {
54
+ constructor(opts = {}) {
55
+ this.model = opts.model || "Xenova/bge-small-zh-v1.5";
56
+ this._dimension = opts.dimension || 512;
57
+ this.device = opts.device || "cpu";
58
+ this.batchSize = opts.batchSize || 8;
59
+ this.cacheDir =
60
+ String(opts.cacheDir ?? "").trim() ||
61
+ path.join(os.homedir(), ".dsh", "mneme", "models");
62
+ this.useDtype = opts.useDtype || "q8";
63
+ this.logger = opts.logger ?? null;
64
+ // Test hook: replace the pipeline factory without touching modules.
65
+ this.engineFactory = opts.engineFactory || defaultPipelineLoader;
66
+ this.extractor = null;
67
+ // issue #6: readiness flag for the service's scheduleEmbed gate. False until
68
+ // init() succeeds, so "ready" in embedder is observable even pre-init.
69
+ this.ready = false;
70
+ }
71
+
72
+ /** Load the model; throws when it cannot be loaded. Idempotent. */
73
+ async init() {
74
+ if (this.extractor) return this; // already initialized: no-op
75
+ const options = {
76
+ dtype: this.useDtype,
77
+ device: this.device
78
+ };
79
+ if (this.cacheDir) options.cache_dir = this.cacheDir;
80
+ this.extractor = await this.engineFactory("feature-extraction", this.model, options);
81
+ this.ready = true; // service reads this to flush queued re-embeds
82
+ this.logger?.info?.(
83
+ `[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device})`
84
+ );
85
+ return this;
86
+ }
87
+
88
+ /** Embed many texts with mean pooling; chunks at batchSize. */
89
+ async embed(texts) {
90
+ if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
91
+ if (!this.extractor) throw new Error("LocalEmbedder not initialized");
92
+ const out = [];
93
+ for (let i = 0; i < texts.length; i += this.batchSize) {
94
+ const chunk = texts.slice(i, i + this.batchSize);
95
+ const tensor = await this.extractor(chunk, { pooling: "mean", normalize: true });
96
+ out.push(...tensorToRows(tensor));
97
+ }
98
+ return out;
99
+ }
100
+
101
+ async embedSingle(text) {
102
+ const rows = await this.embed([String(text)]);
103
+ return rows[0];
104
+ }
105
+
106
+ get dimension() {
107
+ return this._dimension;
108
+ }
109
+
110
+ get modelHash() {
111
+ return modelHash(this.model);
112
+ }
113
+
114
+ dispose() {
115
+ try {
116
+ this.extractor?.dispose?.();
117
+ } catch {
118
+ // best-effort: some engines free resources on GC
119
+ }
120
+ this.extractor = null;
121
+ this.ready = false;
122
+ }
123
+ }
124
+
125
+ /** Chunk texts into batches of at most `size`. */
126
+ function chunk(texts, size) {
127
+ const out = [];
128
+ for (let i = 0; i < texts.length; i += size) out.push(texts.slice(i, i + size));
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * Ollama embedder over its native HTTP API. `dimension` is inferred from the
134
+ * first response. init() verifies reachability and that the model exists.
135
+ */
136
+ export class OllamaEmbedder {
137
+ constructor(opts = {}) {
138
+ this.baseUrl = String(opts.baseUrl ?? "http://localhost:11434").trim().replace(/\/+$/, "");
139
+ this.model = String(opts.model ?? "nomic-embed-text").trim();
140
+ this.logger = opts.logger ?? null;
141
+ this._dimension = null;
142
+ }
143
+
144
+ async _post(body) {
145
+ return fetch(`${this.baseUrl}/api/embeddings`, {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify(body),
149
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
150
+ });
151
+ }
152
+
153
+ /** Probe the server with a tiny prompt; throws when unreachable/missing. */
154
+ async init() {
155
+ const res = await this._post({ model: this.model, prompt: "ping" });
156
+ if (!res.ok) throw new Error(`Ollama ${this.model} unavailable: HTTP ${res.status}`);
157
+ const body = await res.json();
158
+ if (!Array.isArray(body?.embedding)) throw new Error(`Ollama ${this.model} returned no embedding`);
159
+ this._dimension = body.embedding.length;
160
+ this.logger?.info?.(
161
+ `[dsh-mneme] ollama embedder ready: ${this.model} (dim=${this._dimension})`
162
+ );
163
+ return this;
164
+ }
165
+
166
+ async embed(texts) {
167
+ if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
168
+ const out = [];
169
+ for (const text of texts) out.push(await this.embedSingle(text));
170
+ return out;
171
+ }
172
+
173
+ async embedSingle(text) {
174
+ const res = await this._post({ model: this.model, prompt: String(text).slice(0, 8000) });
175
+ if (!res.ok) throw new Error(`Ollama embed failed: HTTP ${res.status}`);
176
+ const body = await res.json();
177
+ const vec = body?.embedding;
178
+ if (!Array.isArray(vec) || !vec.length) throw new Error("Ollama returned no embedding");
179
+ if (this._dimension == null) this._dimension = vec.length;
180
+ return Array.from(vec);
181
+ }
182
+
183
+ get dimension() {
184
+ return this._dimension ?? 0;
185
+ }
186
+
187
+ get modelHash() {
188
+ return modelHash(this.model);
189
+ }
190
+
191
+ dispose() {
192
+ this._dimension = null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * OpenAI-compatible embedder (OpenAI, SiliconFlow, Zhipu, local proxies).
198
+ * Backward-compatible behavior lifted from embedding.js, but batchable and
199
+ * throwing on failure instead of returning null.
200
+ */
201
+ export class OpenAIEmbedder {
202
+ constructor(opts = {}) {
203
+ this.baseUrl = String(opts.baseUrl ?? "").trim().replace(/\/+$/, "");
204
+ this.apiKey = String(opts.apiKey ?? "").trim();
205
+ this.model = String(opts.model ?? "").trim();
206
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
207
+ this.logger = opts.logger ?? null;
208
+ this._dimension = null;
209
+ // Accept both "https://host/v1" and a full path ending in /embeddings.
210
+ this._url = /\/embeddings$/i.test(this.baseUrl)
211
+ ? this.baseUrl
212
+ : this.baseUrl ? `${this.baseUrl}/embeddings` : "";
213
+ }
214
+
215
+ async init() {
216
+ if (!this._url || !this.apiKey || !this.model) {
217
+ throw new Error("OpenAI embedder requires baseUrl, apiKey and model");
218
+ }
219
+ this.logger?.info?.(`[dsh-mneme] openai embedder ready: ${this.model}`);
220
+ return this;
221
+ }
222
+
223
+ async embed(texts) {
224
+ if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
225
+ const out = [];
226
+ for (const batch of chunk(texts, 32)) {
227
+ const res = await fetch(this._url, {
228
+ method: "POST",
229
+ headers: {
230
+ "Content-Type": "application/json",
231
+ "Authorization": `Bearer ${this.apiKey}`
232
+ },
233
+ body: JSON.stringify({ model: this.model, input: batch.map((t) => String(t).slice(0, 8000)) }),
234
+ signal: AbortSignal.timeout(this.timeoutMs)
235
+ });
236
+ if (!res.ok) throw new Error(`Embedding API failed: HTTP ${res.status}`);
237
+ const body = await res.json();
238
+ const list = body?.data;
239
+ if (!Array.isArray(list) || list.length !== batch.length) {
240
+ throw new Error("Embedding API returned unexpected payload");
241
+ }
242
+ for (const item of list) {
243
+ const vec = item?.embedding;
244
+ if (!Array.isArray(vec) || !vec.length) throw new Error("Embedding API returned empty vector");
245
+ if (this._dimension == null) this._dimension = vec.length;
246
+ out.push(Array.from(vec));
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+
252
+ async embedSingle(text) {
253
+ const rows = await this.embed([String(text)]);
254
+ return rows[0];
255
+ }
256
+
257
+ get dimension() {
258
+ return this._dimension ?? 0;
259
+ }
260
+
261
+ get modelHash() {
262
+ return modelHash(this.model);
263
+ }
264
+
265
+ dispose() {
266
+ this._dimension = null;
267
+ }
268
+ }
269
+
270
+ /** Pick a backend instance by provider name. Throws on unknown providers. */
271
+ export function createEmbedderByProvider(provider, opts) {
272
+ switch (String(provider ?? "").toLowerCase()) {
273
+ case "local":
274
+ return new LocalEmbedder(opts);
275
+ case "ollama":
276
+ return new OllamaEmbedder(opts);
277
+ case "openai":
278
+ return new OpenAIEmbedder(opts);
279
+ default:
280
+ throw new Error(`Unknown embedding provider: ${provider}`);
281
+ }
282
+ }