@oh-my-pi/pi-mnemopi 18.2.0 → 18.2.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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.2.1] - 2026-09-15
6
+
7
+ ### Fixed
8
+
9
+ - Fixed an explicitly invalidated memory still being returned by an identical repeat query until the recall cache expired.
10
+ - Fixed recall continuing to serve a stale, pre-embedding ranking for up to an hour after background embeddings finished, when the enhanced recall cache is enabled.
11
+
5
12
  ## [18.0.11] - 2026-08-29
6
13
 
7
14
  ### 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.2.0",
4
+ "version": "18.2.2",
5
5
  "description": "Local SQLite memory engine for Oh My Pi agents",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -39,10 +39,10 @@
39
39
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "18.2.0",
43
- "@oh-my-pi/pi-catalog": "18.2.0",
44
- "@oh-my-pi/pi-natives": "18.2.0",
45
- "@oh-my-pi/pi-utils": "18.2.0"
42
+ "@oh-my-pi/pi-ai": "18.2.2",
43
+ "@oh-my-pi/pi-catalog": "18.2.2",
44
+ "@oh-my-pi/pi-natives": "18.2.2",
45
+ "@oh-my-pi/pi-utils": "18.2.2"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "fastembed": "2.1.0",
@@ -576,8 +576,26 @@ export function extractAndStoreFacts(
576
576
  const rawUnit = match[2] ?? "";
577
577
  let unit = rawUnit.toLowerCase();
578
578
  if (unit.endsWith("s") && !unit.endsWith("ms")) unit = unit.slice(0, -1);
579
- const prefixWords = text
580
- .slice(Math.max(0, (match.index ?? 0) - 50), match.index ?? 0)
579
+ // The naive fixed 50-char lookbehind can cross a markdown table cell, a code fence, or
580
+ // a sentence boundary and glue unrelated words into one underscore-joined key (e.g.
581
+ // "iam-role_smrx/sm-secrets_ssm-parameters_api" -> "2api" from a table row, or
582
+ // "cadence_90/60/5_30-_second" from a run-on list). Clamping the window to start after
583
+ // the nearest preceding structural boundary -- newline, table pipe, sentence-ending
584
+ // punctuation, or a backtick -- keeps the phrase within one cell/sentence/line, matching
585
+ // what a human would read as "the words right before this number" rather than 50 raw
586
+ // characters regardless of what they cross.
587
+ //
588
+ // A bare `.` is NOT always a sentence boundary: "v1.2", "3.14", "18.0.8" all contain
589
+ // dots that are part of a version/decimal number, not sentence punctuation. Only count
590
+ // `.`/`!`/`?` as a boundary when followed by whitespace or the end of the window -- i.e.
591
+ // it actually ends a sentence.
592
+ const windowStart = Math.max(0, (match.index ?? 0) - 50);
593
+ let window = text.slice(windowStart, match.index ?? 0);
594
+ const boundaryMatch = /\n|\||`|[.!?](?=\s|$)|;/g;
595
+ let lastBoundary = -1;
596
+ for (let m = boundaryMatch.exec(window); m; m = boundaryMatch.exec(window)) lastBoundary = m.index;
597
+ if (lastBoundary !== -1) window = window.slice(lastBoundary + 1);
598
+ const prefixWords = window
581
599
  .replace(/`[^`]*`/g, " ")
582
600
  .split(/\s+/)
583
601
  .map(w => w.replace(/[.,:;!?()[\]"'`*_]/g, ""))
@@ -794,15 +794,31 @@ async function runEmbedding(beam: BeamMemoryState, items: readonly EmbedItem[]):
794
794
  using insertEmbedding = beam.db.prepare(
795
795
  "INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model) VALUES (?, ?, ?)",
796
796
  );
797
+ let committed = 0;
797
798
  const insertMany = beam.db.transaction((rows: readonly EmbedItem[]) => {
798
799
  for (let i = 0; i < rows.length; i += 1) {
799
800
  const vector = matrix[i];
800
801
  const item = rows[i];
801
802
  if (vector === undefined || item === undefined) continue;
802
803
  insertEmbedding.run(item.memoryId, JSON.stringify(Array.from(vector)), model);
804
+ committed += 1;
803
805
  }
804
806
  });
805
807
  insertMany(items);
808
+ // A recall taken while these vectors were still generating cached an FTS-only ranking, and
809
+ // committing vectors changes what recall returns -- so that cache must be dropped, or the
810
+ // pre-embedding order keeps being served for the whole cache TTL. Gated on `committed`: a
811
+ // batch whose provider returned a short or empty matrix inserts nothing, changes no ranking,
812
+ // and invalidating there would only discard a still-valid cache. Only the query cache is
813
+ // affected; the polyphonic subject dictionary is built from facts/gists, which an embedding
814
+ // batch never touches.
815
+ if (committed > 0) {
816
+ const caches = beam.caches as
817
+ | { queryCache?: { invalidate?: () => void }; _queryCache?: { invalidate?: () => void } }
818
+ | undefined;
819
+ caches?.queryCache?.invalidate?.();
820
+ caches?._queryCache?.invalidate?.();
821
+ }
806
822
  } catch (error) {
807
823
  // Background embedding generation is best-effort: a failing provider, a closed DB
808
824
  // during shutdown, or a transient API error must never disrupt the synchronous
@@ -651,7 +651,16 @@ export function invalidate(beam: BeamMemoryState, memoryId: string, replacementI
651
651
  `,
652
652
  [now, replacementId, memoryId, beam.sessionId],
653
653
  );
654
- if (working.changes > 0) return true;
654
+ if (working.changes > 0) {
655
+ // Recall filters `valid_until`/`superseded_by` in SQL, but the enhanced path consults the
656
+ // query cache BEFORE it reaches SQL. Without this the row a caller just retired keeps being
657
+ // served to an identical query for the rest of the cache TTL -- the one thing an explicit
658
+ // invalidation is supposed to guarantee against. Every other mutator here already does this;
659
+ // this one was the omission. Gated on an actual row change, like `forgetWorking`, so a
660
+ // no-op invalidation never discards a valid cache.
661
+ invalidateCaches(beam);
662
+ return true;
663
+ }
655
664
  const episodic = beam.db.run(
656
665
  `
657
666
  UPDATE episodic_memory
@@ -660,7 +669,11 @@ export function invalidate(beam: BeamMemoryState, memoryId: string, replacementI
660
669
  `,
661
670
  [now, replacementId, memoryId, beam.sessionId],
662
671
  );
663
- return episodic.changes > 0;
672
+ if (episodic.changes > 0) {
673
+ invalidateCaches(beam);
674
+ return true;
675
+ }
676
+ return false;
664
677
  }
665
678
 
666
679
  export function getWorkingStats(
@@ -189,18 +189,20 @@ export async function callConfiguredCompletion(
189
189
  return null;
190
190
  }
191
191
  try {
192
- const message = await retryTransientCompletion(() =>
193
- completeSimple(
194
- model,
195
- {
196
- messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
197
- },
198
- {
199
- apiKey: llmApiKey() || undefined,
200
- maxTokens: opts.maxTokens ?? llmMaxTokens(),
201
- temperature,
202
- },
203
- ),
192
+ const message = await retryTransientCompletion(
193
+ () =>
194
+ completeSimple(
195
+ model,
196
+ {
197
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
198
+ },
199
+ {
200
+ apiKey: llmApiKey() || undefined,
201
+ maxTokens: opts.maxTokens ?? llmMaxTokens(),
202
+ temperature,
203
+ },
204
+ ),
205
+ { provider: model.provider },
204
206
  );
205
207
  return assistantText(message).trim() || null;
206
208
  } catch {