@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.
- package/CHANGELOG.md +248 -0
- package/README.md +108 -0
- package/dist/types/cli.d.ts +35 -0
- package/dist/types/config.d.ts +108 -0
- package/dist/types/core/aaak.d.ts +55 -0
- package/dist/types/core/annotations.d.ts +75 -0
- package/dist/types/core/banks.d.ts +33 -0
- package/dist/types/core/beam/consolidate.d.ts +38 -0
- package/dist/types/core/beam/helpers.d.ts +69 -0
- package/dist/types/core/beam/index.d.ts +59 -0
- package/dist/types/core/beam/recall.d.ts +52 -0
- package/dist/types/core/beam/schema.d.ts +2 -0
- package/dist/types/core/beam/store.d.ts +57 -0
- package/dist/types/core/beam/types.d.ts +268 -0
- package/dist/types/core/binary-vectors.d.ts +54 -0
- package/dist/types/core/chat-normalize.d.ts +13 -0
- package/dist/types/core/content-sanitizer.d.ts +18 -0
- package/dist/types/core/cost-log.d.ts +13 -0
- package/dist/types/core/embeddings.d.ts +93 -0
- package/dist/types/core/entities.d.ts +9 -0
- package/dist/types/core/episodic-graph.d.ts +89 -0
- package/dist/types/core/extraction/client.d.ts +35 -0
- package/dist/types/core/extraction/diagnostics.d.ts +51 -0
- package/dist/types/core/extraction/prompts.d.ts +2 -0
- package/dist/types/core/extraction.d.ts +34 -0
- package/dist/types/core/fastembed-model-cache.d.ts +2 -0
- package/dist/types/core/fastembed-runtime.d.ts +41 -0
- package/dist/types/core/index.d.ts +6 -0
- package/dist/types/core/llm-backends.d.ts +23 -0
- package/dist/types/core/local-llm.d.ts +20 -0
- package/dist/types/core/memory.d.ts +186 -0
- package/dist/types/core/migrations/e6-triplestore-split.d.ts +17 -0
- package/dist/types/core/migrations/index.d.ts +1 -0
- package/dist/types/core/mmr.d.ts +8 -0
- package/dist/types/core/orchestrator.d.ts +20 -0
- package/dist/types/core/patterns.d.ts +61 -0
- package/dist/types/core/plugins.d.ts +109 -0
- package/dist/types/core/polyphonic-recall.d.ts +66 -0
- package/dist/types/core/query-cache.d.ts +45 -0
- package/dist/types/core/query-intent.d.ts +20 -0
- package/dist/types/core/recall-diagnostics.d.ts +48 -0
- package/dist/types/core/runtime-options.d.ts +76 -0
- package/dist/types/core/shmr.d.ts +62 -0
- package/dist/types/core/streaming.d.ts +136 -0
- package/dist/types/core/synonyms.d.ts +46 -0
- package/dist/types/core/temporal-parser.d.ts +16 -0
- package/dist/types/core/token-counter.d.ts +8 -0
- package/dist/types/core/triples.d.ts +63 -0
- package/dist/types/core/typed-memory.d.ts +39 -0
- package/dist/types/core/vector-index.d.ts +16 -0
- package/dist/types/core/vector-math.d.ts +1 -0
- package/dist/types/core/veracity-consolidation.d.ts +60 -0
- package/dist/types/core/weibull.d.ts +96 -0
- package/dist/types/db.d.ts +18 -0
- package/dist/types/diagnose.d.ts +24 -0
- package/dist/types/dr/index.d.ts +1 -0
- package/dist/types/dr/recovery.d.ts +68 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/mcp-server.d.ts +40 -0
- package/dist/types/mcp-tools.d.ts +484 -0
- package/dist/types/migrations/e6-triplestore-split.d.ts +1 -0
- package/dist/types/migrations/index.d.ts +1 -0
- package/dist/types/types.d.ts +145 -0
- package/dist/types/util/datetime.d.ts +8 -0
- package/dist/types/util/env.d.ts +10 -0
- package/dist/types/util/ids.d.ts +3 -0
- package/dist/types/util/lru.d.ts +12 -0
- package/dist/types/util/regex.d.ts +11 -0
- package/package.json +108 -0
- package/src/cli.ts +398 -0
- package/src/config.ts +373 -0
- package/src/core/aaak.ts +142 -0
- package/src/core/annotations.ts +456 -0
- package/src/core/banks.ts +133 -0
- package/src/core/beam/consolidate.ts +1132 -0
- package/src/core/beam/helpers.ts +831 -0
- package/src/core/beam/index.ts +358 -0
- package/src/core/beam/recall.ts +1225 -0
- package/src/core/beam/schema.ts +426 -0
- package/src/core/beam/store.ts +1083 -0
- package/src/core/beam/types.ts +303 -0
- package/src/core/binary-vectors.ts +317 -0
- package/src/core/chat-normalize.ts +160 -0
- package/src/core/content-sanitizer.ts +136 -0
- package/src/core/cost-log.ts +101 -0
- package/src/core/embeddings.ts +624 -0
- package/src/core/entities.ts +263 -0
- package/src/core/episodic-graph.ts +708 -0
- package/src/core/extraction/client.ts +187 -0
- package/src/core/extraction/diagnostics.ts +193 -0
- package/src/core/extraction/prompts.ts +31 -0
- package/src/core/extraction.ts +491 -0
- package/src/core/fastembed-model-cache.ts +39 -0
- package/src/core/fastembed-runtime.ts +172 -0
- package/src/core/index.ts +39 -0
- package/src/core/llm-backends.ts +54 -0
- package/src/core/local-llm.ts +475 -0
- package/src/core/memory.ts +680 -0
- package/src/core/migrations/e6-triplestore-split.ts +211 -0
- package/src/core/migrations/index.ts +1 -0
- package/src/core/mmr.ts +101 -0
- package/src/core/orchestrator.ts +62 -0
- package/src/core/patterns.ts +484 -0
- package/src/core/plugins.ts +375 -0
- package/src/core/polyphonic-recall.ts +563 -0
- package/src/core/query-cache.ts +351 -0
- package/src/core/query-intent.ts +139 -0
- package/src/core/recall-diagnostics.ts +157 -0
- package/src/core/runtime-options.ts +130 -0
- package/src/core/shmr.ts +567 -0
- package/src/core/streaming.ts +419 -0
- package/src/core/synonyms.ts +197 -0
- package/src/core/temporal-parser.ts +363 -0
- package/src/core/token-counter.ts +30 -0
- package/src/core/triples.ts +452 -0
- package/src/core/typed-memory.ts +407 -0
- package/src/core/vector-index.ts +83 -0
- package/src/core/vector-math.ts +23 -0
- package/src/core/veracity-consolidation.ts +477 -0
- package/src/core/weibull.ts +124 -0
- package/src/db.ts +186 -0
- package/src/diagnose.ts +174 -0
- package/src/dr/index.ts +1 -0
- package/src/dr/recovery.ts +405 -0
- package/src/index.ts +33 -0
- package/src/mcp-server.ts +155 -0
- package/src/mcp-tools.ts +970 -0
- package/src/migrations/e6-triplestore-split.ts +1 -0
- package/src/migrations/index.ts +1 -0
- package/src/types.ts +157 -0
- package/src/util/datetime.ts +69 -0
- package/src/util/env.ts +65 -0
- package/src/util/ids.ts +19 -0
- package/src/util/lru.ts +48 -0
- 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);
|