dsh-tdai-memory 0.2.13 → 0.2.14

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 (69) hide show
  1. package/LICENSE +21 -21
  2. package/THIRD-PARTY-NOTICES.md +20 -20
  3. package/client.js +8 -4
  4. package/cordis.patch.yml +8 -8
  5. package/index.js +414 -414
  6. package/package.json +1 -1
  7. package/recall-inject.js +86 -86
  8. package/vendor/tdai/adapters/index.js +15 -15
  9. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  10. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  11. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  12. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  13. package/vendor/tdai/adapters/standalone/index.js +5 -5
  14. package/vendor/tdai/adapters/standalone/llm-runner.js +246 -246
  15. package/vendor/tdai/config.js +363 -363
  16. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  17. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  18. package/vendor/tdai/core/hooks/auto-recall.js +653 -653
  19. package/vendor/tdai/core/index.js +8 -8
  20. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  21. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  22. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  23. package/vendor/tdai/core/prompts/l1-dedup.js +136 -136
  24. package/vendor/tdai/core/prompts/l1-extraction.js +132 -132
  25. package/vendor/tdai/core/prompts/persona-generation.js +160 -160
  26. package/vendor/tdai/core/prompts/scene-extraction.js +269 -269
  27. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  28. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  29. package/vendor/tdai/core/record/l1-reader.js +178 -178
  30. package/vendor/tdai/core/record/l1-writer.js +141 -141
  31. package/vendor/tdai/core/report/reporter.js +86 -86
  32. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  33. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  34. package/vendor/tdai/core/scene/scene-format.js +53 -53
  35. package/vendor/tdai/core/scene/scene-index.js +82 -82
  36. package/vendor/tdai/core/scene/scene-navigation.js +60 -60
  37. package/vendor/tdai/core/seed/input.js +404 -404
  38. package/vendor/tdai/core/seed/seed-runtime.js +314 -314
  39. package/vendor/tdai/core/seed/types.js +9 -9
  40. package/vendor/tdai/core/store/bm25-client.js +117 -117
  41. package/vendor/tdai/core/store/bm25-local.js +68 -68
  42. package/vendor/tdai/core/store/embedding.js +632 -632
  43. package/vendor/tdai/core/store/factory.js +95 -95
  44. package/vendor/tdai/core/store/search-utils.js +54 -54
  45. package/vendor/tdai/core/store/sqlite.js +1822 -1822
  46. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  47. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  48. package/vendor/tdai/core/store/types.js +17 -17
  49. package/vendor/tdai/core/tdai-core.js +446 -446
  50. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  51. package/vendor/tdai/core/tools/memory-search.js +207 -207
  52. package/vendor/tdai/core/types.js +12 -12
  53. package/vendor/tdai/utils/backup.js +189 -189
  54. package/vendor/tdai/utils/checkpoint.js +360 -360
  55. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  56. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  57. package/vendor/tdai/utils/env.js +13 -13
  58. package/vendor/tdai/utils/managed-timer.js +127 -127
  59. package/vendor/tdai/utils/manifest.js +91 -91
  60. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  61. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  62. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  63. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  64. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  65. package/vendor/tdai/utils/sanitize.js +360 -360
  66. package/vendor/tdai/utils/serial-queue.js +103 -103
  67. package/vendor/tdai/utils/session-filter.js +88 -88
  68. package/vendor/tdai/utils/text-utils.js +29 -29
  69. package/vendor/tdai/utils/time.js +252 -252
