@oh-my-pi/pi-mnemopi 18.18.2 → 18.18.4

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.18.4] - 2026-11-09
6
+
7
+ ### Fixed
8
+
9
+ - Fixed late background embedding writes overwriting newer vectors for updated memories or recreating vectors for deleted memories ([#18503](https://github.com/can1357/oh-my-pi/pull/18503) by [@eggpeat](https://github.com/eggpeat))
10
+ - Fixed a second `Mnemopi` instance configured for a different local embedding model silently reusing the first instance's encoder while stamping its vectors with the configured model: local encoders are now cached per model, and a failed load evicts only that model's entry ([#18562](https://github.com/can1357/oh-my-pi/pull/18562) by [@eggpeat](https://github.com/eggpeat))
11
+
5
12
  ## [18.18.2] - 2026-11-08
6
13
 
7
14
  ### Fixed
@@ -53,6 +53,24 @@ export interface EmbedItem {
53
53
  readonly memoryId: string;
54
54
  readonly content: string;
55
55
  }
56
+ /** One derived vector waiting to be written, keyed to the memory and the exact text embedded. */
57
+ export interface StagedEmbedding extends EmbedItem {
58
+ readonly json: string;
59
+ }
60
+ /**
61
+ * Upsert each staged vector only if its memory is still live (not superseded) and its
62
+ * effective embedding text -- `COALESCE(embed_text, content)` for working memories,
63
+ * `content` for episodic ones -- still equals the text that was embedded. Returns the
64
+ * number of rows actually written, which is what gates callers' cache invalidation.
65
+ * `memory_embeddings` rows are only ever written here, so this is the one place that
66
+ * rejects the two stale writes of a slow `embed()` call: a late vector for a memory
67
+ * whose text changed after an update, and a late vector for a deleted memory.
68
+ *
69
+ * Relies on every `scheduleEmbedding` caller passing exactly the stored effective text
70
+ * (`remember`, `rememberBatch`, `updateWorking` -- which nulls `embed_text` -- and
71
+ * `consolidateToEpisodic`); a mismatched text is silently dropped, not re-embedded.
72
+ */
73
+ export declare function insertLiveEmbeddings(db: Database, rows: readonly StagedEmbedding[], model: string): number;
56
74
  /**
57
75
  * Schedule background embedding generation for one or more freshly stored memories.
58
76
  *
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.18.2",
4
+ "version": "18.18.4",
5
5
  "description": "Local SQLite memory engine for omp agents",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -42,10 +42,10 @@
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.18.2",
46
- "@oh-my-pi/pi-catalog": "18.18.2",
47
- "@oh-my-pi/pi-natives": "18.18.2",
48
- "@oh-my-pi/pi-utils": "18.18.2"
45
+ "@oh-my-pi/pi-ai": "18.18.4",
46
+ "@oh-my-pi/pi-catalog": "18.18.4",
47
+ "@oh-my-pi/pi-natives": "18.18.4",
48
+ "@oh-my-pi/pi-utils": "18.18.4"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "fastembed": "3.0.0",
@@ -723,6 +723,54 @@ export interface EmbedItem {
723
723
  readonly content: string;
724
724
  }
725
725
 
726
+ /** One derived vector waiting to be written, keyed to the memory and the exact text embedded. */
727
+ export interface StagedEmbedding extends EmbedItem {
728
+ readonly json: string;
729
+ }
730
+
731
+ const INSERT_LIVE_EMBEDDING_SQL = `
732
+ INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model)
733
+ SELECT ?, ?, ?
734
+ WHERE EXISTS (
735
+ SELECT 1 FROM working_memory
736
+ WHERE id = ? AND superseded_by IS NULL AND COALESCE(embed_text, content) = ?
737
+ ) OR EXISTS (
738
+ SELECT 1 FROM episodic_memory
739
+ WHERE id = ? AND superseded_by IS NULL AND content = ?
740
+ )
741
+ `;
742
+
743
+ /**
744
+ * Upsert each staged vector only if its memory is still live (not superseded) and its
745
+ * effective embedding text -- `COALESCE(embed_text, content)` for working memories,
746
+ * `content` for episodic ones -- still equals the text that was embedded. Returns the
747
+ * number of rows actually written, which is what gates callers' cache invalidation.
748
+ * `memory_embeddings` rows are only ever written here, so this is the one place that
749
+ * rejects the two stale writes of a slow `embed()` call: a late vector for a memory
750
+ * whose text changed after an update, and a late vector for a deleted memory.
751
+ *
752
+ * Relies on every `scheduleEmbedding` caller passing exactly the stored effective text
753
+ * (`remember`, `rememberBatch`, `updateWorking` -- which nulls `embed_text` -- and
754
+ * `consolidateToEpisodic`); a mismatched text is silently dropped, not re-embedded.
755
+ */
756
+ export function insertLiveEmbeddings(db: Database, rows: readonly StagedEmbedding[], model: string): number {
757
+ using insertEmbedding = db.prepare(INSERT_LIVE_EMBEDDING_SQL);
758
+ let committed = 0;
759
+ for (const row of rows) {
760
+ const result = insertEmbedding.run(
761
+ row.memoryId,
762
+ row.json,
763
+ model,
764
+ row.memoryId,
765
+ row.content,
766
+ row.memoryId,
767
+ row.content,
768
+ );
769
+ committed += result.changes;
770
+ }
771
+ return committed;
772
+ }
773
+
726
774
  async function runEmbedding(beam: BeamMemoryState, items: readonly EmbedItem[]): Promise<void> {
727
775
  try {
728
776
  const matrix = await embed(items.map(item => item.content));
@@ -731,27 +779,29 @@ async function runEmbedding(beam: BeamMemoryState, items: readonly EmbedItem[]):
731
779
  // stall is attributable. The embedding itself is awaited above, outside the label.
732
780
  withLoopPhase("mnemopi.embed-persist", () => {
733
781
  const model = currentEmbeddingModel();
734
- using insertEmbedding = beam.db.prepare(
735
- "INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model) VALUES (?, ?, ?)",
782
+ const staged: StagedEmbedding[] = [];
783
+ for (let i = 0; i < items.length; i += 1) {
784
+ const vector = matrix[i];
785
+ const item = items[i];
786
+ if (vector === undefined || item === undefined) continue;
787
+ staged.push({
788
+ memoryId: item.memoryId,
789
+ content: item.content,
790
+ json: JSON.stringify(Array.from(vector)),
791
+ });
792
+ }
793
+ const insertMany = beam.db.transaction((rows: readonly StagedEmbedding[]) =>
794
+ insertLiveEmbeddings(beam.db, rows, model),
736
795
  );
737
- let committed = 0;
738
- const insertMany = beam.db.transaction((rows: readonly EmbedItem[]) => {
739
- for (let i = 0; i < rows.length; i += 1) {
740
- const vector = matrix[i];
741
- const item = rows[i];
742
- if (vector === undefined || item === undefined) continue;
743
- insertEmbedding.run(item.memoryId, JSON.stringify(Array.from(vector)), model);
744
- committed += 1;
745
- }
746
- });
747
- insertMany(items);
796
+ const committed = insertMany(staged);
748
797
  // A recall taken while these vectors were still generating cached an FTS-only ranking, and
749
798
  // committing vectors changes what recall returns -- so that cache must be dropped, or the
750
799
  // pre-embedding order keeps being served for the whole cache TTL. Gated on `committed`: a
751
- // batch whose provider returned a short or empty matrix inserts nothing, changes no ranking,
752
- // and invalidating there would only discard a still-valid cache. Only the query cache is
753
- // affected; the polyphonic subject dictionary is built from facts/gists, which an embedding
754
- // batch never touches.
800
+ // batch whose provider returned a short or empty matrix inserts nothing, and a row whose
801
+ // memory was updated or deleted while its vector was in flight is discarded instead of
802
+ // written; neither changes a ranking, and invalidating there would only discard a
803
+ // still-valid cache. Only the query cache is affected; the polyphonic subject dictionary
804
+ // is built from facts/gists, which an embedding batch never touches.
755
805
  if (committed > 0) beam.caches.queryCache?.invalidate();
756
806
  });
757
807
  } catch (error) {
@@ -8,7 +8,14 @@ import { EpisodicGraph } from "../episodic-graph";
8
8
  import { countExtractedFactCategories, extractFactCategoriesSafe } from "../extraction";
9
9
  import { getMnemopiRuntimeOptions, withMnemopiRuntimeOptions } from "../runtime-options";
10
10
  import { storeExtractedFactCategories } from "./consolidate";
11
- import { type EmbedItem, scheduleEmbedding, vecAvailable, vecInsert } from "./helpers";
11
+ import {
12
+ type EmbedItem,
13
+ insertLiveEmbeddings,
14
+ type StagedEmbedding,
15
+ scheduleEmbedding,
16
+ vecAvailable,
17
+ vecInsert,
18
+ } from "./helpers";
12
19
  import type {
13
20
  BeamEvent,
14
21
  BeamMemoryState,
@@ -407,7 +414,7 @@ export function reconcileEmbeddingModel(beam: BeamMemoryState): void {
407
414
  // Embed the whole corpus before touching anything stored. embed() handles the provider's
408
415
  // per-request cap; a rejected chunk abandons the rebuild and leaves the stored vectors alone.
409
416
  const toEmbed = live.filter(item => item.content.trim() !== "");
410
- const staged: { memoryId: string; content: string; json: string }[] = [];
417
+ const staged: StagedEmbedding[] = [];
411
418
  for (let offset = 0; offset < toEmbed.length; offset += EMBED_REBUILD_BATCH) {
412
419
  const chunk = toEmbed.slice(offset, offset + EMBED_REBUILD_BATCH);
413
420
  const vectors = await embed(chunk.map(item => item.content));
@@ -443,28 +450,7 @@ export function reconcileEmbeddingModel(beam: BeamMemoryState): void {
443
450
  // sqlite-vec cleanup is best-effort; rebuild correctness takes precedence.
444
451
  }
445
452
  }
446
- using insertEmbedding = beam.db.prepare(`
447
- INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model)
448
- SELECT ?, ?, ?
449
- WHERE EXISTS (
450
- SELECT 1 FROM working_memory
451
- WHERE id = ? AND superseded_by IS NULL AND COALESCE(embed_text, content) = ?
452
- ) OR EXISTS (
453
- SELECT 1 FROM episodic_memory
454
- WHERE id = ? AND superseded_by IS NULL AND content = ?
455
- )
456
- `);
457
- for (const row of staged) {
458
- insertEmbedding.run(
459
- row.memoryId,
460
- row.json,
461
- active,
462
- row.memoryId,
463
- row.content,
464
- row.memoryId,
465
- row.content,
466
- );
467
- }
453
+ insertLiveEmbeddings(beam.db, staged, active);
468
454
  });
469
455
  beam.caches.queryCache?.invalidate();
470
456
 
@@ -52,7 +52,11 @@ export type LocalModelInitializer = (options: LocalModelInitOptions) => Promise<
52
52
  const QUERY_CACHE_MAX = 512;
53
53
 
54
54
  let providerOverride: EmbeddingProvider | null = null;
55
- let localModelPromise: Promise<LocalEmbeddingModel> | null = null;
55
+ // Local encoders are cached per resolved fastembed model id: two Mnemopi instances
56
+ // in one process configured with different local models must each load their own
57
+ // encoder instead of reusing whichever model happened to initialize first. A failed
58
+ // init evicts only that model's entry so other models' encoders stay warm.
59
+ const localModelPromises = new Map<string, Promise<LocalEmbeddingModel>>();
56
60
  let localModelInitializer: LocalModelInitializer = defaultLocalModelInitializer;
57
61
  let apiCallCount = 0;
58
62
  const queryCache = new LRUCache<string, Vector>({ max: QUERY_CACHE_MAX });
@@ -330,17 +334,20 @@ export function fastembedModelName(modelName: string): StandardEmbeddingModel |
330
334
  }
331
335
 
332
336
  async function getLocalModel(): Promise<LocalEmbeddingModel | null> {
333
- if (isApiModel(defaultModel()) || embeddingsDisabled() || inTestRuntime()) {
337
+ const configuredModel = defaultModel();
338
+ if (isApiModel(configuredModel) || embeddingsDisabled() || inTestRuntime()) {
334
339
  return null;
335
340
  }
336
- if (localModelPromise !== null) {
337
- return localModelPromise;
338
- }
339
341
 
340
- const modelName = fastembedModelName(defaultModel());
342
+ const modelName = fastembedModelName(configuredModel);
341
343
  if (modelName === null) {
342
344
  return null;
343
345
  }
346
+ const cached = localModelPromises.get(modelName);
347
+ if (cached !== undefined) {
348
+ return cached;
349
+ }
350
+
344
351
  const cacheDir = getFastembedCacheDir();
345
352
  mkdirSync(cacheDir, { recursive: true });
346
353
  const loading = localModelInitializer({
@@ -348,7 +355,7 @@ async function getLocalModel(): Promise<LocalEmbeddingModel | null> {
348
355
  cacheDir,
349
356
  showDownloadProgress: false,
350
357
  });
351
- localModelPromise = loading;
358
+ localModelPromises.set(modelName, loading);
352
359
  try {
353
360
  return await loading;
354
361
  } catch (error) {
@@ -356,7 +363,7 @@ async function getLocalModel(): Promise<LocalEmbeddingModel | null> {
356
363
  model: modelName,
357
364
  error: String(error),
358
365
  });
359
- if (localModelPromise === loading) localModelPromise = null;
366
+ if (localModelPromises.get(modelName) === loading) localModelPromises.delete(modelName);
360
367
  return null;
361
368
  }
362
369
  }
@@ -491,7 +498,7 @@ export const setEmbeddingProvider = setEmbeddingProviderForTests;
491
498
 
492
499
  export function setLocalModelInitializerForTests(initializer: LocalModelInitializer | null | undefined): void {
493
500
  localModelInitializer = initializer ?? defaultLocalModelInitializer;
494
- localModelPromise = null;
501
+ localModelPromises.clear();
495
502
  queryCache.clear();
496
503
  }
497
504
 
@@ -507,7 +514,7 @@ export const setLocalModelInitializer = setLocalModelInitializerForTests;
507
514
 
508
515
  export function resetEmbeddingProviderForTests(): void {
509
516
  providerOverride = null;
510
- localModelPromise = null;
517
+ localModelPromises.clear();
511
518
  localModelInitializer = defaultLocalModelInitializer;
512
519
  apiCallCount = 0;
513
520
  queryCache.clear();