@remnic/core 9.3.563 → 9.3.564

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 (156) hide show
  1. package/dist/access-cli.js +40 -39
  2. package/dist/access-cli.js.map +1 -1
  3. package/dist/access-http.js +16 -16
  4. package/dist/access-mcp.js +13 -13
  5. package/dist/access-schema.js +3 -3
  6. package/dist/access-service.js +11 -11
  7. package/dist/active-recall.js +1 -1
  8. package/dist/adapters/index.js +4 -4
  9. package/dist/adapters/registry.js +2 -2
  10. package/dist/briefing.js +4 -4
  11. package/dist/causal-consolidation.js +5 -5
  12. package/dist/{chunk-I2K6KCVC.js → chunk-2FHLI4U6.js} +49 -49
  13. package/dist/chunk-3ONXXHQO.js +57 -0
  14. package/dist/chunk-3ONXXHQO.js.map +1 -0
  15. package/dist/{chunk-5GX5MUQ2.js → chunk-574MU2Y3.js} +3 -3
  16. package/dist/{chunk-65OLPXBU.js → chunk-5WB4C7KM.js} +6 -6
  17. package/dist/chunk-6PTSXBPE.js +483 -0
  18. package/dist/chunk-6PTSXBPE.js.map +1 -0
  19. package/dist/{chunk-Z56KAZQL.js → chunk-74VA26CT.js} +2 -2
  20. package/dist/{chunk-CC2ESOOG.js → chunk-7X7TBJRX.js} +2 -2
  21. package/dist/{chunk-O4M4WH6V.js → chunk-ARY5OOLG.js} +2 -2
  22. package/dist/{chunk-JBPKEARU.js → chunk-AU7Q3LSC.js} +4 -4
  23. package/dist/{chunk-PM3QHTFT.js → chunk-CF3ZF2YU.js} +3 -3
  24. package/dist/{chunk-SI3QCHWF.js → chunk-DARLGSFX.js} +5 -5
  25. package/dist/chunk-EWLQPEO6.js +308 -0
  26. package/dist/chunk-EWLQPEO6.js.map +1 -0
  27. package/dist/{chunk-FVCZINOF.js → chunk-FHBEL473.js} +2 -2
  28. package/dist/{chunk-7Q3RCKAQ.js → chunk-FXKPZ3H6.js} +2 -2
  29. package/dist/{chunk-5WLYNZPC.js → chunk-GBXGCFRH.js} +2 -2
  30. package/dist/{chunk-ILJXM3FV.js → chunk-HQO5EBUC.js} +10 -10
  31. package/dist/{chunk-FK556DDH.js → chunk-I4UNL747.js} +4 -4
  32. package/dist/{chunk-RLPIT4YI.js → chunk-IOTTZLFF.js} +38 -38
  33. package/dist/{chunk-TVZ6LKKS.js → chunk-IRFF6LSF.js} +8 -8
  34. package/dist/{chunk-M5T4Q2ZU.js → chunk-KGK2QKWL.js} +1 -1
  35. package/dist/chunk-KGK2QKWL.js.map +1 -0
  36. package/dist/{chunk-IPLYGWQF.js → chunk-KQAFEZQX.js} +5 -5
  37. package/dist/chunk-M46RYSMW.js +597 -0
  38. package/dist/chunk-M46RYSMW.js.map +1 -0
  39. package/dist/{chunk-KXULCVOC.js → chunk-M6I5Z4SR.js} +4 -2
  40. package/dist/chunk-M6I5Z4SR.js.map +1 -0
  41. package/dist/{chunk-JFN6K74Q.js → chunk-MQEIWDYW.js} +2 -2
  42. package/dist/{chunk-7H6CFEBJ.js → chunk-NZPF2SYV.js} +8 -1
  43. package/dist/{chunk-7H6CFEBJ.js.map → chunk-NZPF2SYV.js.map} +1 -1
  44. package/dist/{chunk-SML26KED.js → chunk-OB6353F7.js} +16 -12
  45. package/dist/chunk-OB6353F7.js.map +1 -0
  46. package/dist/{chunk-SOTR74FK.js → chunk-OPYFD6PD.js} +2 -2
  47. package/dist/{chunk-3C5RPJAX.js → chunk-OXJBNGBK.js} +2 -2
  48. package/dist/{chunk-BD5LHQWD.js → chunk-PPPZY2EU.js} +2 -2
  49. package/dist/{chunk-25BY3HHZ.js → chunk-SUTSSOYU.js} +2 -2
  50. package/dist/{chunk-KS7WO6EQ.js → chunk-VFB2G5YL.js} +20 -20
  51. package/dist/{chunk-BUUYY2H2.js → chunk-WP5OWVLZ.js} +4 -4
  52. package/dist/{chunk-6URPAY2D.js → chunk-XCAZF7KQ.js} +207 -53
  53. package/dist/chunk-XCAZF7KQ.js.map +1 -0
  54. package/dist/{chunk-S53PAX2V.js → chunk-XM7BYXT7.js} +2 -2
  55. package/dist/{chunk-FADZBOR4.js → chunk-XRWTAEZM.js} +2 -2
  56. package/dist/{chunk-E5OECWZ5.js → chunk-XT7XVA53.js} +2 -2
  57. package/dist/{chunk-R3PS27B4.js → chunk-Z4R6RI2N.js} +2 -2
  58. package/dist/cli.js +44 -43
  59. package/dist/compounding/engine.js +4 -4
  60. package/dist/config.js +1 -1
  61. package/dist/connectors/codex-materialize-runner.js +4 -4
  62. package/dist/connectors/index.js +4 -4
  63. package/dist/embedding-fallback.d.ts +12 -1
  64. package/dist/embedding-fallback.js +4 -1
  65. package/dist/entity-retrieval.js +4 -4
  66. package/dist/host-embedding-provider.d.ts +21 -0
  67. package/dist/host-embedding-provider.js +14 -0
  68. package/dist/host-embedding-provider.js.map +1 -0
  69. package/dist/index.d.ts +1 -0
  70. package/dist/index.js +71 -63
  71. package/dist/index.js.map +1 -1
  72. package/dist/lcm/index.js +3 -3
  73. package/dist/maintenance/memory-governance.js +4 -4
  74. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
  75. package/dist/maintenance/rebuild-memory-projection.js +5 -5
  76. package/dist/namespaces/migrate.js +14 -13
  77. package/dist/namespaces/search.js +9 -8
  78. package/dist/namespaces/storage.js +4 -4
  79. package/dist/operator-toolkit.js +17 -16
  80. package/dist/orchestrator.js +32 -31
  81. package/dist/recall-explain-renderer.js +3 -3
  82. package/dist/recall-xray-cli.js +4 -4
  83. package/dist/recall-xray-renderer.js +3 -3
  84. package/dist/recall-xray.js +2 -2
  85. package/dist/resume-bundles.js +2 -2
  86. package/dist/search/embed-helper.d.ts +48 -4
  87. package/dist/search/embed-helper.js +2 -1
  88. package/dist/search/factory.js +8 -7
  89. package/dist/search/index.d.ts +1 -0
  90. package/dist/search/index.js +12 -11
  91. package/dist/search/lancedb-backend.d.ts +11 -0
  92. package/dist/search/lancedb-backend.js +2 -2
  93. package/dist/search/meilisearch-backend.js +2 -2
  94. package/dist/search/orama-backend.d.ts +16 -0
  95. package/dist/search/orama-backend.js +2 -2
  96. package/dist/semantic-consolidation.js +5 -5
  97. package/dist/semantic-rule-promotion.js +4 -4
  98. package/dist/semantic-rule-verifier.js +4 -4
  99. package/dist/storage.js +3 -3
  100. package/dist/transfer/autodetect.js +1 -1
  101. package/dist/transfer/backup.js +1 -1
  102. package/dist/transfer/capsule-export.js +2 -2
  103. package/dist/transfer/types.d.ts +6 -6
  104. package/dist/types.d.ts +17 -0
  105. package/dist/types.js +1 -1
  106. package/dist/verified-recall.js +4 -4
  107. package/package.json +11 -1
  108. package/src/config.ts +18 -0
  109. package/src/embedding-fallback.ts +293 -61
  110. package/src/host-embedding-provider.ts +84 -0
  111. package/src/index.ts +7 -0
  112. package/src/namespaces/search.ts +9 -1
  113. package/src/qmd.test.ts +28 -0
  114. package/src/search/embed-helper.ts +319 -51
  115. package/src/search/factory.ts +6 -2
  116. package/src/search/lancedb-backend.ts +297 -41
  117. package/src/search/orama-backend.ts +418 -47
  118. package/src/types.ts +17 -0
  119. package/dist/chunk-6URPAY2D.js.map +0 -1
  120. package/dist/chunk-FUC4LZMD.js +0 -301
  121. package/dist/chunk-FUC4LZMD.js.map +0 -1
  122. package/dist/chunk-KXULCVOC.js.map +0 -1
  123. package/dist/chunk-M5T4Q2ZU.js.map +0 -1
  124. package/dist/chunk-ONPLNAPX.js +0 -133
  125. package/dist/chunk-ONPLNAPX.js.map +0 -1
  126. package/dist/chunk-QVJ4NWL2.js +0 -335
  127. package/dist/chunk-QVJ4NWL2.js.map +0 -1
  128. package/dist/chunk-SML26KED.js.map +0 -1
  129. /package/dist/{chunk-I2K6KCVC.js.map → chunk-2FHLI4U6.js.map} +0 -0
  130. /package/dist/{chunk-5GX5MUQ2.js.map → chunk-574MU2Y3.js.map} +0 -0
  131. /package/dist/{chunk-65OLPXBU.js.map → chunk-5WB4C7KM.js.map} +0 -0
  132. /package/dist/{chunk-Z56KAZQL.js.map → chunk-74VA26CT.js.map} +0 -0
  133. /package/dist/{chunk-CC2ESOOG.js.map → chunk-7X7TBJRX.js.map} +0 -0
  134. /package/dist/{chunk-O4M4WH6V.js.map → chunk-ARY5OOLG.js.map} +0 -0
  135. /package/dist/{chunk-JBPKEARU.js.map → chunk-AU7Q3LSC.js.map} +0 -0
  136. /package/dist/{chunk-PM3QHTFT.js.map → chunk-CF3ZF2YU.js.map} +0 -0
  137. /package/dist/{chunk-SI3QCHWF.js.map → chunk-DARLGSFX.js.map} +0 -0
  138. /package/dist/{chunk-FVCZINOF.js.map → chunk-FHBEL473.js.map} +0 -0
  139. /package/dist/{chunk-7Q3RCKAQ.js.map → chunk-FXKPZ3H6.js.map} +0 -0
  140. /package/dist/{chunk-5WLYNZPC.js.map → chunk-GBXGCFRH.js.map} +0 -0
  141. /package/dist/{chunk-ILJXM3FV.js.map → chunk-HQO5EBUC.js.map} +0 -0
  142. /package/dist/{chunk-FK556DDH.js.map → chunk-I4UNL747.js.map} +0 -0
  143. /package/dist/{chunk-RLPIT4YI.js.map → chunk-IOTTZLFF.js.map} +0 -0
  144. /package/dist/{chunk-TVZ6LKKS.js.map → chunk-IRFF6LSF.js.map} +0 -0
  145. /package/dist/{chunk-IPLYGWQF.js.map → chunk-KQAFEZQX.js.map} +0 -0
  146. /package/dist/{chunk-JFN6K74Q.js.map → chunk-MQEIWDYW.js.map} +0 -0
  147. /package/dist/{chunk-SOTR74FK.js.map → chunk-OPYFD6PD.js.map} +0 -0
  148. /package/dist/{chunk-3C5RPJAX.js.map → chunk-OXJBNGBK.js.map} +0 -0
  149. /package/dist/{chunk-BD5LHQWD.js.map → chunk-PPPZY2EU.js.map} +0 -0
  150. /package/dist/{chunk-25BY3HHZ.js.map → chunk-SUTSSOYU.js.map} +0 -0
  151. /package/dist/{chunk-KS7WO6EQ.js.map → chunk-VFB2G5YL.js.map} +0 -0
  152. /package/dist/{chunk-BUUYY2H2.js.map → chunk-WP5OWVLZ.js.map} +0 -0
  153. /package/dist/{chunk-S53PAX2V.js.map → chunk-XM7BYXT7.js.map} +0 -0
  154. /package/dist/{chunk-FADZBOR4.js.map → chunk-XRWTAEZM.js.map} +0 -0
  155. /package/dist/{chunk-E5OECWZ5.js.map → chunk-XT7XVA53.js.map} +0 -0
  156. /package/dist/{chunk-R3PS27B4.js.map → chunk-Z4R6RI2N.js.map} +0 -0