@@ -1,632 +1,632 @@
1
- /**
2
- * Embedding Service: converts text to vector embeddings.
3
- *
4
- * Supports two providers:
5
- * - "openai": OpenAI-compatible embedding APIs (OpenAI, Azure OpenAI, self-hosted)
6
- * - "local": node-llama-cpp with embeddinggemma-300m GGUF model (fully offline)
7
- *
8
- * When no remote embedding is configured, automatically falls back to local provider.
9
- *
10
- * Design:
11
- * - Single `embed()` for one text, `embedBatch()` for multiple.
12
- * - `getDimensions()` returns configured vector dimensions.
13
- * - Throws on failure; callers decide fallback strategy.
14
- */
15
- /**
16
- * Error thrown when embed() / embedBatch() is called before the local
17
- * embedding model has finished downloading and loading.
18
- * Callers should catch this and fall back to keyword-only mode.
19
- */
20
- export class EmbeddingNotReadyError extends Error {
21
- constructor(message) {
22
- super(message ?? "Local embedding model is not ready yet (still downloading or loading)");
23
- this.name = "EmbeddingNotReadyError";
24
- }
25
- }
26
- const TAG = "[memory-tdai][embedding]";
27
- // ============================
28
- // Local (node-llama-cpp) implementation
29
- // ============================
30
- /** Default model: Google's embeddinggemma-300m, quantized Q8_0 (~300MB) */
31
- const DEFAULT_LOCAL_MODEL = "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf";
32
- /** embeddinggemma-300m outputs 768-dimensional vectors */
33
- const LOCAL_DIMENSIONS = 768;
34
- /**
35
- * embeddinggemma-300m has a 256-token context window.
36
- * As a safe heuristic, we limit input to ~600 chars for CJK text
37
- * (CJK characters typically tokenize to 1-2 tokens each,
38
- * so 600 chars ≈ 200-400 tokens, keeping well within 256-token limit
39
- * after accounting for special tokens).
40
- * For Latin text, ~800 chars is a safe limit (~200 tokens).
41
- * We use 512 chars as a conservative universal limit.
42
- */
43
- const LOCAL_MAX_INPUT_CHARS = 512;
44
- /**
45
- * Sanitize NaN/Inf values and L2-normalize the vector.
46
- * Matches OpenClaw's own sanitizeAndNormalizeEmbedding().
47
- */
48
- function sanitizeAndNormalize(vec) {
49
- const arr = Array.from(vec).map((v) => (Number.isFinite(v) ? v : 0));
50
- const magnitude = Math.sqrt(arr.reduce((sum, v) => sum + v * v, 0));
51
- if (magnitude < 1e-10) {
52
- return new Float32Array(arr);
53
- }
54
- return new Float32Array(arr.map((v) => v / magnitude));
55
- }
56
- const defaultImportLlama = () => import("node-llama-cpp");
57
- export class LocalEmbeddingService {
58
- modelPath;
59
- modelCacheDir;
60
- logger;
61
- importLlama;
62
- // Initialization state machine
63
- initState = "idle";
64
- initPromise = null;
65
- initError = null;
66
- embeddingContext = null;
67
- constructor(config, logger, importLlama) {
68
- this.modelPath = config?.modelPath?.trim() || DEFAULT_LOCAL_MODEL;
69
- this.modelCacheDir = config?.modelCacheDir?.trim();
70
- this.logger = logger;
71
- this.importLlama = importLlama ?? defaultImportLlama;
72
- }
73
- getDimensions() {
74
- return LOCAL_DIMENSIONS;
75
- }
76
- getProviderInfo() {
77
- return { provider: "local", model: this.modelPath };
78
- }
79
- /**
80
- * Whether the local model is fully loaded and ready to serve requests.
81
- */
82
- isReady() {
83
- return this.initState === "ready" && this.embeddingContext !== null;
84
- }
85
- /**
86
- * Start background warmup: download model (if needed) and load into memory.
87
- * Does NOT block the caller — returns immediately.
88
- * Safe to call multiple times (idempotent); re-triggers on "failed" state.
89
- */
90
- startWarmup() {
91
- if (this.initState === "initializing" || this.initState === "ready") {
92
- return; // already in progress or done
93
- }
94
- this.logger?.info(`${TAG} Starting background warmup for local embedding model...`);
95
- this.initState = "initializing";
96
- this.initError = null;
97
- this.initPromise = this._doInitialize()
98
- .then(() => {
99
- this.initState = "ready";
100
- this.logger?.info(`${TAG} Background warmup complete — local embedding ready`);
101
- })
102
- .catch((err) => {
103
- this.initState = "failed";
104
- this.initError = err instanceof Error ? err : new Error(String(err));
105
- this.logger?.error(`${TAG} Background warmup failed: ${this.initError.message}. ` +
106
- `embed() calls will throw EmbeddingNotReadyError until retried.`);
107
- });
108
- }
109
- /**
110
- * Get embedding for a single text.
111
- * @throws {EmbeddingNotReadyError} if model is not yet ready.
112
- */
113
- async embed(text, _options) {
114
- this.assertReady();
115
- const truncated = this.truncateInput(text);
116
- const embedding = await this.embeddingContext.getEmbeddingFor(truncated);
117
- return sanitizeAndNormalize(embedding.vector);
118
- }
119
- /**
120
- * Get embeddings for multiple texts.
121
- * @throws {EmbeddingNotReadyError} if model is not yet ready.
122
- */
123
- async embedBatch(texts, _options) {
124
- if (texts.length === 0)
125
- return [];
126
- this.assertReady();
127
- const results = [];
128
- for (const text of texts) {
129
- const truncated = this.truncateInput(text);
130
- const embedding = await this.embeddingContext.getEmbeddingFor(truncated);
131
- results.push(sanitizeAndNormalize(embedding.vector));
132
- }
133
- return results;
134
- }
135
- /**
136
- * Release the node-llama-cpp embedding context and model resources.
137
- * Safe to call multiple times (idempotent).
138
- */
139
- close() {
140
- if (this.embeddingContext) {
141
- try {
142
- const ctx = this.embeddingContext;
143
- ctx.dispose?.();
144
- }
145
- catch {
146
- // best-effort cleanup
147
- }
148
- this.embeddingContext = null;
149
- this.initPromise = null;
150
- this.initState = "idle";
151
- this.initError = null;
152
- this.logger?.info(`${TAG} Local embedding resources released`);
153
- }
154
- }
155
- /**
156
- * Assert the model is ready. Throws EmbeddingNotReadyError if not.
157
- */
158
- assertReady() {
159
- if (this.initState === "ready" && this.embeddingContext) {
160
- return;
161
- }
162
- if (this.initState === "failed") {
163
- throw new EmbeddingNotReadyError(`Local embedding model initialization failed: ${this.initError?.message ?? "unknown error"}. ` +
164
- `Call startWarmup() to retry.`);
165
- }
166
- if (this.initState === "initializing") {
167
- throw new EmbeddingNotReadyError("Local embedding model is still loading (download/initialization in progress). Please try again later.");
168
- }
169
- // "idle" — startWarmup() was never called
170
- throw new EmbeddingNotReadyError("Local embedding model warmup has not been started. Call startWarmup() first.");
171
- }
172
- /**
173
- * Truncate input text to stay within the model's context window.
174
- * embeddinggemma-300m has a 256-token limit; we use a character-based
175
- * heuristic (LOCAL_MAX_INPUT_CHARS) as a safe proxy.
176
- */
177
- truncateInput(text) {
178
- if (text.length <= LOCAL_MAX_INPUT_CHARS)
179
- return text;
180
- this.logger?.debug?.(`${TAG} Input truncated from ${text.length} to ${LOCAL_MAX_INPUT_CHARS} chars (model context limit)`);
181
- return text.slice(0, LOCAL_MAX_INPUT_CHARS);
182
- }
183
- /**
184
- * Internal: perform the actual model download + load.
185
- * Called by startWarmup(), runs in background.
186
- */
187
- async _doInitialize() {
188
- // Track partially-initialized resources for cleanup on failure
189
- let model;
190
- try {
191
- this.logger?.debug?.(`${TAG} Loading node-llama-cpp for local embedding...`);
192
- // Dynamic import — node-llama-cpp is a peer dependency of OpenClaw
193
- const { getLlama, resolveModelFile, LlamaLogLevel } = await this.importLlama();
194
- const llama = await getLlama({ logLevel: LlamaLogLevel.error });
195
- this.logger?.debug?.(`${TAG} Llama instance created`);
196
- const resolvedPath = await resolveModelFile(this.modelPath, this.modelCacheDir || undefined);
197
- this.logger?.debug?.(`${TAG} Model resolved: ${resolvedPath}`);
198
- model = await llama.loadModel({ modelPath: resolvedPath });
199
- this.logger?.debug?.(`${TAG} Model loaded, creating embedding context...`);
200
- this.embeddingContext = await model.createEmbeddingContext();
201
- this.logger?.info(`${TAG} Local embedding ready (model=${this.modelPath}, dims=${LOCAL_DIMENSIONS})`);
202
- }
203
- catch (err) {
204
- // Clean up partially-initialized resources to prevent leaks
205
- if (model?.dispose) {
206
- try {
207
- model.dispose();
208
- }
209
- catch { /* best-effort */ }
210
- }
211
- this.embeddingContext = null;
212
- throw err;
213
- }
214
- }
215
- /**
216
- * Wait for ongoing warmup to complete (used internally by tests).
217
- * Returns immediately if already ready or idle.
218
- */
219
- async waitForReady() {
220
- if (this.initPromise) {
221
- await this.initPromise;
222
- }
223
- }
224
- }
225
- // ============================
226
- // OpenAI-compatible implementation
227
- // ============================
228
- /** Max texts per batch (OpenAI limit is 2048, we use a safe value) */
229
- const MAX_BATCH_SIZE = 256;
230
- /**
231
- * Max retries for embedding API calls (transient errors: network, 429, DNS).
232
- * Total attempts = MAX_RETRIES + 1. Exponential backoff: 500ms × attempt.
233
- */
234
- const MAX_RETRIES = 3;
235
- /** Default timeout per API call in milliseconds */
236
- const DEFAULT_API_TIMEOUT_MS = 10_000;
237
- /**
238
- * Custom error class for embedding API errors that carries HTTP status code.
239
- * Used to distinguish non-retryable client errors (4xx except 429) from
240
- * retryable server errors (5xx) and rate limits (429).
241
- */
242
- class EmbeddingApiError extends Error {
243
- httpStatus;
244
- constructor(message, httpStatus) {
245
- super(message);
246
- this.name = "EmbeddingApiError";
247
- this.httpStatus = httpStatus;
248
- }
249
- /** Returns true for 4xx errors that should NOT be retried (excluding 429). */
250
- isClientError() {
251
- return this.httpStatus >= 400 && this.httpStatus < 500 && this.httpStatus !== 429;
252
- }
253
- }
254
- // ============================
255
- // Shared HTTP helpers (provider-agnostic)
256
- // ============================
257
- /**
258
- * Truncate every text to `maxInputChars` (when set), emitting one warning
259
- * per text that exceeded the limit. Returns the input array untouched when
260
- * no limit is configured.
261
- */
262
- function truncateEmbeddingInputs(texts, maxInputChars, logger) {
263
- if (!maxInputChars)
264
- return texts;
265
- return texts.map((text) => {
266
- if (text.length <= maxInputChars)
267
- return text;
268
- logger?.warn?.(`${TAG} Input truncated from ${text.length} to ${maxInputChars} chars (maxInputChars limit)`);
269
- return text.slice(0, maxInputChars);
270
- });
271
- }
272
- /**
273
- * POST a remote embedding request with the project's standard timeout +
274
- * retry behaviour, returning the parsed JSON body. Provider-specific
275
- * services own body construction and response shape — this helper handles
276
- * fetch, abort-on-timeout, exponential backoff, and the `EmbeddingApiError`
277
- * non-retry rule for 4xx responses (except 429).
278
- */
279
- async function postEmbeddingRequest(params) {
280
- const { fetchUrl, headers, body, timeoutMs } = params;
281
- let lastError;
282
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
283
- try {
284
- const controller = new AbortController();
285
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
286
- try {
287
- const resp = await fetch(fetchUrl, {
288
- method: "POST",
289
- headers,
290
- body: JSON.stringify(body),
291
- signal: controller.signal,
292
- });
293
- if (!resp.ok) {
294
- const errBody = await resp.text().catch(() => "(unable to read body)");
295
- const err = new EmbeddingApiError(`Embedding API error: HTTP ${resp.status} ${resp.statusText} — ${errBody.slice(0, 500)}`, resp.status);
296
- // Don't retry 4xx client errors (except 429 rate limit).
297
- if (resp.status >= 400 && resp.status < 500 && resp.status !== 429) {
298
- throw err;
299
- }
300
- lastError = err;
301
- continue;
302
- }
303
- return await resp.json();
304
- }
305
- finally {
306
- clearTimeout(timeoutId);
307
- }
308
- }
309
- catch (err) {
310
- // Non-retryable errors (4xx client errors) — rethrow immediately
311
- if (err instanceof EmbeddingApiError && err.isClientError()) {
312
- throw err;
313
- }
314
- lastError = err instanceof Error ? err : new Error(String(err));
315
- // AbortError = timeout, retry
316
- if (attempt < MAX_RETRIES) {
317
- // Exponential backoff: 500ms, 1000ms
318
- const delay = 500 * (attempt + 1);
319
- await new Promise((r) => setTimeout(r, delay));
320
- }
321
- }
322
- }
323
- throw lastError ?? new Error("Embedding API call failed after retries");
324
- }
325
- export class OpenAIEmbeddingService {
326
- baseUrl;
327
- apiKey;
328
- model;
329
- dims;
330
- sendDimensions;
331
- providerName;
332
- proxyUrl;
333
- maxInputChars;
334
- timeoutMs;
335
- logger;
336
- constructor(config, logger) {
337
- if (!config.apiKey) {
338
- throw new Error("EmbeddingService: apiKey is required for remote provider");
339
- }
340
- if (!config.baseUrl) {
341
- throw new Error("EmbeddingService: baseUrl is required for remote provider");
342
- }
343
- if (!config.model) {
344
- throw new Error("EmbeddingService: model is required for remote provider");
345
- }
346
- if (!config.dimensions || config.dimensions <= 0) {
347
- throw new Error("EmbeddingService: dimensions is required for remote provider (must be a positive integer)");
348
- }
349
- this.baseUrl = config.baseUrl.replace(/\/+$/, "");
350
- this.apiKey = config.apiKey;
351
- this.model = config.model;
352
- this.dims = config.dimensions;
353
- this.sendDimensions = config.sendDimensions ?? true;
354
- this.providerName = config.provider || "openai";
355
- this.proxyUrl = config.proxyUrl?.trim() || undefined;
356
- this.maxInputChars = config.maxInputChars && config.maxInputChars > 0 ? config.maxInputChars : undefined;
357
- this.timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_API_TIMEOUT_MS;
358
- this.logger = logger;
359
- }
360
- getDimensions() {
361
- return this.dims;
362
- }
363
- getProviderInfo() {
364
- return { provider: this.providerName, model: this.model };
365
- }
366
- /** Remote embedding is always ready (stateless HTTP). */
367
- isReady() {
368
- return true;
369
- }
370
- /** No-op for remote embedding (no local model to warm up). */
371
- startWarmup() {
372
- // nothing to do — remote API is stateless
373
- }
374
- async embed(text, options) {
375
- const [result] = await this.embedBatch([text], options);
376
- return result;
377
- }
378
- async embedBatch(texts, options) {
379
- if (texts.length === 0)
380
- return [];
381
- // Truncate texts exceeding maxInputChars limit
382
- const processedTexts = this.maxInputChars
383
- ? texts.map((t) => this.truncateInput(t))
384
- : texts;
385
- // Split into sub-batches if needed
386
- if (processedTexts.length > MAX_BATCH_SIZE) {
387
- const results = [];
388
- for (let i = 0; i < processedTexts.length; i += MAX_BATCH_SIZE) {
389
- const chunk = processedTexts.slice(i, i + MAX_BATCH_SIZE);
390
- const chunkResults = await this._callApi(chunk, options?.timeoutMs);
391
- results.push(...chunkResults);
392
- }
393
- return results;
394
- }
395
- return this._callApi(processedTexts, options?.timeoutMs);
396
- }
397
- /**
398
- * Truncate input text to stay within the configured maxInputChars limit.
399
- * Logs a warning when truncation occurs.
400
- */
401
- truncateInput(text) {
402
- if (!this.maxInputChars || text.length <= this.maxInputChars)
403
- return text;
404
- this.logger?.warn?.(`${TAG} Input truncated from ${text.length} to ${this.maxInputChars} chars (maxInputChars limit)`);
405
- return text.slice(0, this.maxInputChars);
406
- }
407
- async _callApi(texts, timeoutOverride) {
408
- const body = {
409
- input: texts,
410
- model: this.model,
411
- };
412
- if (this.sendDimensions) {
413
- body.dimensions = this.dims;
414
- }
415
- // Determine fetch URL and headers based on proxy mode.
416
- const useProxy = this.providerName === "qclaw" && !!this.proxyUrl;
417
- const fetchUrl = useProxy ? this.proxyUrl : `${this.baseUrl}/embeddings`;
418
- const headers = {
419
- "Content-Type": "application/json",
420
- Authorization: `Bearer ${this.apiKey}`,
421
- };
422
- if (useProxy) {
423
- headers["Remote-URL"] = `${this.baseUrl}/embeddings`;
424
- this.logger?.debug?.(`${TAG} [qclaw-proxy] Forwarding embedding request via proxy: ${fetchUrl}, Remote-URL: ${headers["Remote-URL"]}`);
425
- }
426
- const json = (await postEmbeddingRequest({
427
- fetchUrl,
428
- headers,
429
- body,
430
- timeoutMs: timeoutOverride ?? this.timeoutMs,
431
- }));
432
- if (!json.data || !Array.isArray(json.data)) {
433
- throw new Error("Embedding API returned unexpected format: missing 'data' array");
434
- }
435
- // Sort by index to ensure correct order, then sanitize+normalize for consistency with local provider.
436
- const sorted = [...json.data].sort((a, b) => a.index - b.index);
437
- return sorted.map((d) => sanitizeAndNormalize(d.embedding));
438
- }
439
- }
440
- // ============================
441
- // ZeroEntropy embedding service
442
- // ============================
443
- /**
444
- * ZeroEntropy native embedding adapter.
445
- *
446
- * Reuses {@link OpenAIEmbeddingConfig} for the wire-config shape (baseUrl /
447
- * apiKey / model / dimensions / sendDimensions are identical), but the wire
448
- * format diverges in three places, so we keep this provider on its own class
449
- * instead of branching {@link OpenAIEmbeddingService}:
450
- *
451
- * 1. Endpoint is `${baseUrl}/models/embed` (not `/embeddings`).
452
- * 2. Request body requires `input_type` (`"query"` or `"document"`).
453
- * `dimensions` is optional — for `zembed-1` the accepted values are the
454
- * Matryoshka set [2560, 1280, 640, 320, 160, 80, 40]; any other value is
455
- * rejected by the server. The config's `sendDimensions` flag (default
456
- * true) controls whether it is forwarded, matching the OpenAI path.
457
- * 3. Response envelope is `{ results: [{ embedding }] }` and preserves
458
- * input order via array position rather than an `index` field.
459
- *
460
- * Everything else (timeout, retry, batching, char-cap truncation,
461
- * sanitize+normalize) is shared via the module-level
462
- * `postEmbeddingRequest` / `truncateEmbeddingInputs` helpers. See
463
- * https://docs.zeroentropy.dev/api-reference/models/embed and issue #68.
464
- */
465
- export class ZeroEntropyEmbeddingService {
466
- baseUrl;
467
- apiKey;
468
- model;
469
- dims;
470
- sendDimensions;
471
- maxInputChars;
472
- timeoutMs;
473
- logger;
474
- constructor(config, logger) {
475
- if (!config.apiKey) {
476
- throw new Error("ZeroEntropyEmbeddingService: apiKey is required");
477
- }
478
- if (!config.baseUrl) {
479
- throw new Error("ZeroEntropyEmbeddingService: baseUrl is required");
480
- }
481
- if (!config.model) {
482
- throw new Error("ZeroEntropyEmbeddingService: model is required");
483
- }
484
- if (!config.dimensions || config.dimensions <= 0) {
485
- throw new Error("ZeroEntropyEmbeddingService: dimensions is required (must be a positive integer)");
486
- }
487
- this.baseUrl = config.baseUrl.replace(/\/+$/, "");
488
- this.apiKey = config.apiKey;
489
- this.model = config.model;
490
- this.dims = config.dimensions;
491
- this.sendDimensions = config.sendDimensions ?? true;
492
- this.maxInputChars = config.maxInputChars && config.maxInputChars > 0 ? config.maxInputChars : undefined;
493
- this.timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_API_TIMEOUT_MS;
494
- this.logger = logger;
495
- }
496
- getDimensions() {
497
- return this.dims;
498
- }
499
- getProviderInfo() {
500
- return { provider: "zeroentropy", model: this.model };
501
- }
502
- /** Remote embedding is always ready (stateless HTTP). */
503
- isReady() {
504
- return true;
505
- }
506
- /** No-op for remote embedding (no local model to warm up). */
507
- startWarmup() {
508
- // nothing to do — remote API is stateless
509
- }
510
- async embed(text, options) {
511
- const [result] = await this.embedBatch([text], options);
512
- return result;
513
- }
514
- async embedBatch(texts, options) {
515
- if (texts.length === 0)
516
- return [];
517
- const processedTexts = truncateEmbeddingInputs(texts, this.maxInputChars, this.logger);
518
- if (processedTexts.length > MAX_BATCH_SIZE) {
519
- const results = [];
520
- for (let i = 0; i < processedTexts.length; i += MAX_BATCH_SIZE) {
521
- const chunk = processedTexts.slice(i, i + MAX_BATCH_SIZE);
522
- const chunkResults = await this._callApi(chunk, options?.timeoutMs);
523
- results.push(...chunkResults);
524
- }
525
- return results;
526
- }
527
- return this._callApi(processedTexts, options?.timeoutMs);
528
- }
529
- async _callApi(texts, timeoutOverride) {
530
- // ZeroEntropy rejects requests without `input_type`. We default to
531
- // "query" because the recall hot path is the only caller of embed()
532
- // that returns a Float32Array; capture-side batches eventually feed
533
- // the same vector store, and ZeroEntropy's symmetry between "query"
534
- // and "document" makes a single type safe across both directions.
535
- const body = {
536
- input: texts,
537
- model: this.model,
538
- input_type: "query",
539
- };
540
- if (this.sendDimensions) {
541
- // ZeroEntropy's docs list `dimensions` as optional. For zembed-1 the
542
- // accepted set is [2560, 1280, 640, 320, 160, 80, 40] (Matryoshka);
543
- // any other value is rejected server-side. We forward the user's
544
- // configured value verbatim — clamping silently would surprise users
545
- // who deliberately picked a smaller dim for storage savings.
546
- body.dimensions = this.dims;
547
- }
548
- const fetchUrl = `${this.baseUrl}/models/embed`;
549
- const headers = {
550
- "Content-Type": "application/json",
551
- Authorization: `Bearer ${this.apiKey}`,
552
- };
553
- const json = (await postEmbeddingRequest({
554
- fetchUrl,
555
- headers,
556
- body,
557
- timeoutMs: timeoutOverride ?? this.timeoutMs,
558
- }));
559
- if (!json.results || !Array.isArray(json.results)) {
560
- throw new Error("ZeroEntropy embedding API returned unexpected format: missing 'results' array");
561
- }
562
- // ZeroEntropy preserves input order via array position (no `index` field).
563
- return json.results.map((r) => sanitizeAndNormalize(r.embedding));
564
- }
565
- }
566
- // ============================
567
- // Factory
568
- // ============================
569
- /**
570
- * Create an EmbeddingService from config.
571
- *
572
- * Strategy:
573
- * - If config has provider != "local" with valid apiKey, model, and dimensions → use remote OpenAI-compatible embedding
574
- * - If config has provider="local" → use node-llama-cpp local embedding
575
- * - If config is undefined or missing required fields → fall back to local embedding
576
- *
577
- * NOTE: For local providers, `startWarmup()` is NOT called here.
578
- * The caller is responsible for calling `startWarmup()` at the right time
579
- * (e.g. on first conversation) to avoid triggering model download during
580
- * short-lived CLI commands like `gateway stop` or `agents list`.
581
- */
582
- export function createEmbeddingService(config, logger) {
583
- // ZeroEntropy speaks a non-OpenAI wire format and has its own service class.
584
- if (config && config.provider === "zeroentropy" && "apiKey" in config && config.apiKey) {
585
- logger?.debug?.(`${TAG} Using ZeroEntropy embedding (model=${config.model})`);
586
- return new ZeroEntropyEmbeddingService(config, logger);
587
- }
588
- // Remote OpenAI-compatible provider: any provider value other than "local"
589
- if (config && config.provider !== "local" && "apiKey" in config && config.apiKey) {
590
- logger?.debug?.(`${TAG} Using remote embedding (provider=${config.provider}, model=${config.model})`);
591
- return new OpenAIEmbeddingService(config, logger);
592
- }
593
- // Explicit local config
594
- if (config && config.provider === "local") {
595
- const localConfig = config;
596
- logger?.debug?.(`${TAG} Using local embedding (node-llama-cpp, model=${localConfig.modelPath ?? DEFAULT_LOCAL_MODEL})`);
597
- return new LocalEmbeddingService(localConfig, logger);
598
- }
599
- // Fallback: no config or empty apiKey → use local
600
- logger?.debug?.(`${TAG} No remote embedding configured, falling back to local embedding (node-llama-cpp)`);
601
- return new LocalEmbeddingService(undefined, logger);
602
- }
603
- // ============================
604
- // NoopEmbeddingService (for server-side embedding backends)
605
- // ============================
606
- /**
607
- * No-op embedding service for backends with built-in server-side embedding
608
- * (e.g., TCVDB with Collection-level embedding config).
609
- *
610
- * All embed() calls return an empty Float32Array because the server generates
611
- * vectors automatically from the text field during upsert/search.
612
- */
613
- export class NoopEmbeddingService {
614
- embed(_text) {
615
- return Promise.resolve(new Float32Array(0));
616
- }
617
- embedBatch(texts) {
618
- return Promise.resolve(texts.map(() => new Float32Array(0)));
619
- }
620
- getDimensions() {
621
- return 0;
622
- }
623
- getProviderInfo() {
624
- return { provider: "noop", model: "server-side" };
625
- }
626
- isReady() {
627
- return true;
628
- }
629
- startWarmup() {
630
- // no-op
631
- }
632
- }
1
+ /**
2
+ * Embedding Service: converts text to vector embeddings.
3
+ *
4
+ * Supports two providers:
5
+ * - "openai": OpenAI-compatible embedding APIs (OpenAI, Azure OpenAI, self-hosted)
6
+ * - "local": node-llama-cpp with embeddinggemma-300m GGUF model (fully offline)
7
+ *
8
+ * When no remote embedding is configured, automatically falls back to local provider.
9
+ *
10
+ * Design:
11
+ * - Single `embed()` for one text, `embedBatch()` for multiple.
12
+ * - `getDimensions()` returns configured vector dimensions.
13
+ * - Throws on failure; callers decide fallback strategy.
14
+ */
15
+ /**
16
+ * Error thrown when embed() / embedBatch() is called before the local
17
+ * embedding model has finished downloading and loading.
18
+ * Callers should catch this and fall back to keyword-only mode.
19
+ */
20
+ export class EmbeddingNotReadyError extends Error {
21
+ constructor(message) {
22
+ super(message ?? "Local embedding model is not ready yet (still downloading or loading)");
23
+ this.name = "EmbeddingNotReadyError";
24
+ }
25
+ }
26
+ const TAG = "[memory-tdai][embedding]";
27
+ // ============================
28
+ // Local (node-llama-cpp) implementation
29
+ // ============================
30
+ /** Default model: Google's embeddinggemma-300m, quantized Q8_0 (~300MB) */
31
+ const DEFAULT_LOCAL_MODEL = "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf";
32
+ /** embeddinggemma-300m outputs 768-dimensional vectors */
33
+ const LOCAL_DIMENSIONS = 768;
34
+ /**
35
+ * embeddinggemma-300m has a 256-token context window.
36
+ * As a safe heuristic, we limit input to ~600 chars for CJK text
37
+ * (CJK characters typically tokenize to 1-2 tokens each,
38
+ * so 600 chars ≈ 200-400 tokens, keeping well within 256-token limit
39
+ * after accounting for special tokens).
40
+ * For Latin text, ~800 chars is a safe limit (~200 tokens).
41
+ * We use 512 chars as a conservative universal limit.
42
+ */
43
+ const LOCAL_MAX_INPUT_CHARS = 512;
44
+ /**
45
+ * Sanitize NaN/Inf values and L2-normalize the vector.
46
+ * Matches OpenClaw's own sanitizeAndNormalizeEmbedding().
47
+ */
48
+ function sanitizeAndNormalize(vec) {
49
+ const arr = Array.from(vec).map((v) => (Number.isFinite(v) ? v : 0));
50
+ const magnitude = Math.sqrt(arr.reduce((sum, v) => sum + v * v, 0));
51
+ if (magnitude < 1e-10) {
52
+ return new Float32Array(arr);
53
+ }
54
+ return new Float32Array(arr.map((v) => v / magnitude));
55
+ }
56
+ const defaultImportLlama = () => import("node-llama-cpp");
57
+ export class LocalEmbeddingService {
58
+ modelPath;
59
+ modelCacheDir;
60
+ logger;
61
+ importLlama;
62
+ // Initialization state machine
63
+ initState = "idle";
64
+ initPromise = null;
65
+ initError = null;
66
+ embeddingContext = null;
67
+ constructor(config, logger, importLlama) {
68
+ this.modelPath = config?.modelPath?.trim() || DEFAULT_LOCAL_MODEL;
69
+ this.modelCacheDir = config?.modelCacheDir?.trim();
70
+ this.logger = logger;
71
+ this.importLlama = importLlama ?? defaultImportLlama;
72
+ }
73
+ getDimensions() {
74
+ return LOCAL_DIMENSIONS;
75
+ }
76
+ getProviderInfo() {
77
+ return { provider: "local", model: this.modelPath };
78
+ }
79
+ /**
80
+ * Whether the local model is fully loaded and ready to serve requests.
81
+ */
82
+ isReady() {
83
+ return this.initState === "ready" && this.embeddingContext !== null;
84
+ }
85
+ /**
86
+ * Start background warmup: download model (if needed) and load into memory.
87
+ * Does NOT block the caller — returns immediately.
88
+ * Safe to call multiple times (idempotent); re-triggers on "failed" state.
89
+ */
90
+ startWarmup() {
91
+ if (this.initState === "initializing" || this.initState === "ready") {
92
+ return; // already in progress or done
93
+ }
94
+ this.logger?.info(`${TAG} Starting background warmup for local embedding model...`);
95
+ this.initState = "initializing";
96
+ this.initError = null;
97
+ this.initPromise = this._doInitialize()
98
+ .then(() => {
99
+ this.initState = "ready";
100
+ this.logger?.info(`${TAG} Background warmup complete — local embedding ready`);
101
+ })
102
+ .catch((err) => {
103
+ this.initState = "failed";
104
+ this.initError = err instanceof Error ? err : new Error(String(err));
105
+ this.logger?.error(`${TAG} Background warmup failed: ${this.initError.message}. ` +
106
+ `embed() calls will throw EmbeddingNotReadyError until retried.`);
107
+ });
108
+ }
109
+ /**
110
+ * Get embedding for a single text.
111
+ * @throws {EmbeddingNotReadyError} if model is not yet ready.
112
+ */
113
+ async embed(text, _options) {
114
+ this.assertReady();
115
+ const truncated = this.truncateInput(text);
116
+ const embedding = await this.embeddingContext.getEmbeddingFor(truncated);
117
+ return sanitizeAndNormalize(embedding.vector);
118
+ }
119
+ /**
120
+ * Get embeddings for multiple texts.
121
+ * @throws {EmbeddingNotReadyError} if model is not yet ready.
122
+ */
123
+ async embedBatch(texts, _options) {
124
+ if (texts.length === 0)
125
+ return [];
126
+ this.assertReady();
127
+ const results = [];
128
+ for (const text of texts) {
129
+ const truncated = this.truncateInput(text);
130
+ const embedding = await this.embeddingContext.getEmbeddingFor(truncated);
131
+ results.push(sanitizeAndNormalize(embedding.vector));
132
+ }
133
+ return results;
134
+ }
135
+ /**
136
+ * Release the node-llama-cpp embedding context and model resources.
137
+ * Safe to call multiple times (idempotent).
138
+ */
139
+ close() {
140
+ if (this.embeddingContext) {
141
+ try {
142
+ const ctx = this.embeddingContext;
143
+ ctx.dispose?.();
144
+ }
145
+ catch {
146
+ // best-effort cleanup
147
+ }
148
+ this.embeddingContext = null;
149
+ this.initPromise = null;
150
+ this.initState = "idle";
151
+ this.initError = null;
152
+ this.logger?.info(`${TAG} Local embedding resources released`);
153
+ }
154
+ }
155
+ /**
156
+ * Assert the model is ready. Throws EmbeddingNotReadyError if not.
157
+ */
158
+ assertReady() {
159
+ if (this.initState === "ready" && this.embeddingContext) {
160
+ return;
161
+ }
162
+ if (this.initState === "failed") {
163
+ throw new EmbeddingNotReadyError(`Local embedding model initialization failed: ${this.initError?.message ?? "unknown error"}. ` +
164
+ `Call startWarmup() to retry.`);
165
+ }
166
+ if (this.initState === "initializing") {
167
+ throw new EmbeddingNotReadyError("Local embedding model is still loading (download/initialization in progress). Please try again later.");
168
+ }
169
+ // "idle" — startWarmup() was never called
170
+ throw new EmbeddingNotReadyError("Local embedding model warmup has not been started. Call startWarmup() first.");
171
+ }
172
+ /**
173
+ * Truncate input text to stay within the model's context window.
174
+ * embeddinggemma-300m has a 256-token limit; we use a character-based
175
+ * heuristic (LOCAL_MAX_INPUT_CHARS) as a safe proxy.
176
+ */
177
+ truncateInput(text) {
178
+ if (text.length <= LOCAL_MAX_INPUT_CHARS)
179
+ return text;
180
+ this.logger?.debug?.(`${TAG} Input truncated from ${text.length} to ${LOCAL_MAX_INPUT_CHARS} chars (model context limit)`);
181
+ return text.slice(0, LOCAL_MAX_INPUT_CHARS);
182
+ }
183
+ /**
184
+ * Internal: perform the actual model download + load.
185
+ * Called by startWarmup(), runs in background.
186
+ */
187
+ async _doInitialize() {
188
+ // Track partially-initialized resources for cleanup on failure
189
+ let model;
190
+ try {
191
+ this.logger?.debug?.(`${TAG} Loading node-llama-cpp for local embedding...`);
192
+ // Dynamic import — node-llama-cpp is a peer dependency of OpenClaw
193
+ const { getLlama, resolveModelFile, LlamaLogLevel } = await this.importLlama();
194
+ const llama = await getLlama({ logLevel: LlamaLogLevel.error });
195
+ this.logger?.debug?.(`${TAG} Llama instance created`);
196
+ const resolvedPath = await resolveModelFile(this.modelPath, this.modelCacheDir || undefined);
197
+ this.logger?.debug?.(`${TAG} Model resolved: ${resolvedPath}`);
198
+ model = await llama.loadModel({ modelPath: resolvedPath });
199
+ this.logger?.debug?.(`${TAG} Model loaded, creating embedding context...`);
200
+ this.embeddingContext = await model.createEmbeddingContext();
201
+ this.logger?.info(`${TAG} Local embedding ready (model=${this.modelPath}, dims=${LOCAL_DIMENSIONS})`);
202
+ }
203
+ catch (err) {
204
+ // Clean up partially-initialized resources to prevent leaks
205
+ if (model?.dispose) {
206
+ try {
207
+ model.dispose();
208
+ }
209
+ catch { /* best-effort */ }
210
+ }
211
+ this.embeddingContext = null;
212
+ throw err;
213
+ }
214
+ }
215
+ /**
216
+ * Wait for ongoing warmup to complete (used internally by tests).
217
+ * Returns immediately if already ready or idle.
218
+ */
219
+ async waitForReady() {
220
+ if (this.initPromise) {
221
+ await this.initPromise;
222
+ }
223
+ }
224
+ }
225
+ // ============================
226
+ // OpenAI-compatible implementation
227
+ // ============================
228
+ /** Max texts per batch (OpenAI limit is 2048, we use a safe value) */
229
+ const MAX_BATCH_SIZE = 256;
230
+ /**
231
+ * Max retries for embedding API calls (transient errors: network, 429, DNS).
232
+ * Total attempts = MAX_RETRIES + 1. Exponential backoff: 500ms × attempt.
233
+ */
234
+ const MAX_RETRIES = 3;
235
+ /** Default timeout per API call in milliseconds */
236
+ const DEFAULT_API_TIMEOUT_MS = 10_000;
237
+ /**
238
+ * Custom error class for embedding API errors that carries HTTP status code.
239
+ * Used to distinguish non-retryable client errors (4xx except 429) from
240
+ * retryable server errors (5xx) and rate limits (429).
241
+ */
242
+ class EmbeddingApiError extends Error {
243
+ httpStatus;
244
+ constructor(message, httpStatus) {
245
+ super(message);
246
+ this.name = "EmbeddingApiError";
247
+ this.httpStatus = httpStatus;
248
+ }
249
+ /** Returns true for 4xx errors that should NOT be retried (excluding 429). */
250
+ isClientError() {
251
+ return this.httpStatus >= 400 && this.httpStatus < 500 && this.httpStatus !== 429;
252
+ }
253
+ }
254
+ // ============================
255
+ // Shared HTTP helpers (provider-agnostic)
256
+ // ============================
257
+ /**
258
+ * Truncate every text to `maxInputChars` (when set), emitting one warning
259
+ * per text that exceeded the limit. Returns the input array untouched when
260
+ * no limit is configured.
261
+ */
262
+ function truncateEmbeddingInputs(texts, maxInputChars, logger) {
263
+ if (!maxInputChars)
264
+ return texts;
265
+ return texts.map((text) => {
266
+ if (text.length <= maxInputChars)
267
+ return text;
268
+ logger?.warn?.(`${TAG} Input truncated from ${text.length} to ${maxInputChars} chars (maxInputChars limit)`);
269
+ return text.slice(0, maxInputChars);
270
+ });
271
+ }
272
+ /**
273
+ * POST a remote embedding request with the project's standard timeout +
274
+ * retry behaviour, returning the parsed JSON body. Provider-specific
275
+ * services own body construction and response shape — this helper handles
276
+ * fetch, abort-on-timeout, exponential backoff, and the `EmbeddingApiError`
277
+ * non-retry rule for 4xx responses (except 429).
278
+ */
279
+ async function postEmbeddingRequest(params) {
280
+ const { fetchUrl, headers, body, timeoutMs } = params;
281
+ let lastError;
282
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
283
+ try {
284
+ const controller = new AbortController();
285
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
286
+ try {
287
+ const resp = await fetch(fetchUrl, {
288
+ method: "POST",
289
+ headers,
290
+ body: JSON.stringify(body),
291
+ signal: controller.signal,
292
+ });
293
+ if (!resp.ok) {
294
+ const errBody = await resp.text().catch(() => "(unable to read body)");
295
+ const err = new EmbeddingApiError(`Embedding API error: HTTP ${resp.status} ${resp.statusText} — ${errBody.slice(0, 500)}`, resp.status);
296
+ // Don't retry 4xx client errors (except 429 rate limit).
297
+ if (resp.status >= 400 && resp.status < 500 && resp.status !== 429) {
298
+ throw err;
299
+ }
300
+ lastError = err;
301
+ continue;
302
+ }
303
+ return await resp.json();
304
+ }
305
+ finally {
306
+ clearTimeout(timeoutId);
307
+ }
308
+ }
309
+ catch (err) {
310
+ // Non-retryable errors (4xx client errors) — rethrow immediately
311
+ if (err instanceof EmbeddingApiError && err.isClientError()) {
312
+ throw err;
313
+ }
314
+ lastError = err instanceof Error ? err : new Error(String(err));
315
+ // AbortError = timeout, retry
316
+ if (attempt < MAX_RETRIES) {
317
+ // Exponential backoff: 500ms, 1000ms
318
+ const delay = 500 * (attempt + 1);
319
+ await new Promise((r) => setTimeout(r, delay));
320
+ }
321
+ }
322
+ }
323
+ throw lastError ?? new Error("Embedding API call failed after retries");
324
+ }
325
+ export class OpenAIEmbeddingService {
326
+ baseUrl;
327
+ apiKey;
328
+ model;
329
+ dims;
330
+ sendDimensions;
331
+ providerName;
332
+ proxyUrl;
333
+ maxInputChars;
334
+ timeoutMs;
335
+ logger;
336
+ constructor(config, logger) {
337
+ if (!config.apiKey) {
338
+ throw new Error("EmbeddingService: apiKey is required for remote provider");
339
+ }
340
+ if (!config.baseUrl) {
341
+ throw new Error("EmbeddingService: baseUrl is required for remote provider");
342
+ }
343
+ if (!config.model) {
344
+ throw new Error("EmbeddingService: model is required for remote provider");
345
+ }
346
+ if (!config.dimensions || config.dimensions <= 0) {
347
+ throw new Error("EmbeddingService: dimensions is required for remote provider (must be a positive integer)");
348
+ }
349
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
350
+ this.apiKey = config.apiKey;
351
+ this.model = config.model;
352
+ this.dims = config.dimensions;
353
+ this.sendDimensions = config.sendDimensions ?? true;
354
+ this.providerName = config.provider || "openai";
355
+ this.proxyUrl = config.proxyUrl?.trim() || undefined;
356
+ this.maxInputChars = config.maxInputChars && config.maxInputChars > 0 ? config.maxInputChars : undefined;
357
+ this.timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_API_TIMEOUT_MS;
358
+ this.logger = logger;
359
+ }
360
+ getDimensions() {
361
+ return this.dims;
362
+ }
363
+ getProviderInfo() {
364
+ return { provider: this.providerName, model: this.model };
365
+ }
366
+ /** Remote embedding is always ready (stateless HTTP). */
367
+ isReady() {
368
+ return true;
369
+ }
370
+ /** No-op for remote embedding (no local model to warm up). */
371
+ startWarmup() {
372
+ // nothing to do — remote API is stateless
373
+ }
374
+ async embed(text, options) {
375
+ const [result] = await this.embedBatch([text], options);
376
+ return result;
377
+ }
378
+ async embedBatch(texts, options) {
379
+ if (texts.length === 0)
380
+ return [];
381
+ // Truncate texts exceeding maxInputChars limit
382
+ const processedTexts = this.maxInputChars
383
+ ? texts.map((t) => this.truncateInput(t))
384
+ : texts;
385
+ // Split into sub-batches if needed
386
+ if (processedTexts.length > MAX_BATCH_SIZE) {
387
+ const results = [];
388
+ for (let i = 0; i < processedTexts.length; i += MAX_BATCH_SIZE) {
389
+ const chunk = processedTexts.slice(i, i + MAX_BATCH_SIZE);
390
+ const chunkResults = await this._callApi(chunk, options?.timeoutMs);
391
+ results.push(...chunkResults);
392
+ }
393
+ return results;
394
+ }
395
+ return this._callApi(processedTexts, options?.timeoutMs);
396
+ }
397
+ /**
398
+ * Truncate input text to stay within the configured maxInputChars limit.
399
+ * Logs a warning when truncation occurs.
400
+ */
401
+ truncateInput(text) {
402
+ if (!this.maxInputChars || text.length <= this.maxInputChars)
403
+ return text;
404
+ this.logger?.warn?.(`${TAG} Input truncated from ${text.length} to ${this.maxInputChars} chars (maxInputChars limit)`);
405
+ return text.slice(0, this.maxInputChars);
406
+ }
407
+ async _callApi(texts, timeoutOverride) {
408
+ const body = {
409
+ input: texts,
410
+ model: this.model,
411
+ };
412
+ if (this.sendDimensions) {
413
+ body.dimensions = this.dims;
414
+ }
415
+ // Determine fetch URL and headers based on proxy mode.
416
+ const useProxy = this.providerName === "qclaw" && !!this.proxyUrl;
417
+ const fetchUrl = useProxy ? this.proxyUrl : `${this.baseUrl}/embeddings`;
418
+ const headers = {
419
+ "Content-Type": "application/json",
420
+ Authorization: `Bearer ${this.apiKey}`,
421
+ };
422
+ if (useProxy) {
423
+ headers["Remote-URL"] = `${this.baseUrl}/embeddings`;
424
+ this.logger?.debug?.(`${TAG} [qclaw-proxy] Forwarding embedding request via proxy: ${fetchUrl}, Remote-URL: ${headers["Remote-URL"]}`);
425
+ }
426
+ const json = (await postEmbeddingRequest({
427
+ fetchUrl,
428
+ headers,
429
+ body,
430
+ timeoutMs: timeoutOverride ?? this.timeoutMs,
431
+ }));
432
+ if (!json.data || !Array.isArray(json.data)) {
433
+ throw new Error("Embedding API returned unexpected format: missing 'data' array");
434
+ }
435
+ // Sort by index to ensure correct order, then sanitize+normalize for consistency with local provider.
436
+ const sorted = [...json.data].sort((a, b) => a.index - b.index);
437
+ return sorted.map((d) => sanitizeAndNormalize(d.embedding));
438
+ }
439
+ }
440
+ // ============================
441
+ // ZeroEntropy embedding service
442
+ // ============================
443
+ /**
444
+ * ZeroEntropy native embedding adapter.
445
+ *
446
+ * Reuses {@link OpenAIEmbeddingConfig} for the wire-config shape (baseUrl /
447
+ * apiKey / model / dimensions / sendDimensions are identical), but the wire
448
+ * format diverges in three places, so we keep this provider on its own class
449
+ * instead of branching {@link OpenAIEmbeddingService}:
450
+ *
451
+ * 1. Endpoint is `${baseUrl}/models/embed` (not `/embeddings`).
452
+ * 2. Request body requires `input_type` (`"query"` or `"document"`).
453
+ * `dimensions` is optional — for `zembed-1` the accepted values are the
454
+ * Matryoshka set [2560, 1280, 640, 320, 160, 80, 40]; any other value is
455
+ * rejected by the server. The config's `sendDimensions` flag (default
456
+ * true) controls whether it is forwarded, matching the OpenAI path.
457
+ * 3. Response envelope is `{ results: [{ embedding }] }` and preserves
458
+ * input order via array position rather than an `index` field.
459
+ *
460
+ * Everything else (timeout, retry, batching, char-cap truncation,
461
+ * sanitize+normalize) is shared via the module-level
462
+ * `postEmbeddingRequest` / `truncateEmbeddingInputs` helpers. See
463
+ * https://docs.zeroentropy.dev/api-reference/models/embed and issue #68.
464
+ */
465
+ export class ZeroEntropyEmbeddingService {
466
+ baseUrl;
467
+ apiKey;
468
+ model;
469
+ dims;
470
+ sendDimensions;
471
+ maxInputChars;
472
+ timeoutMs;
473
+ logger;
474
+ constructor(config, logger) {
475
+ if (!config.apiKey) {
476
+ throw new Error("ZeroEntropyEmbeddingService: apiKey is required");
477
+ }
478
+ if (!config.baseUrl) {
479
+ throw new Error("ZeroEntropyEmbeddingService: baseUrl is required");
480
+ }
481
+ if (!config.model) {
482
+ throw new Error("ZeroEntropyEmbeddingService: model is required");
483
+ }
484
+ if (!config.dimensions || config.dimensions <= 0) {
485
+ throw new Error("ZeroEntropyEmbeddingService: dimensions is required (must be a positive integer)");
486
+ }
487
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
488
+ this.apiKey = config.apiKey;
489
+ this.model = config.model;
490
+ this.dims = config.dimensions;
491
+ this.sendDimensions = config.sendDimensions ?? true;
492
+ this.maxInputChars = config.maxInputChars && config.maxInputChars > 0 ? config.maxInputChars : undefined;
493
+ this.timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_API_TIMEOUT_MS;
494
+ this.logger = logger;
495
+ }
496
+ getDimensions() {
497
+ return this.dims;
498
+ }
499
+ getProviderInfo() {
500
+ return { provider: "zeroentropy", model: this.model };
501
+ }
502
+ /** Remote embedding is always ready (stateless HTTP). */
503
+ isReady() {
504
+ return true;
505
+ }
506
+ /** No-op for remote embedding (no local model to warm up). */
507
+ startWarmup() {
508
+ // nothing to do — remote API is stateless
509
+ }
510
+ async embed(text, options) {
511
+ const [result] = await this.embedBatch([text], options);
512
+ return result;
513
+ }
514
+ async embedBatch(texts, options) {
515
+ if (texts.length === 0)
516
+ return [];
517
+ const processedTexts = truncateEmbeddingInputs(texts, this.maxInputChars, this.logger);
518
+ if (processedTexts.length > MAX_BATCH_SIZE) {
519
+ const results = [];
520
+ for (let i = 0; i < processedTexts.length; i += MAX_BATCH_SIZE) {
521
+ const chunk = processedTexts.slice(i, i + MAX_BATCH_SIZE);
522
+ const chunkResults = await this._callApi(chunk, options?.timeoutMs);
523
+ results.push(...chunkResults);
524
+ }
525
+ return results;
526
+ }
527
+ return this._callApi(processedTexts, options?.timeoutMs);
528
+ }
529
+ async _callApi(texts, timeoutOverride) {
530
+ // ZeroEntropy rejects requests without `input_type`. We default to
531
+ // "query" because the recall hot path is the only caller of embed()
532
+ // that returns a Float32Array; capture-side batches eventually feed
533
+ // the same vector store, and ZeroEntropy's symmetry between "query"
534
+ // and "document" makes a single type safe across both directions.
535
+ const body = {
536
+ input: texts,
537
+ model: this.model,
538
+ input_type: "query",
539
+ };
540
+ if (this.sendDimensions) {
541
+ // ZeroEntropy's docs list `dimensions` as optional. For zembed-1 the
542
+ // accepted set is [2560, 1280, 640, 320, 160, 80, 40] (Matryoshka);
543
+ // any other value is rejected server-side. We forward the user's
544
+ // configured value verbatim — clamping silently would surprise users
545
+ // who deliberately picked a smaller dim for storage savings.
546
+ body.dimensions = this.dims;
547
+ }
548
+ const fetchUrl = `${this.baseUrl}/models/embed`;
549
+ const headers = {
550
+ "Content-Type": "application/json",
551
+ Authorization: `Bearer ${this.apiKey}`,
552
+ };
553
+ const json = (await postEmbeddingRequest({
554
+ fetchUrl,
555
+ headers,
556
+ body,
557
+ timeoutMs: timeoutOverride ?? this.timeoutMs,
558
+ }));
559
+ if (!json.results || !Array.isArray(json.results)) {
560
+ throw new Error("ZeroEntropy embedding API returned unexpected format: missing 'results' array");
561
+ }
562
+ // ZeroEntropy preserves input order via array position (no `index` field).
563
+ return json.results.map((r) => sanitizeAndNormalize(r.embedding));
564
+ }
565
+ }
566
+ // ============================
567
+ // Factory
568
+ // ============================
569
+ /**
570
+ * Create an EmbeddingService from config.
571
+ *
572
+ * Strategy:
573
+ * - If config has provider != "local" with valid apiKey, model, and dimensions → use remote OpenAI-compatible embedding
574
+ * - If config has provider="local" → use node-llama-cpp local embedding
575
+ * - If config is undefined or missing required fields → fall back to local embedding
576
+ *
577
+ * NOTE: For local providers, `startWarmup()` is NOT called here.
578
+ * The caller is responsible for calling `startWarmup()` at the right time
579
+ * (e.g. on first conversation) to avoid triggering model download during
580
+ * short-lived CLI commands like `gateway stop` or `agents list`.
581
+ */
582
+ export function createEmbeddingService(config, logger) {
583
+ // ZeroEntropy speaks a non-OpenAI wire format and has its own service class.
584
+ if (config && config.provider === "zeroentropy" && "apiKey" in config && config.apiKey) {
585
+ logger?.debug?.(`${TAG} Using ZeroEntropy embedding (model=${config.model})`);
586
+ return new ZeroEntropyEmbeddingService(config, logger);
587
+ }
588
+ // Remote OpenAI-compatible provider: any provider value other than "local"
589
+ if (config && config.provider !== "local" && "apiKey" in config && config.apiKey) {
590
+ logger?.debug?.(`${TAG} Using remote embedding (provider=${config.provider}, model=${config.model})`);
591
+ return new OpenAIEmbeddingService(config, logger);
592
+ }
593
+ // Explicit local config
594
+ if (config && config.provider === "local") {
595
+ const localConfig = config;
596
+ logger?.debug?.(`${TAG} Using local embedding (node-llama-cpp, model=${localConfig.modelPath ?? DEFAULT_LOCAL_MODEL})`);
597
+ return new LocalEmbeddingService(localConfig, logger);
598
+ }
599
+ // Fallback: no config or empty apiKey → use local
600
+ logger?.debug?.(`${TAG} No remote embedding configured, falling back to local embedding (node-llama-cpp)`);
601
+ return new LocalEmbeddingService(undefined, logger);
602
+ }
603
+ // ============================
604
+ // NoopEmbeddingService (for server-side embedding backends)
605
+ // ============================
606
+ /**
607
+ * No-op embedding service for backends with built-in server-side embedding
608
+ * (e.g., TCVDB with Collection-level embedding config).
609
+ *
610
+ * All embed() calls return an empty Float32Array because the server generates
611
+ * vectors automatically from the text field during upsert/search.
612
+ */
613
+ export class NoopEmbeddingService {
614
+ embed(_text) {
615
+ return Promise.resolve(new Float32Array(0));
616
+ }
617
+ embedBatch(texts) {
618
+ return Promise.resolve(texts.map(() => new Float32Array(0)));
619
+ }
620
+ getDimensions() {
621
+ return 0;
622
+ }
623
+ getProviderInfo() {
624
+ return { provider: "noop", model: "server-side" };
625
+ }
626
+ isReady() {
627
+ return true;
628
+ }
629
+ startWarmup() {
630
+ // no-op
631
+ }
632
+ }