@oh-my-pi/pi-mnemopi 18.16.0 → 18.18.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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.18.0] - 2026-11-07
6
+
7
+ ### Fixed
8
+
9
+ - Fixed embedding rebuilds failing on hosted APIs that cap the inputs per request: `embed()` now splits large lists into requests of at most `MNEMOPI_EMBEDDING_BATCH_SIZE` texts (default 64; set `10` for DashScope) or the `embeddings.batchSize` runtime option, and returns `null` when the API returns a different number of vectors than texts ([#18278](https://github.com/can1357/oh-my-pi/pull/18278) by [@aleister1102](https://github.com/aleister1102))
10
+ - Fixed an embedding model change deleting every stored vector when the new provider had no key, did not answer, or rejected a batch partway through: the store now embeds the whole corpus with the new model before replacing anything, keeps the old vectors if any request fails, and tries again on the next open ([#18278](https://github.com/can1357/oh-my-pi/pull/18278) by [@aleister1102](https://github.com/aleister1102))
11
+ - Fixed rejected embedding API requests leaving no trace: the status and the first 300 characters of the response body now go to the log at `debug`, or at `warn` when runtime debug is on ([#18278](https://github.com/can1357/oh-my-pi/pull/18278) by [@aleister1102](https://github.com/aleister1102))
12
+
13
+ ## [18.17.0] - 2026-11-06
14
+
15
+ ### Changed
16
+
17
+ - Development installs reuse fastembed’s ONNX Runtime instead of installing a duplicate native runtime.
18
+
5
19
  ## [18.14.2] - 2026-10-31
6
20
 
7
21
  ### Fixed
package/README.md CHANGED
@@ -89,6 +89,7 @@ Common environment fallbacks:
89
89
  - `MNEMOPI_NO_EMBEDDINGS=1`: force FTS-only recall.
90
90
  - `MNEMOPI_EMBEDDING_MODEL`: defaults to `BAAI/bge-small-en-v1.5`.
91
91
  - `MNEMOPI_EMBEDDING_API_URL` and `MNEMOPI_EMBEDDING_API_KEY`: OpenAI-compatible embedding endpoint.
92
+ - `MNEMOPI_EMBEDDING_BATCH_SIZE`: texts per embedding API request, default `64`. Larger inputs are split into several requests. Set it lower for a provider with a smaller per-request limit, for example `10` for DashScope.
92
93
  - `MNEMOPI_LLM_ENABLED=1`, `MNEMOPI_LLM_BASE_URL`, `MNEMOPI_LLM_API_KEY`, `MNEMOPI_LLM_MODEL`: OpenAI-compatible LLM endpoint.
93
94
 
94
95
  Local embeddings use the `fastembed` npm package. Its default `BGESmallENV15` model is 384-dimensional and uses the package's CLS pooling plus vector normalization path. Local GGUF LLMs are not available in this package.
@@ -26,18 +26,21 @@ type StoreRememberBatchOptions = RememberBatchOptions & {
26
26
  * Every `memory_embeddings` row is stamped with the model that produced it (see
27
27
  * `runEmbedding` in `helpers.ts`). When the configured embedding model changes,
28
28
  * its vector dimension changes too, so the previously-stored vectors are no
29
- * longer comparable. On a mismatch we wipe every stored vector — the
30
- * `memory_embeddings` table, the `episodic_memory.binary_vector` column, and the
31
- * sqlite-vec `vec_episodes` index — then enqueue all live memories for
32
- * background re-embedding under the new model via `scheduleEmbedding`.
29
+ * longer comparable. On a mismatch we embed one probe text with the new model. Once
30
+ * it answers we embed the whole corpus under the new model in memory. Only when every
31
+ * batch comes back do we replace the stored vectors (the `memory_embeddings` table, the
32
+ * `episodic_memory.binary_vector` column, and the sqlite-vec `vec_episodes` index) in one
33
+ * transaction, so a rejected batch leaves the old vectors in place. The work runs as a
34
+ * tracked background task on `beam.pendingExtractions`, so `flushExtractions()` drains it.
33
35
  *
34
36
  * Runs once per store open; a fresh store (no embeddings) or an already-current
35
37
  * store is a no-op. The destructive wipe is skipped whenever it could not be
36
- * rebuilt — embeddings disabled via the runtime option OR the
37
- * `MNEMOPI_NO_EMBEDDINGS` env, or an unresolved (empty) active model — so a
38
- * stale-but-valid corpus is never destroyed without a replacement. MUST run
39
- * inside the active runtime-options scope so `currentEmbeddingModel()` /
40
- * `embeddingsDisabled()` reflect the per-instance configuration.
38
+ * rebuilt: embeddings disabled via the runtime option OR the `MNEMOPI_NO_EMBEDDINGS`
39
+ * env, an unresolved (empty) active model, or a provider that does not answer the
40
+ * probe (missing key, unreachable endpoint). A stale-but-valid corpus is never
41
+ * destroyed without a replacement. MUST run inside the active runtime-options scope
42
+ * so `currentEmbeddingModel()` / `embeddingsDisabled()` reflect the per-instance
43
+ * configuration.
41
44
  */
42
45
  export declare function reconcileEmbeddingModel(beam: BeamMemoryState): void;
43
46
  export declare function remember(beam: BeamMemoryState, content: string, options?: StoreRememberOptions): string;
@@ -31,6 +31,8 @@ export interface MnemopiEmbeddingRuntimeOptions {
31
31
  provider?: MnemopiEmbeddingProvider | ((texts: readonly string[]) => EmbeddingOutput | Promise<EmbeddingOutput>);
32
32
  /** Override `MNEMOPI_EMBEDDING_MAX_INPUT_CHARS`. `0` disables the cap. See `config.embeddingMaxInputChars`. */
33
33
  maxInputChars?: number;
34
+ /** Override `MNEMOPI_EMBEDDING_BATCH_SIZE`: max texts per embedding API request. */
35
+ batchSize?: number;
34
36
  }
35
37
  export interface MnemopiLlmRuntimeOptions {
36
38
  enabled?: boolean;
@@ -57,6 +59,7 @@ export interface ResolvedMnemopiEmbeddingRuntimeOptions {
57
59
  apiKey?: ApiKey;
58
60
  provider?: MnemopiEmbeddingProvider;
59
61
  maxInputChars?: number;
62
+ batchSize?: number;
60
63
  }
61
64
  export interface ResolvedMnemopiLlmRuntimeOptions {
62
65
  enabled?: boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-mnemopi",
4
- "version": "18.16.0",
4
+ "version": "18.18.0",
5
5
  "description": "Local SQLite memory engine for omp agents",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -35,17 +35,17 @@
35
35
  },
36
36
  "scripts": {
37
37
  "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
38
- "check:types": "tsgo -p tsconfig.json --noEmit",
38
+ "check:types": "bun check --no-script -p tsconfig.json",
39
39
  "lint": "oxlint .",
40
40
  "test": "bun test --parallel",
41
41
  "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
42
42
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
43
43
  },
44
44
  "dependencies": {
45
- "@oh-my-pi/pi-ai": "18.16.0",
46
- "@oh-my-pi/pi-catalog": "18.16.0",
47
- "@oh-my-pi/pi-natives": "18.16.0",
48
- "@oh-my-pi/pi-utils": "18.16.0"
45
+ "@oh-my-pi/pi-ai": "18.18.0",
46
+ "@oh-my-pi/pi-catalog": "18.18.0",
47
+ "@oh-my-pi/pi-natives": "18.18.0",
48
+ "@oh-my-pi/pi-utils": "18.18.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "fastembed": "3.0.0",
@@ -62,7 +62,7 @@
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14",
64
64
  "fastembed": "3.0.0",
65
- "onnxruntime-node": "1.26.0"
65
+ "onnxruntime-node": "1.21.0"
66
66
  },
67
67
  "engines": {
68
68
  "bun": ">=1.4.4"
@@ -3,7 +3,7 @@ import { logger, withLoopPhase } from "@oh-my-pi/pi-utils";
3
3
  import { transaction } from "../../db";
4
4
  import { toUtcIso } from "../../util/datetime";
5
5
  import { generateId } from "../../util/ids";
6
- import { currentEmbeddingModel, embeddingsDisabled } from "../embeddings";
6
+ import { currentEmbeddingModel, embed, embeddingsDisabled } from "../embeddings";
7
7
  import { EpisodicGraph } from "../episodic-graph";
8
8
  import { countExtractedFactCategories, extractFactCategoriesSafe } from "../extraction";
9
9
  import { getMnemopiRuntimeOptions, withMnemopiRuntimeOptions } from "../runtime-options";
@@ -351,18 +351,21 @@ const EMBED_REBUILD_BATCH = 128;
351
351
  * Every `memory_embeddings` row is stamped with the model that produced it (see
352
352
  * `runEmbedding` in `helpers.ts`). When the configured embedding model changes,
353
353
  * its vector dimension changes too, so the previously-stored vectors are no
354
- * longer comparable. On a mismatch we wipe every stored vector — the
355
- * `memory_embeddings` table, the `episodic_memory.binary_vector` column, and the
356
- * sqlite-vec `vec_episodes` index — then enqueue all live memories for
357
- * background re-embedding under the new model via `scheduleEmbedding`.
354
+ * longer comparable. On a mismatch we embed one probe text with the new model. Once
355
+ * it answers we embed the whole corpus under the new model in memory. Only when every
356
+ * batch comes back do we replace the stored vectors (the `memory_embeddings` table, the
357
+ * `episodic_memory.binary_vector` column, and the sqlite-vec `vec_episodes` index) in one
358
+ * transaction, so a rejected batch leaves the old vectors in place. The work runs as a
359
+ * tracked background task on `beam.pendingExtractions`, so `flushExtractions()` drains it.
358
360
  *
359
361
  * Runs once per store open; a fresh store (no embeddings) or an already-current
360
362
  * store is a no-op. The destructive wipe is skipped whenever it could not be
361
- * rebuilt — embeddings disabled via the runtime option OR the
362
- * `MNEMOPI_NO_EMBEDDINGS` env, or an unresolved (empty) active model — so a
363
- * stale-but-valid corpus is never destroyed without a replacement. MUST run
364
- * inside the active runtime-options scope so `currentEmbeddingModel()` /
365
- * `embeddingsDisabled()` reflect the per-instance configuration.
363
+ * rebuilt: embeddings disabled via the runtime option OR the `MNEMOPI_NO_EMBEDDINGS`
364
+ * env, an unresolved (empty) active model, or a provider that does not answer the
365
+ * probe (missing key, unreachable endpoint). A stale-but-valid corpus is never
366
+ * destroyed without a replacement. MUST run inside the active runtime-options scope
367
+ * so `currentEmbeddingModel()` / `embeddingsDisabled()` reflect the per-instance
368
+ * configuration.
366
369
  */
367
370
  export function reconcileEmbeddingModel(beam: BeamMemoryState): void {
368
371
  if (embeddingsDisabled()) return;
@@ -381,35 +384,84 @@ export function reconcileEmbeddingModel(beam: BeamMemoryState): void {
381
384
  // (NULL/unstamped counts as a mismatch via `IS NOT`).
382
385
  const mismatch = beam.db.query("SELECT 1 FROM memory_embeddings WHERE model IS NOT ? LIMIT 1").get(active);
383
386
  if (mismatch) {
384
- const staleModels = beam.db
385
- .query("SELECT DISTINCT model FROM memory_embeddings WHERE model IS NOT ?")
386
- .all(active) as { model: string | null }[];
387
- const live = beam.db
388
- .query(`
389
- SELECT id AS memoryId, COALESCE(embed_text, content) AS content FROM working_memory WHERE superseded_by IS NULL
390
- UNION ALL
391
- SELECT id AS memoryId, content FROM episodic_memory WHERE superseded_by IS NULL
392
- `)
393
- .all() as EmbedItem[];
387
+ // Replace stored vectors only once the whole corpus embeds under the new model. A missing key,
388
+ // an unreachable endpoint, or a rejected batch keeps the stale vectors; the next open retries.
389
+ const migrate = async (): Promise<void> => {
390
+ try {
391
+ const probe = await embed(["mnemopi embedding probe"]);
392
+ if (probe === null || probe.length !== 1) {
393
+ logger.warn("mnemopi: embedding provider did not answer; keeping stored embeddings", { to: active });
394
+ return;
395
+ }
396
+ const staleModels = beam.db
397
+ .query("SELECT DISTINCT model FROM memory_embeddings WHERE model IS NOT ?")
398
+ .all(active) as { model: string | null }[];
399
+ const live = beam.db
400
+ .query(`
401
+ SELECT id AS memoryId, COALESCE(embed_text, content) AS content FROM working_memory WHERE superseded_by IS NULL
402
+ UNION ALL
403
+ SELECT id AS memoryId, content FROM episodic_memory WHERE superseded_by IS NULL
404
+ `)
405
+ .all() as EmbedItem[];
394
406
 
395
- transaction(beam.db, () => {
396
- beam.db.run("DELETE FROM memory_embeddings");
397
- beam.db.run("UPDATE episodic_memory SET binary_vector = NULL");
398
- if (vecAvailable(beam.db)) {
399
- try {
400
- beam.db.run("DELETE FROM vec_episodes");
401
- } catch {
402
- // sqlite-vec cleanup is best-effort; rebuild correctness takes precedence.
407
+ // Embed the whole corpus before touching anything stored. embed() handles the provider's
408
+ // per-request cap; a rejected chunk abandons the rebuild and leaves the stored vectors alone.
409
+ const toEmbed = live.filter(item => item.content.trim() !== "");
410
+ const staged: { memoryId: string; json: string }[] = [];
411
+ for (let offset = 0; offset < toEmbed.length; offset += EMBED_REBUILD_BATCH) {
412
+ const chunk = toEmbed.slice(offset, offset + EMBED_REBUILD_BATCH);
413
+ const vectors = await embed(chunk.map(item => item.content));
414
+ if (vectors === null || vectors.length !== chunk.length) {
415
+ logger.warn("mnemopi: embedding rebuild incomplete; keeping stored embeddings", {
416
+ to: active,
417
+ embedded: staged.length,
418
+ total: toEmbed.length,
419
+ });
420
+ return;
421
+ }
422
+ chunk.forEach((item, index) => {
423
+ const vector = vectors[index];
424
+ if (vector !== undefined) {
425
+ staged.push({ memoryId: item.memoryId, json: JSON.stringify(Array.from(vector)) });
426
+ }
427
+ });
403
428
  }
404
- }
405
- });
406
429
 
407
- logger.info("mnemopi: embedding model changed, rebuilding", {
408
- from: staleModels.map(row => row.model ?? "(unstamped)"),
409
- to: active,
410
- count: live.length,
411
- });
412
- rebuild(live);
430
+ transaction(beam.db, () => {
431
+ // Only stale-model rows go. A vector that a concurrent remember() stamped under the active
432
+ // model survives, and the INSERT below overwrites any row this rebuild covers.
433
+ beam.db.query("DELETE FROM memory_embeddings WHERE model IS NOT ?").run(active);
434
+ beam.db.run("UPDATE episodic_memory SET binary_vector = NULL");
435
+ if (vecAvailable(beam.db)) {
436
+ try {
437
+ beam.db.run("DELETE FROM vec_episodes");
438
+ } catch {
439
+ // sqlite-vec cleanup is best-effort; rebuild correctness takes precedence.
440
+ }
441
+ }
442
+ using insertEmbedding = beam.db.prepare(
443
+ "INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model) VALUES (?, ?, ?)",
444
+ );
445
+ for (const row of staged) insertEmbedding.run(row.memoryId, row.json, active);
446
+ });
447
+ beam.caches.queryCache?.invalidate();
448
+
449
+ logger.info("mnemopi: embedding model changed, rebuilt", {
450
+ from: staleModels.map(row => row.model ?? "(unstamped)"),
451
+ to: active,
452
+ count: staged.length,
453
+ });
454
+ } catch (error) {
455
+ // A store closed during shutdown lands here; the next open resumes from the stamps.
456
+ logger.debug("mnemopi: embedding migration aborted", { error: String(error) });
457
+ }
458
+ };
459
+ const task = withMnemopiRuntimeOptions(getMnemopiRuntimeOptions(), migrate);
460
+ const pending = beam.pendingExtractions;
461
+ if (pending !== undefined) {
462
+ pending.add(task);
463
+ void task.finally(() => pending.delete(task));
464
+ }
413
465
  return;
414
466
  }
415
467
 
@@ -361,7 +361,53 @@ async function getLocalModel(): Promise<LocalEmbeddingModel | null> {
361
361
  }
362
362
  }
363
363
 
364
+ const DEFAULT_EMBEDDING_BATCH_SIZE = 64;
365
+
366
+ /**
367
+ * Max texts per `/embeddings` request. Some hosted providers cap the inputs per
368
+ * request (DashScope: 10), and a rejected request fails the whole call. Reads the
369
+ * runtime scope's `embeddings.batchSize`, then `MNEMOPI_EMBEDDING_BATCH_SIZE`, then 64.
370
+ */
371
+ function embeddingBatchSize(): number {
372
+ return (
373
+ positiveBatchSize(activeEmbeddingOptions()?.batchSize) ??
374
+ positiveBatchSize(Number.parseInt($env.MNEMOPI_EMBEDDING_BATCH_SIZE ?? "", 10)) ??
375
+ DEFAULT_EMBEDDING_BATCH_SIZE
376
+ );
377
+ }
378
+
379
+ /** Floors a configured size to a whole request cap; undefined unless it is at least 1. */
380
+ function positiveBatchSize(value: number | undefined): number | undefined {
381
+ if (value === undefined || !Number.isFinite(value)) return undefined;
382
+ const size = Math.floor(value);
383
+ return size >= 1 ? size : undefined;
384
+ }
385
+
386
+ // Same level policy as the local-model failures: debug, or warn when runtime debug is on.
387
+ function logEmbeddingRejection(status: number, body: string): void {
388
+ logger[mnemopiDebugEnabled() ? "warn" : "debug"]("mnemopi embedding request rejected", {
389
+ status,
390
+ body: body.slice(0, 300),
391
+ });
392
+ }
393
+
364
394
  async function embedApi(texts: readonly string[]): Promise<EmbeddingMatrix | null> {
395
+ const size = embeddingBatchSize();
396
+ if (texts.length <= size) {
397
+ return embedApiBatch(texts);
398
+ }
399
+ const matrix: EmbeddingMatrix = [];
400
+ for (let offset = 0; offset < texts.length; offset += size) {
401
+ const part = await embedApiBatch(texts.slice(offset, offset + size));
402
+ if (part === null) {
403
+ return null;
404
+ }
405
+ matrix.push(...part);
406
+ }
407
+ return matrix;
408
+ }
409
+
410
+ async function embedApiBatch(texts: readonly string[]): Promise<EmbeddingMatrix | null> {
365
411
  const baseUrl = embeddingBaseUrl();
366
412
  const isCustom = !hostMatchesUrl(baseUrl, "openrouter");
367
413
  const apiKey = embeddingApiKey();
@@ -392,24 +438,35 @@ async function embedApi(texts: readonly string[]): Promise<EmbeddingMatrix | nul
392
438
  defaultDelayMs: attempt => 2 ** attempt * 1000,
393
439
  });
394
440
  if (res.status === 401) {
395
- throw new ProviderHttpError("mnemopi embedding request unauthorized (401)", 401, { headers: res.headers });
441
+ // Keep the provider's denial text: the log can only show what the error carries.
442
+ const detail = (await res.text().catch(() => "")).trim();
443
+ throw new ProviderHttpError(detail || "mnemopi embedding request unauthorized (401)", 401, {
444
+ headers: res.headers,
445
+ });
396
446
  }
397
447
  return res;
398
448
  });
399
449
  if (!response.ok) {
450
+ logEmbeddingRejection(response.status, await response.text().catch(() => ""));
400
451
  return null;
401
452
  }
402
453
  const data = (await response.json()) as { data?: Array<{ embedding: number[] }> };
403
454
  return withLoopPhase("mnemopi.embed", () => {
404
455
  const { data: rows } = data;
405
- if (rows === undefined) {
456
+ // A short or long matrix would stamp vectors onto the wrong memories.
457
+ if (rows === undefined || rows.length !== texts.length) {
406
458
  return null;
407
459
  }
408
460
  apiCallCount += 1;
409
461
  return rows.map(row => new Float32Array(row.embedding));
410
462
  });
411
463
  } catch (error) {
412
- logger.debug("mnemopi embedding request failed", { status: extractHttpStatusFromError(error) });
464
+ const status = extractHttpStatusFromError(error);
465
+ if (status === undefined) {
466
+ logger.debug("mnemopi embedding request failed", { error: String(error) });
467
+ } else {
468
+ logEmbeddingRejection(status, error instanceof Error ? error.message : "");
469
+ }
413
470
  return null;
414
471
  }
415
472
  }
@@ -194,6 +194,7 @@ function resolveRuntimeOptions(options: MnemopiOptions): ResolvedMnemopiRuntimeO
194
194
  const embeddingApiKey = options.embeddingApiKey ?? nestedEmbeddings?.apiKey;
195
195
  const embeddingProvider = resolveEmbeddingProvider(nestedEmbeddings?.provider);
196
196
  const embeddingMaxInputChars = nestedEmbeddings?.maxInputChars;
197
+ const embeddingBatchSize = nestedEmbeddings?.batchSize;
197
198
 
198
199
  const embeddings =
199
200
  embeddingDisabled !== undefined ||
@@ -201,7 +202,8 @@ function resolveRuntimeOptions(options: MnemopiOptions): ResolvedMnemopiRuntimeO
201
202
  embeddingApiUrl !== undefined ||
202
203
  embeddingApiKey !== undefined ||
203
204
  embeddingProvider !== undefined ||
204
- embeddingMaxInputChars !== undefined
205
+ embeddingMaxInputChars !== undefined ||
206
+ embeddingBatchSize !== undefined
205
207
  ? {
206
208
  disabled: embeddingDisabled,
207
209
  model: embeddingModel,
@@ -209,6 +211,7 @@ function resolveRuntimeOptions(options: MnemopiOptions): ResolvedMnemopiRuntimeO
209
211
  apiKey: embeddingApiKey,
210
212
  provider: embeddingProvider,
211
213
  maxInputChars: embeddingMaxInputChars,
214
+ batchSize: embeddingBatchSize,
212
215
  }
213
216
  : undefined;
214
217
 
@@ -41,6 +41,8 @@ export interface MnemopiEmbeddingRuntimeOptions {
41
41
  provider?: MnemopiEmbeddingProvider | ((texts: readonly string[]) => EmbeddingOutput | Promise<EmbeddingOutput>);
42
42
  /** Override `MNEMOPI_EMBEDDING_MAX_INPUT_CHARS`. `0` disables the cap. See `config.embeddingMaxInputChars`. */
43
43
  maxInputChars?: number;
44
+ /** Override `MNEMOPI_EMBEDDING_BATCH_SIZE`: max texts per embedding API request. */
45
+ batchSize?: number;
44
46
  }
45
47
 
46
48
  export interface MnemopiLlmRuntimeOptions {
@@ -70,6 +72,7 @@ export interface ResolvedMnemopiEmbeddingRuntimeOptions {
70
72
  apiKey?: ApiKey;
71
73
  provider?: MnemopiEmbeddingProvider;
72
74
  maxInputChars?: number;
75
+ batchSize?: number;
73
76
  }
74
77
 
75
78
  export interface ResolvedMnemopiLlmRuntimeOptions {