@@ -3,14 +3,20 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { log } from "./logger.js";
4
4
  import { readEnvVar } from "./runtime/env.js";
5
5
  import type { PluginConfig } from "./types.js";
6
+ import {
7
+ getHostEmbeddingProvider,
8
+ type HostEmbeddingProvider,
9
+ normalizeHostEmbeddingVector,
10
+ } from "./host-embedding-provider.js";
6
11
 
7
- type EmbeddingProviderType = "openai" | "local";
12
+ type EmbeddingProviderType = "openai" | "local" | "host";
8
13
 
9
14
  type ProviderConfig = {
10
15
  type: EmbeddingProviderType;
11
16
  model: string;
12
- endpoint: string;
13
- headers: Record<string, string>;
17
+ endpoint?: string;
18
+ headers?: Record<string, string>;
19
+ hostProvider?: HostEmbeddingProvider;
14
20
  };
15
21
 
16
22
  type EmbeddingIndexEntry = {
@@ -18,6 +24,11 @@ type EmbeddingIndexEntry = {
18
24
  path: string;
19
25
  };
20
26
 
27
+ type EmbeddingResult = {
28
+ provider: ProviderConfig;
29
+ vector: number[];
30
+ };
31
+
21
32
  type EmbeddingIndexFile = {
22
33
  version: 1;
23
34
  provider: EmbeddingProviderType;
@@ -25,6 +36,11 @@ type EmbeddingIndexFile = {
25
36
  entries: Record<string, EmbeddingIndexEntry>;
26
37
  };
27
38
 
39
+ type EmbeddingIndexIdentity = Pick<EmbeddingIndexFile, "provider" | "model">;
40
+ type EmbeddingIndexComparable =
41
+ | EmbeddingIndexIdentity
42
+ | Pick<ProviderConfig, "type" | "model">;
43
+
28
44
  const DEFAULT_OPENAI_MODEL = "text-embedding-3-small";
29
45
 
30
46
  /**
@@ -63,6 +79,20 @@ export class EmbeddingTimeoutError extends Error {
63
79
  }
64
80
  }
65
81
 
82
+ export class EmbeddingProviderUnavailableError extends Error {
83
+ override readonly name = "EmbeddingProviderUnavailableError" as const;
84
+ constructor(message: string) {
85
+ super(message);
86
+ }
87
+ }
88
+
89
+ function isLookupBackendUnavailableError(err: unknown): boolean {
90
+ return (
91
+ err instanceof EmbeddingTimeoutError ||
92
+ err instanceof EmbeddingProviderUnavailableError
93
+ );
94
+ }
95
+
66
96
  /**
67
97
  * Maximum time to wait for an embedding HTTP request on the LOOKUP/query
68
98
  * path before giving up.
@@ -228,26 +258,33 @@ export class EmbeddingFallback {
228
258
  const provider = await this.resolveProvider();
229
259
  if (!provider) return [];
230
260
 
231
- const index = await this.loadIndex(provider);
232
- const ids = Object.keys(index.entries);
233
- if (ids.length === 0) return [];
234
-
235
- let queryVector: number[] | null;
236
- try {
237
- queryVector = await this.embed(query, provider, { mode: "lookup" });
238
- } catch (err) {
239
- if (err instanceof EmbeddingTimeoutError) {
240
- if (options.throwOnTimeout) {
241
- throw err;
261
+ let queryResult = await this.embedForSearch(query, provider, options);
262
+ if (!queryResult) return [];
263
+
264
+ const diskIdentity = await this.readIndexIdentityFromDisk();
265
+ if (diskIdentity && !sameIndexIdentity(diskIdentity, queryResult.provider)) {
266
+ const diskProvider = await this.resolveFallbackProviderForIndexIdentity(diskIdentity);
267
+ if (diskProvider) {
268
+ const diskQueryResult = await this.embedForSearch(query, diskProvider, options);
269
+ if (diskQueryResult && sameIndexIdentity(diskIdentity, diskQueryResult.provider)) {
270
+ queryResult = diskQueryResult;
271
+ } else {
272
+ log.debug(
273
+ `embedding fallback search skipped: preserved ${diskIdentity.provider}/${diskIdentity.model} index is unavailable for lookup`,
274
+ );
275
+ return [];
242
276
  }
243
- // Fail-open: recall-path callers get an empty result rather than an
244
- // unhandled rejection that would abort recall entirely.
245
- log.debug("embedding fallback search: timeout on lookup, returning [] (throwOnTimeout=false)");
277
+ } else {
278
+ log.debug(
279
+ `embedding fallback search skipped: query provider ${queryResult.provider.type}/${queryResult.provider.model} does not match existing ${diskIdentity.provider}/${diskIdentity.model} index`,
280
+ );
246
281
  return [];
247
282
  }
248
- throw err;
249
283
  }
250
- if (!queryVector) return [];
284
+
285
+ const index = await this.loadIndex(queryResult.provider);
286
+ const ids = Object.keys(index.entries);
287
+ if (ids.length === 0) return [];
251
288
 
252
289
  const includePrefix = normalizePathPrefix(options.pathPrefix);
253
290
  const excludePrefixes = (options.pathExcludePrefixes ?? [])
@@ -260,7 +297,7 @@ export class EmbeddingFallback {
260
297
  return {
261
298
  id,
262
299
  path: entry.path,
263
- score: cosineSimilarity(queryVector, entry.vector),
300
+ score: cosineSimilarity(queryResult.vector, entry.vector),
264
301
  };
265
302
  })
266
303
  .filter((r) => {
@@ -289,14 +326,27 @@ export class EmbeddingFallback {
289
326
  // add the entry to the index. Previously this used the short lookup
290
327
  // budget and silently dropped updates, leaving later dedup lookups
291
328
  // blind to the memory. Related: PR #399 P2.
292
- const vector = await this.embed(content, provider, { mode: "index" });
293
- if (!vector) return;
329
+ const result = await this.embedWithEffectiveProvider(content, provider, {
330
+ mode: "index",
331
+ });
332
+ if (!result) return;
294
333
 
295
334
  await this.enqueueIndexMutation(async () => {
296
- const index = await this.loadIndex(provider);
335
+ const existing = await this.readIndexIdentityFromDisk();
336
+ if (
337
+ existing &&
338
+ !sameIndexIdentity(existing, result.provider) &&
339
+ !canReplaceIndexIdentity(existing, result.provider)
340
+ ) {
341
+ log.debug(
342
+ `embedding fallback index update skipped: ${result.provider.type}/${result.provider.model} would replace existing ${existing.provider}/${existing.model} index`,
343
+ );
344
+ return;
345
+ }
346
+ const index = await this.loadIndex(result.provider);
297
347
  const relPath = toMemoryRelativePath(this.config.memoryDir, filePath);
298
348
  index.entries[memoryId] = {
299
- vector,
349
+ vector: result.vector,
300
350
  path: relPath,
301
351
  };
302
352
  await this.saveIndex(index);
@@ -308,10 +358,30 @@ export class EmbeddingFallback {
308
358
  if (!provider) return;
309
359
 
310
360
  await this.enqueueIndexMutation(async () => {
311
- const index = await this.loadIndex(provider);
312
- if (!index.entries[memoryId]) return;
313
- delete index.entries[memoryId];
314
- await this.saveIndex(index);
361
+ const providers = [provider];
362
+ const diskIdentity = await this.readIndexIdentityFromDisk();
363
+ if (
364
+ diskIdentity &&
365
+ !providers.some((entry) => sameIndexIdentity(entry, diskIdentity))
366
+ ) {
367
+ providers.push(providerFromIndexIdentity(diskIdentity));
368
+ }
369
+ if (provider.type === "host") {
370
+ const fallbackProvider = await this.resolveProvider({ includeHost: false });
371
+ if (
372
+ fallbackProvider &&
373
+ !providers.some((entry) => sameIndexIdentity(entry, fallbackProvider))
374
+ ) {
375
+ providers.push(fallbackProvider);
376
+ }
377
+ }
378
+
379
+ for (const indexProvider of providers) {
380
+ const index = await this.loadIndex(indexProvider);
381
+ if (!index.entries[memoryId]) continue;
382
+ delete index.entries[memoryId];
383
+ await this.saveIndex(index);
384
+ }
315
385
  });
316
386
  }
317
387
 
@@ -324,56 +394,130 @@ export class EmbeddingFallback {
324
394
  return run;
325
395
  }
326
396
 
327
- private async resolveProvider(): Promise<ProviderConfig | null> {
397
+ private async resolveProvider(
398
+ options: { includeHost?: boolean } = {},
399
+ ): Promise<ProviderConfig | null> {
328
400
  if (!this.config.embeddingFallbackEnabled) return null;
329
401
 
402
+ if (
403
+ options.includeHost !== false &&
404
+ this.config.hostEmbeddingProviderEnabled !== false
405
+ ) {
406
+ const hostProvider = getHostEmbeddingProvider(this.config.memoryDir);
407
+ if (hostProvider) {
408
+ return {
409
+ type: "host",
410
+ model: hostProvider.model || hostProvider.id,
411
+ hostProvider,
412
+ };
413
+ }
414
+ }
415
+
330
416
  const preferred = this.config.embeddingFallbackProvider;
331
417
  const providers = preferred === "auto" ? ["openai", "local"] : [preferred];
332
418
 
333
419
  for (const p of providers) {
334
- if (p === "openai" && this.config.openaiApiKey) {
335
- const baseUrl = this.config.openaiBaseUrl ?? "https://api.openai.com/v1";
336
- return {
337
- type: "openai",
338
- model: DEFAULT_OPENAI_MODEL,
339
- endpoint: `${baseUrl.replace(/\/$/, "")}/embeddings`,
340
- headers: {
341
- "Content-Type": "application/json",
342
- Authorization: `Bearer ${this.config.openaiApiKey}`,
343
- },
344
- };
420
+ if (p === "openai") {
421
+ const provider = this.createOpenAiProvider();
422
+ if (provider) return provider;
345
423
  }
346
424
 
347
- if (p === "local" && this.config.localLlmEnabled && this.config.localLlmUrl) {
348
- const base = this.config.localLlmUrl.replace(/\/$/, "");
349
- const endpoint = /\/v1$/i.test(base) ? `${base}/embeddings` : `${base}/v1/embeddings`;
350
- const headers: Record<string, string> = {
351
- "Content-Type": "application/json",
352
- ...(this.config.localLlmHeaders ?? {}),
353
- };
354
- if (this.config.localLlmApiKey && this.config.localLlmAuthHeader !== false) {
355
- headers.Authorization = `Bearer ${this.config.localLlmApiKey}`;
356
- }
357
- return {
358
- type: "local",
359
- model:
360
- this.config.embeddingFallbackModel ||
361
- this.config.localLlmModel ||
362
- DEFAULT_OPENAI_MODEL,
363
- endpoint,
364
- headers,
365
- };
425
+ if (p === "local") {
426
+ const provider = this.createLocalProvider();
427
+ if (provider) return provider;
366
428
  }
367
429
  }
368
430
 
369
431
  return null;
370
432
  }
371
433
 
434
+ private async resolveFallbackProviderForIndexIdentity(
435
+ identity: EmbeddingIndexIdentity,
436
+ ): Promise<ProviderConfig | null> {
437
+ if (identity.provider === "openai") {
438
+ const provider = this.createOpenAiProvider();
439
+ return provider && sameIndexIdentity(provider, identity) ? provider : null;
440
+ }
441
+ if (identity.provider === "local") {
442
+ const provider = this.createLocalProvider();
443
+ return provider && sameIndexIdentity(provider, identity) ? provider : null;
444
+ }
445
+ return null;
446
+ }
447
+
448
+ private createOpenAiProvider(): ProviderConfig | null {
449
+ if (!this.config.openaiApiKey) return null;
450
+ const baseUrl = this.config.openaiBaseUrl ?? "https://api.openai.com/v1";
451
+ return {
452
+ type: "openai",
453
+ model: DEFAULT_OPENAI_MODEL,
454
+ endpoint: `${baseUrl.replace(/\/$/, "")}/embeddings`,
455
+ headers: {
456
+ "Content-Type": "application/json",
457
+ Authorization: `Bearer ${this.config.openaiApiKey}`,
458
+ },
459
+ };
460
+ }
461
+
462
+ private createLocalProvider(): ProviderConfig | null {
463
+ if (!this.config.localLlmEnabled || !this.config.localLlmUrl) return null;
464
+ const base = this.config.localLlmUrl.replace(/\/$/, "");
465
+ const endpoint = /\/v1$/i.test(base) ? `${base}/embeddings` : `${base}/v1/embeddings`;
466
+ const headers: Record<string, string> = {
467
+ "Content-Type": "application/json",
468
+ ...(this.config.localLlmHeaders ?? {}),
469
+ };
470
+ if (this.config.localLlmApiKey && this.config.localLlmAuthHeader !== false) {
471
+ headers.Authorization = `Bearer ${this.config.localLlmApiKey}`;
472
+ }
473
+ return {
474
+ type: "local",
475
+ model:
476
+ this.config.embeddingFallbackModel ||
477
+ this.config.localLlmModel ||
478
+ DEFAULT_OPENAI_MODEL,
479
+ endpoint,
480
+ headers,
481
+ };
482
+ }
483
+
484
+ private async embedForSearch(
485
+ query: string,
486
+ provider: ProviderConfig,
487
+ options: { throwOnTimeout?: boolean } = {},
488
+ ): Promise<EmbeddingResult | null> {
489
+ try {
490
+ return await this.embedWithEffectiveProvider(query, provider, {
491
+ mode: "lookup",
492
+ });
493
+ } catch (err) {
494
+ if (isLookupBackendUnavailableError(err)) {
495
+ if (options.throwOnTimeout) {
496
+ throw err;
497
+ }
498
+ // Fail-open: recall-path callers get an empty result rather than an
499
+ // unhandled rejection that would abort recall entirely.
500
+ log.debug("embedding fallback search: backend unavailable on lookup, returning [] (throwOnTimeout=false)");
501
+ return null;
502
+ }
503
+ throw err;
504
+ }
505
+ }
506
+
372
507
  private async embed(
373
508
  input: string,
374
509
  provider: ProviderConfig,
375
510
  options: { mode?: EmbedMode } = {},
376
511
  ): Promise<number[] | null> {
512
+ const result = await this.embedWithEffectiveProvider(input, provider, options);
513
+ return result?.vector ?? null;
514
+ }
515
+
516
+ private async embedWithEffectiveProvider(
517
+ input: string,
518
+ provider: ProviderConfig,
519
+ options: { mode?: EmbedMode } = {},
520
+ ): Promise<EmbeddingResult | null> {
377
521
  // Bound the fetch so a hung embedding endpoint cannot stall callers.
378
522
  // The lookup path uses a short budget (see DEFAULT_EMBEDDING_LOOKUP_TIMEOUT_MS
379
523
  // docblock) so semantic dedup fails open fast. The index path uses a
@@ -385,6 +529,21 @@ export class EmbeddingFallback {
385
529
  mode === "index"
386
530
  ? resolveEmbeddingIndexTimeoutMs()
387
531
  : resolveEmbeddingLookupTimeoutMs();
532
+ if (provider.type === "host") {
533
+ const vector = await this.embedWithHostProvider(input, provider, mode, timeoutMs);
534
+ if (vector) return { provider, vector };
535
+ const fallbackProvider = await this.resolveProvider({ includeHost: false });
536
+ if (!fallbackProvider) {
537
+ if (mode === "lookup") {
538
+ throw new EmbeddingProviderUnavailableError(
539
+ `host embedding provider unavailable (${provider.hostProvider?.id ?? provider.model})`,
540
+ );
541
+ }
542
+ return null;
543
+ }
544
+ return this.embedWithEffectiveProvider(input, fallbackProvider, options);
545
+ }
546
+ if (!provider.endpoint || !provider.headers) return null;
388
547
  try {
389
548
  const res = await fetch(provider.endpoint, {
390
549
  method: "POST",
@@ -417,12 +576,15 @@ export class EmbeddingFallback {
417
576
  const payload = (await res.json()) as any;
418
577
  const vector = payload?.data?.[0]?.embedding;
419
578
  if (!Array.isArray(vector)) return null;
420
- return vector.map((n: unknown) => Number(n)).filter((n: number) => Number.isFinite(n));
579
+ const normalized = vector
580
+ .map((n: unknown) => Number(n))
581
+ .filter((n: number) => Number.isFinite(n));
582
+ return normalized.length > 0 ? { provider, vector: normalized } : null;
421
583
  } catch (err) {
422
584
  // Round 11 (Finding Ur_J): the !res.ok branch above throws
423
585
  // EmbeddingTimeoutError directly. Re-throw it here so the catch does
424
586
  // not swallow our own intentional signal back into a null return.
425
- if (err instanceof EmbeddingTimeoutError) {
587
+ if (isLookupBackendUnavailableError(err)) {
426
588
  throw err;
427
589
  }
428
590
  // AbortSignal.timeout throws a DOMException with name "TimeoutError";
@@ -479,6 +641,26 @@ export class EmbeddingFallback {
479
641
  }
480
642
  }
481
643
 
644
+ private async embedWithHostProvider(
645
+ input: string,
646
+ provider: ProviderConfig,
647
+ mode: EmbedMode,
648
+ timeoutMs: number,
649
+ ): Promise<number[] | null> {
650
+ const hostProvider = provider.hostProvider;
651
+ if (!hostProvider) return null;
652
+ try {
653
+ const vector = await hostProvider.embed(input.slice(0, 8000), {
654
+ signal: AbortSignal.timeout(timeoutMs),
655
+ inputType: mode === "lookup" ? "query" : "document",
656
+ });
657
+ return normalizeHostEmbeddingVector(vector);
658
+ } catch (err) {
659
+ log.debug(`host embedding provider error: ${hostProvider.id}: ${err}`);
660
+ return null;
661
+ }
662
+ }
663
+
482
664
  private async loadIndex(provider: ProviderConfig): Promise<EmbeddingIndexFile> {
483
665
  if (this.loaded && this.loaded.provider === provider.type && this.loaded.model === provider.model) {
484
666
  return this.loaded;
@@ -516,6 +698,31 @@ export class EmbeddingFallback {
516
698
  return this.loaded;
517
699
  }
518
700
 
701
+ private async readIndexIdentityFromDisk(): Promise<EmbeddingIndexIdentity | null> {
702
+ try {
703
+ const raw = await readFile(this.indexPath, "utf-8");
704
+ const parsed = JSON.parse(raw) as Partial<EmbeddingIndexFile> | null;
705
+ if (
706
+ parsed &&
707
+ parsed.version === 1 &&
708
+ (parsed.provider === "openai" ||
709
+ parsed.provider === "local" ||
710
+ parsed.provider === "host") &&
711
+ typeof parsed.model === "string" &&
712
+ parsed.model.length > 0
713
+ ) {
714
+ return {
715
+ provider: parsed.provider,
716
+ model: parsed.model,
717
+ };
718
+ }
719
+ } catch {
720
+ // Missing or invalid indexes are treated as absent; loadIndex() owns
721
+ // creating a fresh file when it is safe to write.
722
+ }
723
+ return null;
724
+ }
725
+
519
726
  private async saveIndex(index: EmbeddingIndexFile): Promise<void> {
520
727
  const dir = path.dirname(this.indexPath);
521
728
  await mkdir(dir, { recursive: true });
@@ -578,6 +785,31 @@ function normalizePathPrefix(prefix: string | undefined): string | undefined {
578
785
  return p;
579
786
  }
580
787
 
788
+ function sameIndexIdentity(
789
+ left: EmbeddingIndexComparable,
790
+ right: EmbeddingIndexComparable,
791
+ ): boolean {
792
+ return indexIdentityProvider(left) === indexIdentityProvider(right) && left.model === right.model;
793
+ }
794
+
795
+ function indexIdentityProvider(identity: EmbeddingIndexComparable): EmbeddingProviderType {
796
+ return "provider" in identity ? identity.provider : identity.type;
797
+ }
798
+
799
+ function canReplaceIndexIdentity(
800
+ existing: EmbeddingIndexIdentity,
801
+ replacement: Pick<ProviderConfig, "type" | "model">,
802
+ ): boolean {
803
+ return existing.provider === "host" && !sameIndexIdentity(existing, replacement);
804
+ }
805
+
806
+ function providerFromIndexIdentity(identity: EmbeddingIndexIdentity): ProviderConfig {
807
+ return {
808
+ type: identity.provider,
809
+ model: identity.model,
810
+ };
811
+ }
812
+
581
813
  function cosineSimilarity(a: number[], b: number[]): number {
582
814
  const n = Math.min(a.length, b.length);
583
815
  if (n === 0) return 0;
@@ -0,0 +1,84 @@
1
+ export type HostEmbeddingInputType =
2
+ | "query"
3
+ | "document"
4
+ | "semantic"
5
+ | "classification"
6
+ | "clustering";
7
+
8
+ export interface HostEmbeddingProvider {
9
+ id: string;
10
+ model?: string;
11
+ dimensions?: number;
12
+ embed(
13
+ text: string,
14
+ options?: { signal?: AbortSignal; inputType?: HostEmbeddingInputType },
15
+ ): Promise<ArrayLike<number> | null>;
16
+ embedBatch?(
17
+ texts: string[],
18
+ options?: { signal?: AbortSignal; inputType?: HostEmbeddingInputType },
19
+ ): Promise<Array<ArrayLike<number> | null>>;
20
+ close?: () => Promise<void> | void;
21
+ }
22
+
23
+ const HOST_EMBEDDING_PROVIDERS_KEY = Symbol.for(
24
+ "remnic.hostEmbeddingProviders",
25
+ );
26
+
27
+ function providerMap(): Map<string, HostEmbeddingProvider> {
28
+ const store = globalThis as Record<PropertyKey, unknown>;
29
+ const existing = store[HOST_EMBEDDING_PROVIDERS_KEY];
30
+ if (existing instanceof Map) {
31
+ return existing as Map<string, HostEmbeddingProvider>;
32
+ }
33
+ const created = new Map<string, HostEmbeddingProvider>();
34
+ store[HOST_EMBEDDING_PROVIDERS_KEY] = created;
35
+ return created;
36
+ }
37
+
38
+ export function registerHostEmbeddingProvider(
39
+ scope: string,
40
+ provider: HostEmbeddingProvider,
41
+ ): () => void {
42
+ const key = normalizeScope(scope);
43
+ const providers = providerMap();
44
+ const previous = providers.get(key);
45
+ if (previous && previous !== provider) {
46
+ void previous.close?.();
47
+ }
48
+ providers.set(key, provider);
49
+ return () => {
50
+ const providers = providerMap();
51
+ if (providers.get(key) === provider) {
52
+ providers.delete(key);
53
+ void provider.close?.();
54
+ }
55
+ };
56
+ }
57
+
58
+ export function getHostEmbeddingProvider(
59
+ scope: string,
60
+ ): HostEmbeddingProvider | undefined {
61
+ return providerMap().get(normalizeScope(scope));
62
+ }
63
+
64
+ export function clearHostEmbeddingProvidersForTest(): void {
65
+ providerMap().clear();
66
+ }
67
+
68
+ export function normalizeHostEmbeddingVector(value: unknown): number[] | null {
69
+ if (!Array.isArray(value) && (!ArrayBuffer.isView(value) || value instanceof DataView)) {
70
+ return null;
71
+ }
72
+ const vector = Array.from(value as unknown as ArrayLike<unknown>);
73
+ return vector.length > 0 &&
74
+ vector.every((component): component is number => {
75
+ return typeof component === "number" && Number.isFinite(component);
76
+ })
77
+ ? vector
78
+ : null;
79
+ }
80
+
81
+ function normalizeScope(scope: string): string {
82
+ const normalized = typeof scope === "string" ? scope.trim() : "";
83
+ return normalized || "default";
84
+ }
package/src/index.ts CHANGED
@@ -104,6 +104,13 @@ export {
104
104
  parseEntityFile,
105
105
  serializeEntityFile,
106
106
  } from "./storage.js";
107
+ export {
108
+ getHostEmbeddingProvider,
109
+ normalizeHostEmbeddingVector,
110
+ registerHostEmbeddingProvider,
111
+ type HostEmbeddingInputType,
112
+ type HostEmbeddingProvider,
113
+ } from "./host-embedding-provider.js";
107
114
 
108
115
  // ---------------------------------------------------------------------------
109
116
  // Extraction
@@ -35,6 +35,10 @@ type NamespaceBackendRecord = {
35
35
  collectionState: "present" | "missing" | "unknown" | "skipped";
36
36
  };
37
37
 
38
+ type NamespaceScopedSearchConfig = PluginConfig & {
39
+ hostEmbeddingProviderScope?: string;
40
+ };
41
+
38
42
  export class NamespaceSearchRouter {
39
43
  private readonly cache = new Map<string, Promise<NamespaceBackendRecord>>();
40
44
 
@@ -183,9 +187,13 @@ export class NamespaceSearchRouter {
183
187
  const storage = await this.storageRouter.storageFor(key);
184
188
  const useLegacyDefaultCollection =
185
189
  key === this.config.defaultNamespace && storage.dir === this.config.memoryDir;
186
- const scopedConfig: PluginConfig = {
190
+ const rootHostEmbeddingScope =
191
+ (this.config as NamespaceScopedSearchConfig).hostEmbeddingProviderScope ??
192
+ this.config.memoryDir;
193
+ const scopedConfig: NamespaceScopedSearchConfig = {
187
194
  ...this.config,
188
195
  memoryDir: storage.dir,
196
+ hostEmbeddingProviderScope: rootHostEmbeddingScope,
189
197
  qmdCollection: namespaceCollectionName(this.config.qmdCollection, key, {
190
198
  defaultNamespace: this.config.defaultNamespace,
191
199
  useLegacyDefaultCollection,
package/src/qmd.test.ts CHANGED
@@ -277,6 +277,34 @@ test("parseConfig exposes qmd 2.5 integration defaults and opt-in auto upgrade",
277
277
  assert.equal(falseGpuBackend.qmdGpuBackend, "false");
278
278
  });
279
279
 
280
+ test("parseConfig exposes gated OpenClaw host feature defaults", () => {
281
+ const defaults = parseConfig({});
282
+ assert.equal(defaults.hostEmbeddingProviderEnabled, true);
283
+ assert.equal(defaults.hostEmbeddingProviderId, undefined);
284
+ assert.equal(defaults.hostEmbeddingProviderModel, undefined);
285
+ assert.equal(defaults.openclawMessageReceivedCaptureEnabled, true);
286
+ assert.equal(defaults.openclawReplyMetadataCaptureEnabled, true);
287
+ assert.equal(defaults.openclawReplyMetadataExtractionHintsEnabled, false);
288
+ assert.equal(defaults.openclawChannelEnvelopeCleaningEnabled, true);
289
+
290
+ const configured = parseConfig({
291
+ openclawHostEmbeddingProviderEnabled: "false",
292
+ openclawHostEmbeddingProviderId: "openai-compatible",
293
+ openclawHostEmbeddingProviderModel: "text-embedding-3-large",
294
+ openclawMessageReceivedCaptureEnabled: "0",
295
+ openclawReplyMetadataCaptureEnabled: "off",
296
+ openclawReplyMetadataExtractionHintsEnabled: "yes",
297
+ openclawChannelEnvelopeCleaningEnabled: "no",
298
+ });
299
+ assert.equal(configured.hostEmbeddingProviderEnabled, false);
300
+ assert.equal(configured.hostEmbeddingProviderId, "openai-compatible");
301
+ assert.equal(configured.hostEmbeddingProviderModel, "text-embedding-3-large");
302
+ assert.equal(configured.openclawMessageReceivedCaptureEnabled, false);
303
+ assert.equal(configured.openclawReplyMetadataCaptureEnabled, false);
304
+ assert.equal(configured.openclawReplyMetadataExtractionHintsEnabled, true);
305
+ assert.equal(configured.openclawChannelEnvelopeCleaningEnabled, false);
306
+ });
307
+
280
308
  test("parseConfig rejects invalid qmd version and integer config values", () => {
281
309
  assert.throws(
282
310
  () => parseConfig({ qmdSupportedVersion: "latest" }),