@linxiraos/pi-mnemopi 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +248 -0
  2. package/README.md +108 -0
  3. package/dist/types/cli.d.ts +35 -0
  4. package/dist/types/config.d.ts +108 -0
  5. package/dist/types/core/aaak.d.ts +55 -0
  6. package/dist/types/core/annotations.d.ts +75 -0
  7. package/dist/types/core/banks.d.ts +33 -0
  8. package/dist/types/core/beam/consolidate.d.ts +38 -0
  9. package/dist/types/core/beam/helpers.d.ts +69 -0
  10. package/dist/types/core/beam/index.d.ts +59 -0
  11. package/dist/types/core/beam/recall.d.ts +52 -0
  12. package/dist/types/core/beam/schema.d.ts +2 -0
  13. package/dist/types/core/beam/store.d.ts +57 -0
  14. package/dist/types/core/beam/types.d.ts +268 -0
  15. package/dist/types/core/binary-vectors.d.ts +54 -0
  16. package/dist/types/core/chat-normalize.d.ts +13 -0
  17. package/dist/types/core/content-sanitizer.d.ts +18 -0
  18. package/dist/types/core/cost-log.d.ts +13 -0
  19. package/dist/types/core/embeddings.d.ts +93 -0
  20. package/dist/types/core/entities.d.ts +9 -0
  21. package/dist/types/core/episodic-graph.d.ts +89 -0
  22. package/dist/types/core/extraction/client.d.ts +35 -0
  23. package/dist/types/core/extraction/diagnostics.d.ts +51 -0
  24. package/dist/types/core/extraction/prompts.d.ts +2 -0
  25. package/dist/types/core/extraction.d.ts +34 -0
  26. package/dist/types/core/fastembed-model-cache.d.ts +2 -0
  27. package/dist/types/core/fastembed-runtime.d.ts +41 -0
  28. package/dist/types/core/index.d.ts +6 -0
  29. package/dist/types/core/llm-backends.d.ts +23 -0
  30. package/dist/types/core/local-llm.d.ts +20 -0
  31. package/dist/types/core/memory.d.ts +186 -0
  32. package/dist/types/core/migrations/e6-triplestore-split.d.ts +17 -0
  33. package/dist/types/core/migrations/index.d.ts +1 -0
  34. package/dist/types/core/mmr.d.ts +8 -0
  35. package/dist/types/core/orchestrator.d.ts +20 -0
  36. package/dist/types/core/patterns.d.ts +61 -0
  37. package/dist/types/core/plugins.d.ts +109 -0
  38. package/dist/types/core/polyphonic-recall.d.ts +66 -0
  39. package/dist/types/core/query-cache.d.ts +45 -0
  40. package/dist/types/core/query-intent.d.ts +20 -0
  41. package/dist/types/core/recall-diagnostics.d.ts +48 -0
  42. package/dist/types/core/runtime-options.d.ts +76 -0
  43. package/dist/types/core/shmr.d.ts +62 -0
  44. package/dist/types/core/streaming.d.ts +136 -0
  45. package/dist/types/core/synonyms.d.ts +46 -0
  46. package/dist/types/core/temporal-parser.d.ts +16 -0
  47. package/dist/types/core/token-counter.d.ts +8 -0
  48. package/dist/types/core/triples.d.ts +63 -0
  49. package/dist/types/core/typed-memory.d.ts +39 -0
  50. package/dist/types/core/vector-index.d.ts +16 -0
  51. package/dist/types/core/vector-math.d.ts +1 -0
  52. package/dist/types/core/veracity-consolidation.d.ts +60 -0
  53. package/dist/types/core/weibull.d.ts +96 -0
  54. package/dist/types/db.d.ts +18 -0
  55. package/dist/types/diagnose.d.ts +24 -0
  56. package/dist/types/dr/index.d.ts +1 -0
  57. package/dist/types/dr/recovery.d.ts +68 -0
  58. package/dist/types/index.d.ts +6 -0
  59. package/dist/types/mcp-server.d.ts +40 -0
  60. package/dist/types/mcp-tools.d.ts +484 -0
  61. package/dist/types/migrations/e6-triplestore-split.d.ts +1 -0
  62. package/dist/types/migrations/index.d.ts +1 -0
  63. package/dist/types/types.d.ts +145 -0
  64. package/dist/types/util/datetime.d.ts +8 -0
  65. package/dist/types/util/env.d.ts +10 -0
  66. package/dist/types/util/ids.d.ts +3 -0
  67. package/dist/types/util/lru.d.ts +12 -0
  68. package/dist/types/util/regex.d.ts +11 -0
  69. package/package.json +108 -0
  70. package/src/cli.ts +398 -0
  71. package/src/config.ts +373 -0
  72. package/src/core/aaak.ts +142 -0
  73. package/src/core/annotations.ts +456 -0
  74. package/src/core/banks.ts +133 -0
  75. package/src/core/beam/consolidate.ts +1132 -0
  76. package/src/core/beam/helpers.ts +831 -0
  77. package/src/core/beam/index.ts +358 -0
  78. package/src/core/beam/recall.ts +1225 -0
  79. package/src/core/beam/schema.ts +426 -0
  80. package/src/core/beam/store.ts +1083 -0
  81. package/src/core/beam/types.ts +303 -0
  82. package/src/core/binary-vectors.ts +317 -0
  83. package/src/core/chat-normalize.ts +160 -0
  84. package/src/core/content-sanitizer.ts +136 -0
  85. package/src/core/cost-log.ts +101 -0
  86. package/src/core/embeddings.ts +624 -0
  87. package/src/core/entities.ts +263 -0
  88. package/src/core/episodic-graph.ts +708 -0
  89. package/src/core/extraction/client.ts +187 -0
  90. package/src/core/extraction/diagnostics.ts +193 -0
  91. package/src/core/extraction/prompts.ts +31 -0
  92. package/src/core/extraction.ts +491 -0
  93. package/src/core/fastembed-model-cache.ts +39 -0
  94. package/src/core/fastembed-runtime.ts +172 -0
  95. package/src/core/index.ts +39 -0
  96. package/src/core/llm-backends.ts +54 -0
  97. package/src/core/local-llm.ts +475 -0
  98. package/src/core/memory.ts +680 -0
  99. package/src/core/migrations/e6-triplestore-split.ts +211 -0
  100. package/src/core/migrations/index.ts +1 -0
  101. package/src/core/mmr.ts +101 -0
  102. package/src/core/orchestrator.ts +62 -0
  103. package/src/core/patterns.ts +484 -0
  104. package/src/core/plugins.ts +375 -0
  105. package/src/core/polyphonic-recall.ts +563 -0
  106. package/src/core/query-cache.ts +351 -0
  107. package/src/core/query-intent.ts +139 -0
  108. package/src/core/recall-diagnostics.ts +157 -0
  109. package/src/core/runtime-options.ts +130 -0
  110. package/src/core/shmr.ts +567 -0
  111. package/src/core/streaming.ts +419 -0
  112. package/src/core/synonyms.ts +197 -0
  113. package/src/core/temporal-parser.ts +363 -0
  114. package/src/core/token-counter.ts +30 -0
  115. package/src/core/triples.ts +452 -0
  116. package/src/core/typed-memory.ts +407 -0
  117. package/src/core/vector-index.ts +83 -0
  118. package/src/core/vector-math.ts +23 -0
  119. package/src/core/veracity-consolidation.ts +477 -0
  120. package/src/core/weibull.ts +124 -0
  121. package/src/db.ts +186 -0
  122. package/src/diagnose.ts +174 -0
  123. package/src/dr/index.ts +1 -0
  124. package/src/dr/recovery.ts +405 -0
  125. package/src/index.ts +33 -0
  126. package/src/mcp-server.ts +155 -0
  127. package/src/mcp-tools.ts +970 -0
  128. package/src/migrations/e6-triplestore-split.ts +1 -0
  129. package/src/migrations/index.ts +1 -0
  130. package/src/types.ts +157 -0
  131. package/src/util/datetime.ts +69 -0
  132. package/src/util/env.ts +65 -0
  133. package/src/util/ids.ts +19 -0
  134. package/src/util/lru.ts +48 -0
  135. package/src/util/regex.ts +165 -0
