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

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,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.18.2] - 2026-11-08
6
+
7
+ ### Fixed
8
+
9
+ - Fixed recall returning episodic summaries for working memories that had been invalidated.
10
+
11
+ ## [18.18.1] - 2026-11-08
12
+
13
+ ### Fixed
14
+
15
+ - Fixed recall scoring memories against vectors from a previous embedding model while a model-change rebuild was still running or had failed: dense search now only uses vectors stamped with the active model, falling back to lexical matching for memories awaiting re-embedding ([#18442](https://github.com/can1357/oh-my-pi/pull/18442) by [@eggpeat](https://github.com/eggpeat))
16
+ - Fixed model-change embedding rebuilds overwriting newer vectors for updated memories or recreating vectors for deleted memories ([#18441](https://github.com/can1357/oh-my-pi/pull/18441) by [@eggpeat](https://github.com/eggpeat))
17
+
5
18
  ## [18.18.0] - 2026-11-07
6
19
 
7
20
  ### Fixed
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.0",
4
+ "version": "18.18.2",
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.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"
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"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "fastembed": "3.0.0",
@@ -13,7 +13,7 @@ import {
13
13
  unknownWeight,
14
14
  } from "../../config";
15
15
  import { hasCjk, matchesWordForm } from "../../util/regex";
16
- import { embedQuery } from "../embeddings";
16
+ import { currentEmbeddingModel, embedQuery } from "../embeddings";
17
17
  import { mmrRerank } from "../mmr";
18
18
  import { type OrchestratedRecallResult, orchestrateRecall } from "../orchestrator";
19
19
  import { POLYPHONIC_MAX_COMBINED_SCORE } from "../polyphonic-recall";
@@ -604,12 +604,15 @@ function vectorSimilarities(
604
604
  ) {
605
605
  return out;
606
606
  }
607
+ // Only vectors stamped with the active embedding model are comparable to the
608
+ // query embedding; stale-model rows stay on disk for the rebuild but must not score.
609
+ const model = currentEmbeddingModel();
607
610
  for (let offset = 0; offset < memoryIds.length; offset += 500) {
608
611
  const chunk = memoryIds.slice(offset, offset + 500);
609
612
  const rows = queryAll(
610
613
  beam,
611
- `SELECT memory_id, embedding_json FROM memory_embeddings WHERE memory_id IN (${placeholders(chunk.length)})`,
612
- chunk,
614
+ `SELECT memory_id, embedding_json FROM memory_embeddings WHERE model IS ? AND memory_id IN (${placeholders(chunk.length)})`,
615
+ [model, ...chunk],
613
616
  );
614
617
  for (const row of rows) {
615
618
  const vector = parseEmbedding(row.embedding_json);
@@ -407,7 +407,7 @@ export function reconcileEmbeddingModel(beam: BeamMemoryState): void {
407
407
  // Embed the whole corpus before touching anything stored. embed() handles the provider's
408
408
  // per-request cap; a rejected chunk abandons the rebuild and leaves the stored vectors alone.
409
409
  const toEmbed = live.filter(item => item.content.trim() !== "");
410
- const staged: { memoryId: string; json: string }[] = [];
410
+ const staged: { memoryId: string; content: string; json: string }[] = [];
411
411
  for (let offset = 0; offset < toEmbed.length; offset += EMBED_REBUILD_BATCH) {
412
412
  const chunk = toEmbed.slice(offset, offset + EMBED_REBUILD_BATCH);
413
413
  const vectors = await embed(chunk.map(item => item.content));
@@ -422,14 +422,18 @@ export function reconcileEmbeddingModel(beam: BeamMemoryState): void {
422
422
  chunk.forEach((item, index) => {
423
423
  const vector = vectors[index];
424
424
  if (vector !== undefined) {
425
- staged.push({ memoryId: item.memoryId, json: JSON.stringify(Array.from(vector)) });
425
+ staged.push({
426
+ memoryId: item.memoryId,
427
+ content: item.content,
428
+ json: JSON.stringify(Array.from(vector)),
429
+ });
426
430
  }
427
431
  });
428
432
  }
429
433
 
430
434
  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.
435
+ // Only stale-model rows go. A vector that a concurrent remember() or update() stamped
436
+ // under the active model survives. Each staged insert also revalidates its source text.
433
437
  beam.db.query("DELETE FROM memory_embeddings WHERE model IS NOT ?").run(active);
434
438
  beam.db.run("UPDATE episodic_memory SET binary_vector = NULL");
435
439
  if (vecAvailable(beam.db)) {
@@ -439,10 +443,28 @@ export function reconcileEmbeddingModel(beam: BeamMemoryState): void {
439
443
  // sqlite-vec cleanup is best-effort; rebuild correctness takes precedence.
440
444
  }
441
445
  }
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
+ 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
+ }
446
468
  });
447
469
  beam.caches.queryCache?.invalidate();
448
470
 
@@ -693,37 +715,39 @@ export function getContext(beam: BeamMemoryState, limit = 10): Row[] {
693
715
 
694
716
  export function invalidate(beam: BeamMemoryState, memoryId: string, replacementId: string | null = null): boolean {
695
717
  const now = toUtcIso();
696
- const working = beam.db.run(
697
- `
698
- UPDATE working_memory
699
- SET valid_until = ?, superseded_by = ?
700
- WHERE id = ? AND (session_id = ? OR scope = 'global')
701
- `,
702
- [now, replacementId, memoryId, beam.sessionId],
703
- );
704
- if (working.changes > 0) {
705
- // Recall filters `valid_until`/`superseded_by` in SQL, but the enhanced path consults the
706
- // query cache BEFORE it reaches SQL. Without this the row a caller just retired keeps being
707
- // served to an identical query for the rest of the cache TTL -- the one thing an explicit
708
- // invalidation is supposed to guarantee against. Every other mutator here already does this;
709
- // this one was the omission. Gated on an actual row change, like `forgetWorking`, so a
710
- // no-op invalidation never discards a valid cache.
711
- invalidateCaches(beam);
712
- return true;
713
- }
714
- const episodic = beam.db.run(
715
- `
716
- UPDATE episodic_memory
717
- SET valid_until = ?, superseded_by = ?
718
- WHERE id = ? AND (session_id = ? OR scope = 'global')
719
- `,
720
- [now, replacementId, memoryId, beam.sessionId],
721
- );
722
- if (episodic.changes > 0) {
723
- invalidateCaches(beam);
724
- return true;
725
- }
726
- return false;
718
+ const changed = transaction(beam.db, () => {
719
+ const working = beam.db.run(
720
+ `
721
+ UPDATE working_memory
722
+ SET valid_until = ?, superseded_by = ?
723
+ WHERE id = ? AND (session_id = ? OR scope = 'global')
724
+ `,
725
+ [now, replacementId, memoryId, beam.sessionId],
726
+ );
727
+ if (working.changes > 0) {
728
+ // Retire episodic summaries built from this source: they still carry its text. Siblings
729
+ // covered by the same summary stay recallable via their (consolidated, never TTL-trimmed)
730
+ // working rows.
731
+ beam.db.run(
732
+ `UPDATE episodic_memory SET valid_until = ?, superseded_by = ?
733
+ WHERE (session_id = ? OR scope = 'global')
734
+ AND instr(',' || summary_of || ',', ',' || ? || ',') > 0`,
735
+ [now, replacementId, beam.sessionId, memoryId],
736
+ );
737
+ return true;
738
+ }
739
+ const episodic = beam.db.run(
740
+ `
741
+ UPDATE episodic_memory
742
+ SET valid_until = ?, superseded_by = ?
743
+ WHERE id = ? AND (session_id = ? OR scope = 'global')
744
+ `,
745
+ [now, replacementId, memoryId, beam.sessionId],
746
+ );
747
+ return episodic.changes > 0;
748
+ });
749
+ if (changed) invalidateCaches(beam);
750
+ return changed;
727
751
  }
728
752
 
729
753
  export function getWorkingStats(
@@ -5,6 +5,7 @@ import { closeQuietly, type DatabasePath, openDatabase } from "../db";
5
5
  import { backfillConsolidatedFacts, ensureVeracityConsolidator } from "./beam/consolidate";
6
6
  import type { BeamMemoryState, JsonValue, Metadata, RecallResult, RecallTierLabel } from "./beam/types";
7
7
  import { EpisodicGraph, extractProperNouns } from "./episodic-graph";
8
+ import { currentEmbeddingModel } from "./embeddings";
8
9
  import { STOP_WORDS } from "./synonyms";
9
10
  import { VeracityConsolidator } from "./veracity-consolidation";
10
11
 
@@ -342,6 +343,7 @@ export class PolyphonicRecallEngine {
342
343
  const queryUnit = normalizeVector(queryEmbedding);
343
344
  if (queryUnit === null) return [];
344
345
  const now = new Date().toISOString();
346
+ const model = currentEmbeddingModel();
345
347
  const working = this.#visibility("wm", options.channelId);
346
348
  const episodic = this.#visibility("em", options.channelId);
347
349
  let rows: EmbeddingRow[] = [];
@@ -351,19 +353,21 @@ export class PolyphonicRecallEngine {
351
353
  SELECT me.memory_id, me.embedding_json, 'working' AS embedding_tier
352
354
  FROM memory_embeddings me
353
355
  JOIN working_memory wm ON wm.id = me.memory_id
354
- WHERE wm.superseded_by IS NULL
356
+ WHERE me.model IS ?
357
+ AND wm.superseded_by IS NULL
355
358
  AND (wm.valid_until IS NULL OR wm.valid_until > ?)
356
359
  AND ${working.clause}
357
360
  UNION ALL
358
361
  SELECT me.memory_id, me.embedding_json, 'episodic' AS embedding_tier
359
362
  FROM memory_embeddings me
360
363
  JOIN episodic_memory em ON em.id = me.memory_id
361
- WHERE em.superseded_by IS NULL
364
+ WHERE me.model IS ?
365
+ AND em.superseded_by IS NULL
362
366
  AND (em.valid_until IS NULL OR em.valid_until > ?)
363
367
  AND ${episodic.clause}
364
368
  LIMIT 50000
365
369
  `)
366
- .all(now, ...working.params, now, ...episodic.params) as EmbeddingRow[];
370
+ .all(model, now, ...working.params, model, now, ...episodic.params) as EmbeddingRow[];
367
371
  } catch {
368
372
  return [];
369
373
  }