@@ -0,0 +1,624 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as nodePath from "node:path";
4
+ import { type ApiKey, getOpenRouterHeaders, withAuth } from "@linxiraos/pi-ai";
5
+ import { ProviderHttpError } from "@linxiraos/pi-ai/error";
6
+ import { hostMatchesUrl } from "@linxiraos/pi-catalog/hosts";
7
+ import {
8
+ $env,
9
+ $flag,
10
+ extractHttpStatusFromError,
11
+ fetchWithRetry,
12
+ getFastembedCacheDir,
13
+ logger,
14
+ } from "@linxiraos/pi-utils";
15
+ import { LRUCache } from "@linxiraos/pi-utils/lru";
16
+ import type { EmbeddingModel } from "fastembed";
17
+ import { ensureFastembedModelSidecars } from "./fastembed-model-cache";
18
+ import { loadFastembed } from "./fastembed-runtime";
19
+ import {
20
+ type EmbeddingOutput,
21
+ getMnemopiRuntimeOptions,
22
+ mnemopiDebugEnabled,
23
+ resolveEmbeddingProvider,
24
+ } from "./runtime-options";
25
+
26
+ export type { EmbeddingOutput } from "./runtime-options";
27
+ export { cosineSimilarity } from "./vector-math";
28
+
29
+ export type Vector = Float32Array;
30
+ export type EmbeddingMatrix = Vector[];
31
+
32
+ export interface EmbeddingProvider {
33
+ embed(texts: readonly string[]): EmbeddingOutput | Promise<EmbeddingOutput>;
34
+ available?(): boolean | Promise<boolean>;
35
+ }
36
+
37
+ export type StandardEmbeddingModel = Exclude<EmbeddingModel, EmbeddingModel.CUSTOM>;
38
+
39
+ export interface LocalEmbeddingModel {
40
+ embed(texts: string[], batchSize?: number): EmbeddingOutput;
41
+ queryEmbed?(query: string): Promise<number[]>;
42
+ }
43
+
44
+ export type LocalModelInitOptions = {
45
+ model: StandardEmbeddingModel;
46
+ cacheDir?: string;
47
+ showDownloadProgress?: boolean;
48
+ };
49
+ export type LocalModelInitializer = (options: LocalModelInitOptions) => Promise<LocalEmbeddingModel>;
50
+
51
+ const QUERY_CACHE_MAX = 512;
52
+
53
+ let providerOverride: EmbeddingProvider | null = null;
54
+ let localModelPromise: Promise<LocalEmbeddingModel> | null = null;
55
+ let localModelInitializer: LocalModelInitializer = defaultLocalModelInitializer;
56
+ let apiCallCount = 0;
57
+ const queryCache = new LRUCache<string, Vector>({ max: QUERY_CACHE_MAX });
58
+
59
+ // Provider identity table for the cache key. Each unique `provider` object/function
60
+ // (configured via `withMnemopiRuntimeOptions`) gets a stable integer id so the cache
61
+ // scope reflects the runtime's actual embedding source. Two Mnemopi instances in the
62
+ // same process using different providers/models hash to disjoint keys and never
63
+ // collide on the same query text. `0` is the sentinel for "env-default fallback".
64
+ const providerIds = new WeakMap<object, number>();
65
+ let nextProviderId = 1;
66
+
67
+ /**
68
+ * Quarantine the exact ONNX file named by a "Protobuf parsing failed" init
69
+ * error. A truncated cached model blocks local embeddings forever: the
70
+ * downloader treats the existing file as complete, so every init re-parses
71
+ * the same broken bytes. The extracted path is error-message CONTENT, so it
72
+ * is only honored when it resolves inside the fastembed cache directory —
73
+ * never rename an arbitrary file a dependency happens to mention. Atomic
74
+ * rename; losing a concurrent-heal race (file already renamed/removed)
75
+ * still returns true because a retry is safe either way.
76
+ * @internal exported for tests
77
+ */
78
+ export async function quarantineCorruptModelFile(message: string, cacheDir?: string): Promise<boolean> {
79
+ const match = /Load model from (.+?\.onnx) failed:.*Protobuf parsing failed/i.exec(message);
80
+ if (!match) return false;
81
+ const modelFile = nodePath.resolve(match[1]);
82
+ const cacheRoot = nodePath.resolve(cacheDir ?? getFastembedCacheDir());
83
+ if (!modelFile.startsWith(cacheRoot + nodePath.sep)) return false;
84
+ try {
85
+ await fsp.rename(modelFile, `${modelFile}.corrupt-${Date.now()}`);
86
+ logger.warn("mnemopi: quarantined corrupt local embedding model; retrying init", { modelFile });
87
+ } catch {
88
+ // Concurrent heal or vanished file: the single retry stays safe. A
89
+ // rename that failed with the file still in place just makes the
90
+ // retry surface the original error again.
91
+ }
92
+ return true;
93
+ }
94
+
95
+ /**
96
+ * Recover from a partially-extracted fastembed model cache. An interrupted
97
+ * archive download/extraction leaves `<cacheDir>/<model>/` populated with
98
+ * sidecars and a truncated `model.onnx_data` but WITHOUT the graph file
99
+ * (`model.onnx` / `model_optimized.onnx`), so `FlagEmbedding.init` throws
100
+ * `Model file not found at .../model.onnx`. Upstream `retrieveModel`
101
+ * short-circuits on the existing model dir and never re-downloads, and
102
+ * `downloadFileFromGCS` reuses a leftover partial `<model>.tar.gz` as-is, so
103
+ * the cache is stuck broken forever: recall silently dies and the rebuild
104
+ * queue grows every session. Delete the incomplete model dir AND the leftover
105
+ * partial archive so the retry re-downloads from scratch. The model path is
106
+ * error-message CONTENT, so the dir is only removed when it is a direct child
107
+ * of the fastembed cache root — never touch a directory a dependency merely
108
+ * names. Returns true when a retry is worth attempting (the incomplete cache
109
+ * was cleared, or already gone from a concurrent heal).
110
+ * @internal exported for tests
111
+ */
112
+ export async function clearIncompleteModelCache(message: string, cacheDir?: string): Promise<boolean> {
113
+ const match = /Model file not found at (.+?\.onnx)\b/i.exec(message);
114
+ if (!match) return false;
115
+ const modelFile = nodePath.resolve(match[1]);
116
+ const modelDir = nodePath.dirname(modelFile);
117
+ const cacheRoot = nodePath.resolve(cacheDir ?? getFastembedCacheDir());
118
+ // Only a direct child of the cache root: `<cacheRoot>/<model>`.
119
+ if (nodePath.dirname(modelDir) !== cacheRoot) return false;
120
+ try {
121
+ await fsp.rm(modelDir, { recursive: true, force: true });
122
+ await fsp.rm(`${modelDir}.tar.gz`, { force: true });
123
+ logger.warn("mnemopi: cleared incomplete local embedding model cache; re-downloading", { modelDir });
124
+ } catch {
125
+ // Concurrent heal or vanished dir: the single retry stays safe.
126
+ }
127
+ return true;
128
+ }
129
+
130
+ const SIDECAR_ERROR_RE =
131
+ /(?:Config file not found at .*config|Tokenizer file not found at .*tokenizer|Tokens map file not found at .*special_tokens_map)/u;
132
+
133
+ /**
134
+ * Shared local-model initializer: FlagEmbedding.init with BOTH cache heals.
135
+ * Missing sidecars (config/tokenizer/tokens map) re-fetch and retry; a
136
+ * corrupt model blob (Protobuf parse failure) quarantines the file and
137
+ * retries THROUGH the sidecar heal, so a cache that is broken in both ways
138
+ * still recovers in one pass. Also the initializer the embed worker uses in
139
+ * its subprocess; the in-process seam stays {@link setLocalModelInitializer}.
140
+ */
141
+ export async function defaultLocalModelInitializer(options: LocalModelInitOptions): Promise<LocalEmbeddingModel> {
142
+ const cacheDir = options.cacheDir ?? getFastembedCacheDir();
143
+ const initOptions = options.cacheDir === undefined ? { ...options, cacheDir } : options;
144
+ const { FlagEmbedding } = await loadFastembed();
145
+ const initWithSidecarHeal = async (): Promise<LocalEmbeddingModel> => {
146
+ try {
147
+ return await FlagEmbedding.init(initOptions);
148
+ } catch (error) {
149
+ const message = error instanceof Error ? error.message : "";
150
+ if (!SIDECAR_ERROR_RE.test(message)) throw error;
151
+ if (!(await ensureFastembedModelSidecars(options.model, cacheDir))) throw error;
152
+ return FlagEmbedding.init(initOptions);
153
+ }
154
+ };
155
+ try {
156
+ return await initWithSidecarHeal();
157
+ } catch (error) {
158
+ const message = error instanceof Error ? error.message : "";
159
+ if (/Protobuf parsing failed/i.test(message) && (await quarantineCorruptModelFile(message, cacheDir))) {
160
+ return initWithSidecarHeal();
161
+ }
162
+ if (await clearIncompleteModelCache(message, cacheDir)) {
163
+ return initWithSidecarHeal();
164
+ }
165
+ throw error;
166
+ }
167
+ }
168
+
169
+ function activeEmbeddingOptions() {
170
+ return getMnemopiRuntimeOptions()?.embeddings;
171
+ }
172
+
173
+ /**
174
+ * Compose the per-query cache key. Includes the active provider's identity, the
175
+ * resolved model name, and the API base URL so two `Mnemopi` instances in the same
176
+ * process that point at different providers/models never share a cached query
177
+ * vector. Provider identity comes from `providerIds` (WeakMap-assigned integer);
178
+ * `0` is the sentinel for "no provider configured, fall back to env defaults".
179
+ */
180
+ function queryCacheKey(text: string): string {
181
+ const active = activeEmbeddingOptions();
182
+ const provider = active?.provider as object | undefined;
183
+ let providerId = 0;
184
+ if (provider !== undefined) {
185
+ const existing = providerIds.get(provider);
186
+ if (existing === undefined) {
187
+ providerId = nextProviderId++;
188
+ providerIds.set(provider, providerId);
189
+ } else {
190
+ providerId = existing;
191
+ }
192
+ }
193
+ const model = defaultModel();
194
+ const apiUrl = active?.apiUrl ?? "";
195
+ return `${providerId}::${model}::${apiUrl}::${text}`;
196
+ }
197
+
198
+ function inTestRuntime(): boolean {
199
+ return $env.NODE_ENV === "test" || $env.BUN_ENV === "test";
200
+ }
201
+
202
+ export function embeddingsDisabled(): boolean {
203
+ const active = activeEmbeddingOptions();
204
+ if (active?.disabled !== undefined) {
205
+ return active.disabled;
206
+ }
207
+ return $flag("MNEMOPI_NO_EMBEDDINGS");
208
+ }
209
+
210
+ /**
211
+ * Resolved per-input character cap for {@link embed}.
212
+ *
213
+ * Reads (in order): the active runtime scope's `embeddings.maxInputChars`, then
214
+ * `MNEMOPI_EMBEDDING_MAX_INPUT_CHARS`, then the bundled `8192` default. `0`
215
+ * disables the cap entirely.
216
+ */
217
+ function effectiveMaxInputChars(): number {
218
+ const override = activeEmbeddingOptions()?.maxInputChars;
219
+ if (override !== undefined) return Math.max(0, Math.trunc(override));
220
+ const envValue = Number.parseInt($env.MNEMOPI_EMBEDDING_MAX_INPUT_CHARS ?? "", 10);
221
+ if (Number.isFinite(envValue) && envValue >= 0) return envValue;
222
+ return 8192;
223
+ }
224
+
225
+ /** Elision marker injected between the retained head and tail of an oversized input. */
226
+ const EMBEDDING_ELISION_MARKER = "\n\n[...]\n\n";
227
+
228
+ /**
229
+ * Right-clip a single oversized input to {@link max} chars while preserving
230
+ * both ends. Retention transcripts are chronological (oldest → newest), so a
231
+ * naive `slice(0, max)` would drop the most recent — and most semantically
232
+ * loaded — turns once a session passed the cap, leaving every later retained
233
+ * episode with essentially the same prefix vector. Keeping a head/tail split
234
+ * lets the embedding capture the topic setup at the start AND the latest
235
+ * exchanges at the end. Falls back to a tail-only clip when `max` is too
236
+ * small to fit the elision marker plus a useful slice on either side.
237
+ */
238
+ function clipToWindow(text: string, max: number): string {
239
+ if (text.length <= max) return text;
240
+ if (max <= EMBEDDING_ELISION_MARKER.length + 16) return text.slice(text.length - max);
241
+ const budget = max - EMBEDDING_ELISION_MARKER.length;
242
+ const headLen = budget >>> 1;
243
+ const tailLen = budget - headLen;
244
+ return text.slice(0, headLen) + EMBEDDING_ELISION_MARKER + text.slice(text.length - tailLen);
245
+ }
246
+
247
+ /**
248
+ * Clip every input to {@link effectiveMaxInputChars} so a runaway retention
249
+ * transcript can't blow past the embedding model's context window. Uses a
250
+ * head/tail split via {@link clipToWindow} so the embedding still sees the
251
+ * tail of the conversation (where the latest topic shifts live) and not just
252
+ * the stale prefix. Returns the original array when no input needs trimming
253
+ * (the common case); the new array is allocated only when at least one input
254
+ * is oversized so we don't churn arrays for the typical short-query path
255
+ * through `embedQuery`. Emits one debug-or-warn log per call summarizing how
256
+ * many inputs were trimmed and by how much — silent truncation was the
257
+ * original bug (#3126).
258
+ */
259
+ function capInputs(texts: readonly string[]): readonly string[] {
260
+ const max = effectiveMaxInputChars();
261
+ if (max === 0) return texts;
262
+ let trimmed: string[] | null = null;
263
+ let trimmedCount = 0;
264
+ let maxOriginalLen = 0;
265
+ for (let i = 0; i < texts.length; i++) {
266
+ const text = texts[i] ?? "";
267
+ if (text.length <= max) continue;
268
+ if (trimmed === null) trimmed = texts.slice() as string[];
269
+ trimmed[i] = clipToWindow(text, max);
270
+ trimmedCount++;
271
+ if (text.length > maxOriginalLen) maxOriginalLen = text.length;
272
+ }
273
+ if (trimmed === null) return texts;
274
+ logger[mnemopiDebugEnabled() ? "warn" : "debug"]("mnemopi: embedding input truncated", {
275
+ inputCount: texts.length,
276
+ trimmedCount,
277
+ maxOriginalLen,
278
+ maxInputChars: max,
279
+ });
280
+ return trimmed;
281
+ }
282
+
283
+ function embeddingApiKey(): ApiKey {
284
+ const active = activeEmbeddingOptions();
285
+ if (active?.apiKey !== undefined) {
286
+ return active.apiKey;
287
+ }
288
+ return $env.MNEMOPI_EMBEDDING_API_KEY || $env.OPENROUTER_API_KEY || $env.OPENAI_API_KEY || "";
289
+ }
290
+
291
+ /** A resolver always counts as configured; a static key only when non-empty. */
292
+ function embeddingKeyConfigured(key: ApiKey = embeddingApiKey()): boolean {
293
+ return typeof key === "function" || key !== "";
294
+ }
295
+
296
+ function embeddingBaseUrl(): string {
297
+ const active = activeEmbeddingOptions();
298
+ if (active?.apiUrl !== undefined) {
299
+ return active.apiUrl;
300
+ }
301
+ return $env.MNEMOPI_EMBEDDING_API_URL || $env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
302
+ }
303
+
304
+ function defaultModel(): string {
305
+ const active = activeEmbeddingOptions();
306
+ if (active?.model !== undefined) {
307
+ return active.model;
308
+ }
309
+ return $env.MNEMOPI_EMBEDDING_MODEL || "BAAI/bge-small-en-v1.5";
310
+ }
311
+
312
+ /**
313
+ * Resolve the embedding model name for the currently active runtime scope.
314
+ *
315
+ * Reads (in order): the active provider's `model` from `withMnemopiRuntimeOptions`,
316
+ * the `MNEMOPI_EMBEDDING_MODEL` env var, then the bundled fastembed default. Stored
317
+ * alongside each row in `memory_embeddings.model` so migrations can re-embed when
318
+ * the active model changes.
319
+ */
320
+ export function currentEmbeddingModel(): string {
321
+ return defaultModel();
322
+ }
323
+
324
+ export function isApiModel(modelName: string): boolean {
325
+ if (
326
+ modelName.startsWith("openai/") ||
327
+ modelName.includes("text-embedding") ||
328
+ modelName.startsWith("text-embedding")
329
+ ) {
330
+ return true;
331
+ }
332
+ const active = activeEmbeddingOptions();
333
+ const baseUrl = active?.apiUrl ?? ($env.MNEMOPI_EMBEDDING_API_URL || $env.OPENROUTER_BASE_URL);
334
+ if (baseUrl !== undefined && baseUrl !== "" && !hostMatchesUrl(baseUrl, "openrouter")) {
335
+ return true;
336
+ }
337
+ return $flag("MNEMOPI_EMBEDDINGS_VIA_API");
338
+ }
339
+
340
+ const MODEL_DIMS: Record<string, number> = {
341
+ "BAAI/bge-small-en-v1.5": 384,
342
+ "BAAI/bge-base-en-v1.5": 768,
343
+ "BAAI/bge-large-en-v1.5": 1024,
344
+ "BAAI/bge-small-zh-v1.5": 512,
345
+ "BAAI/bge-base-zh-v1.5": 768,
346
+ "BAAI/bge-large-zh-v1.5": 1024,
347
+ "intfloat/multilingual-e5-small": 384,
348
+ "intfloat/multilingual-e5-base": 768,
349
+ "intfloat/multilingual-e5-large": 1024,
350
+ "BAAI/bge-m3": 1024,
351
+ "BAAI/bge-multilingual-gemma2": 3584,
352
+ "openai/text-embedding-3-small": 1536,
353
+ "openai/text-embedding-3-large": 3072,
354
+ "text-embedding-3-small": 1536,
355
+ "text-embedding-3-large": 3072,
356
+ "jina-embeddings-v5-omni-nano": 768,
357
+ "jina-embeddings-v5-omni-small": 1024,
358
+ };
359
+ export function embeddingDimFor(modelName: string): number {
360
+ const override = Number.parseInt($env.MNEMOPI_EMBEDDING_DIM ?? "", 10);
361
+ if (Number.isFinite(override)) {
362
+ return override;
363
+ }
364
+ return MODEL_DIMS[modelName] ?? 384;
365
+ }
366
+
367
+ /** Drain an embedding stream (a custom provider or fastembed) into a `Float32Array` matrix. */
368
+ async function collectMatrix(batches: EmbeddingOutput): Promise<EmbeddingMatrix> {
369
+ const rows: Vector[] = [];
370
+ for await (const batch of batches) {
371
+ for (const row of batch) {
372
+ rows.push(new Float32Array(row));
373
+ }
374
+ }
375
+ return rows;
376
+ }
377
+
378
+ const KNOWN_MODEL_NAMES: Record<string, string> = {
379
+ "BAAI/bge-small-en-v1.5": "fast-bge-small-en-v1.5",
380
+ "BAAI/bge-base-en-v1.5": "fast-bge-base-en-v1.5",
381
+ "BAAI/bge-small-en": "fast-bge-small-en",
382
+ "BAAI/bge-base-en": "fast-bge-base-en",
383
+ "BAAI/bge-small-zh-v1.5": "fast-bge-small-zh-v1.5",
384
+ "intfloat/multilingual-e5-large": "fast-multilingual-e5-large",
385
+ "sentence-transformers/all-MiniLM-L6-v2": "fast-all-MiniLM-L6-v2",
386
+ };
387
+ function fastembedModelName(modelName: string): StandardEmbeddingModel | null {
388
+ // Fastembed `EmbeddingModel` enum string values, inlined so resolving a model name
389
+ // (and `available()`) never imports `fastembed` — its module eagerly loads the
390
+ // `onnxruntime-node` native addon, which segfaults in some runtimes.
391
+ const id = KNOWN_MODEL_NAMES[modelName];
392
+ return id === undefined ? null : (id as StandardEmbeddingModel);
393
+ }
394
+
395
+ async function getLocalModel(): Promise<LocalEmbeddingModel | null> {
396
+ if (isApiModel(defaultModel()) || embeddingsDisabled() || inTestRuntime()) {
397
+ return null;
398
+ }
399
+ if (localModelPromise !== null) {
400
+ return localModelPromise;
401
+ }
402
+
403
+ const modelName = fastembedModelName(defaultModel());
404
+ if (modelName === null) {
405
+ return null;
406
+ }
407
+ const cacheDir = getFastembedCacheDir();
408
+ mkdirSync(cacheDir, { recursive: true });
409
+ const loading = localModelInitializer({
410
+ model: modelName,
411
+ cacheDir,
412
+ showDownloadProgress: false,
413
+ });
414
+ localModelPromise = loading;
415
+ try {
416
+ return await loading;
417
+ } catch (error) {
418
+ logger[mnemopiDebugEnabled() ? "warn" : "debug"]("mnemopi: local embedding model failed to load", {
419
+ model: modelName,
420
+ error: String(error),
421
+ });
422
+ if (localModelPromise === loading) localModelPromise = null;
423
+ return null;
424
+ }
425
+ }
426
+
427
+ async function embedApi(texts: readonly string[]): Promise<EmbeddingMatrix | null> {
428
+ const baseUrl = embeddingBaseUrl();
429
+ const isCustom = !hostMatchesUrl(baseUrl, "openrouter");
430
+ const apiKey = embeddingApiKey();
431
+ if (!isCustom && !embeddingKeyConfigured(apiKey)) {
432
+ return null;
433
+ }
434
+
435
+ const body = JSON.stringify({ model: defaultModel(), input: texts });
436
+ try {
437
+ // withAuth re-resolves the key on 401 (force-refresh, then sibling
438
+ // rotation) when `apiKey` is a resolver. The 429 backoff stays inside
439
+ // the attempt via fetchWithRetry. An empty static key attempts without
440
+ // an Authorization header (local/proxy setups).
441
+ const response = await withAuth(apiKey, async key => {
442
+ const headers: Record<string, string> = {
443
+ "Content-Type": "application/json",
444
+ ...getOpenRouterHeaders(),
445
+ };
446
+ if (key !== "") {
447
+ headers.Authorization = `Bearer ${key}`;
448
+ }
449
+ const res = await fetchWithRetry(`${baseUrl.replace(/\/+$/, "")}/embeddings`, {
450
+ method: "POST",
451
+ headers,
452
+ body,
453
+ signal: AbortSignal.timeout(30000),
454
+ maxAttempts: 3,
455
+ defaultDelayMs: attempt => 2 ** attempt * 1000,
456
+ });
457
+ if (res.status === 401) {
458
+ throw new ProviderHttpError("mnemopi embedding request unauthorized (401)", 401, { headers: res.headers });
459
+ }
460
+ return res;
461
+ });
462
+ if (!response.ok) {
463
+ return null;
464
+ }
465
+ const { data: rows } = (await response.json()) as { data?: Array<{ embedding: number[] }> };
466
+ if (rows === undefined) {
467
+ return null;
468
+ }
469
+ apiCallCount += 1;
470
+ return rows.map(row => new Float32Array(row.embedding));
471
+ } catch (error) {
472
+ logger.debug("mnemopi embedding request failed", { status: extractHttpStatusFromError(error) });
473
+ return null;
474
+ }
475
+ }
476
+
477
+ async function providerAvailable(provider: EmbeddingProvider): Promise<boolean> {
478
+ if (provider.available === undefined) {
479
+ return true;
480
+ }
481
+ try {
482
+ return await provider.available();
483
+ } catch {
484
+ return false;
485
+ }
486
+ }
487
+
488
+ export function setEmbeddingProviderForTests(provider: EmbeddingProvider | null | undefined): void {
489
+ providerOverride = provider ?? null;
490
+ queryCache.clear();
491
+ }
492
+
493
+ export const setEmbeddingProvider = setEmbeddingProviderForTests;
494
+
495
+ export function setLocalModelInitializerForTests(initializer: LocalModelInitializer | null | undefined): void {
496
+ localModelInitializer = initializer ?? defaultLocalModelInitializer;
497
+ localModelPromise = null;
498
+ queryCache.clear();
499
+ }
500
+
501
+ /**
502
+ * Override the function used to construct the local fastembed model the next
503
+ * time `embed()` is called. Lets a host (e.g. the agent CLI) keep
504
+ * `onnxruntime-node` out of its own address space by routing every fastembed
505
+ * load + inference through a dedicated subprocess. Same wipe semantics as the
506
+ * `*ForTests` form: clears the cached model promise and the query cache so
507
+ * subsequent embeds run through the new initializer immediately.
508
+ */
509
+ export const setLocalModelInitializer = setLocalModelInitializerForTests;
510
+
511
+ export function resetEmbeddingProviderForTests(): void {
512
+ providerOverride = null;
513
+ localModelPromise = null;
514
+ localModelInitializer = defaultLocalModelInitializer;
515
+ apiCallCount = 0;
516
+ queryCache.clear();
517
+ }
518
+
519
+ export const resetEmbeddingStateForTests = resetEmbeddingProviderForTests;
520
+
521
+ export async function available(): Promise<boolean> {
522
+ if (embeddingsDisabled()) {
523
+ return false;
524
+ }
525
+ const active = activeEmbeddingOptions();
526
+ const activeProvider = resolveEmbeddingProvider(active?.provider);
527
+ if (activeProvider !== undefined) {
528
+ return providerAvailable(activeProvider);
529
+ }
530
+ if (providerOverride !== null) {
531
+ return providerAvailable(providerOverride);
532
+ }
533
+ if (isApiModel(defaultModel())) {
534
+ const baseUrl = active?.apiUrl ?? ($env.MNEMOPI_EMBEDDING_API_URL || $env.OPENROUTER_BASE_URL);
535
+ if (baseUrl !== undefined && baseUrl !== "" && !hostMatchesUrl(baseUrl, "openrouter")) {
536
+ return true;
537
+ }
538
+ return embeddingKeyConfigured();
539
+ }
540
+ if (inTestRuntime()) {
541
+ return false;
542
+ }
543
+ return fastembedModelName(defaultModel()) !== null;
544
+ }
545
+
546
+ export function availableApi(): boolean {
547
+ return embeddingKeyConfigured();
548
+ }
549
+
550
+ export async function embedQuery(text: string): Promise<Vector | null> {
551
+ if (text === "" || embeddingsDisabled()) {
552
+ return null;
553
+ }
554
+ const key = queryCacheKey(text);
555
+ const cached = queryCache.get(key);
556
+ if (cached !== undefined) {
557
+ return cached;
558
+ }
559
+ const vectors = await embed([text]);
560
+ const vector = vectors?.[0] ?? null;
561
+ if (vector !== null) {
562
+ queryCache.set(key, vector);
563
+ }
564
+ return vector;
565
+ }
566
+
567
+ export async function embed(texts: readonly string[]): Promise<EmbeddingMatrix | null> {
568
+ if (texts.length === 0 || embeddingsDisabled()) {
569
+ return null;
570
+ }
571
+ texts = capInputs(texts);
572
+ const activeProvider = resolveEmbeddingProvider(activeEmbeddingOptions()?.provider);
573
+ if (activeProvider !== undefined) {
574
+ try {
575
+ return await collectMatrix(await activeProvider.embed(texts));
576
+ } catch {
577
+ return null;
578
+ }
579
+ }
580
+ if (providerOverride !== null) {
581
+ try {
582
+ return await collectMatrix(await providerOverride.embed(texts));
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+ if (isApiModel(defaultModel())) {
588
+ return embedApi(texts);
589
+ }
590
+ if (texts.length === 1) {
591
+ const key = queryCacheKey(texts[0] ?? "");
592
+ const cached = queryCache.get(key);
593
+ if (cached !== undefined) {
594
+ return [cached];
595
+ }
596
+ }
597
+ const model = await getLocalModel();
598
+ if (model === null) {
599
+ return null;
600
+ }
601
+ try {
602
+ const vectors = await collectMatrix(model.embed([...texts]));
603
+ if (vectors.length === 1) {
604
+ const vector = vectors[0];
605
+ if (vector !== undefined) {
606
+ queryCache.set(queryCacheKey(texts[0] ?? ""), vector);
607
+ }
608
+ }
609
+ return vectors;
610
+ } catch (error) {
611
+ logger[mnemopiDebugEnabled() ? "warn" : "debug"]("mnemopi: local embedding failed", {
612
+ textCount: texts.length,
613
+ error: String(error),
614
+ });
615
+ return null;
616
+ }
617
+ }
618
+
619
+ export function getEmbeddingApiCallCountForTests(): number {
620
+ return apiCallCount;
621
+ }
622
+
623
+ export const DEFAULT_MODEL = defaultModel();
624
+ export const EMBEDDING_DIM = embeddingDimFor(DEFAULT_MODEL);