@oh-my-pi/pi-mnemopi 18.8.9 → 18.9.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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.9.0] - 2026-10-11
6
+
7
+ ### Added
8
+
9
+ - Exported `clipRecallContent` for applications that need to format content consistently with Mnemopi recall previews.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed memory-save performance degradation and excessive memory-database growth when date-related terms such as “today” appeared in otherwise unrelated memories.
14
+
5
15
  ## [18.8.8] - 2026-10-10
6
16
 
7
17
  ### Fixed
@@ -1,6 +1,7 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import type { VeracityConsolidator } from "../veracity-consolidation.js";
3
3
  import type { BeamCaches, BeamConfig, BeamEvent, BeamMemoryOptions, BeamMemoryState, BeamStats, ImportStats, MemoriaRetrieveResult, Metadata, RecallEnhancedOptions, RecallOptions, RecallResult, RememberBatchItem, RememberBatchOptions, RememberOptions, SleepResult } from "./types.js";
4
+ export { clipRecallContent } from "./recall.js";
4
5
  export { initBeam } from "./schema.js";
5
6
  export type * from "./types.js";
6
7
  export declare class BeamMemory implements BeamMemoryState {
@@ -91,7 +91,6 @@ export declare class EpisodicGraph {
91
91
  private knownMemoryIds;
92
92
  private memoryContent;
93
93
  private entityOverlapScore;
94
- private temporalContextScore;
95
94
  private memoryFeatures;
96
95
  private scoreFeatures;
97
96
  }
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.8.9",
4
+ "version": "18.9.0",
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.8.9",
46
- "@oh-my-pi/pi-catalog": "18.8.9",
47
- "@oh-my-pi/pi-natives": "18.8.9",
48
- "@oh-my-pi/pi-utils": "18.8.9"
45
+ "@oh-my-pi/pi-ai": "18.9.0",
46
+ "@oh-my-pi/pi-catalog": "18.9.0",
47
+ "@oh-my-pi/pi-natives": "18.9.0",
48
+ "@oh-my-pi/pi-utils": "18.9.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "fastembed": "3.0.0",
@@ -1,5 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import { logger } from "@oh-my-pi/pi-utils";
2
+ import { logger, withLoopPhase } from "@oh-my-pi/pi-utils";
3
3
  import { generateId as generateTimedId, sha256Hex16, stableMemoryId } from "../../util/ids";
4
4
  import { cjkFtsTerms, containsSpacelessCjk, ftsQueryTerms, hasCjk, isCjkChar, recallTokens } from "../../util/regex";
5
5
  import { currentEmbeddingModel, embed } from "../embeddings";
@@ -727,29 +727,33 @@ async function runEmbedding(beam: BeamMemoryState, items: readonly EmbedItem[]):
727
727
  try {
728
728
  const matrix = await embed(items.map(item => item.content));
729
729
  if (matrix === null) return;
730
- const model = currentEmbeddingModel();
731
- using insertEmbedding = beam.db.prepare(
732
- "INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model) VALUES (?, ?, ?)",
733
- );
734
- let committed = 0;
735
- const insertMany = beam.db.transaction((rows: readonly EmbedItem[]) => {
736
- for (let i = 0; i < rows.length; i += 1) {
737
- const vector = matrix[i];
738
- const item = rows[i];
739
- if (vector === undefined || item === undefined) continue;
740
- insertEmbedding.run(item.memoryId, JSON.stringify(Array.from(vector)), model);
741
- committed += 1;
742
- }
730
+ // Persisting the vectors runs synchronously on the caller's event loop; label it so a
731
+ // stall is attributable. The embedding itself is awaited above, outside the label.
732
+ withLoopPhase("mnemopi.embed-persist", () => {
733
+ const model = currentEmbeddingModel();
734
+ using insertEmbedding = beam.db.prepare(
735
+ "INSERT OR REPLACE INTO memory_embeddings(memory_id, embedding_json, model) VALUES (?, ?, ?)",
736
+ );
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);
748
+ // A recall taken while these vectors were still generating cached an FTS-only ranking, and
749
+ // committing vectors changes what recall returns -- so that cache must be dropped, or the
750
+ // 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.
755
+ if (committed > 0) beam.caches.queryCache?.invalidate();
743
756
  });
744
- insertMany(items);
745
- // A recall taken while these vectors were still generating cached an FTS-only ranking, and
746
- // committing vectors changes what recall returns -- so that cache must be dropped, or the
747
- // pre-embedding order keeps being served for the whole cache TTL. Gated on `committed`: a
748
- // batch whose provider returned a short or empty matrix inserts nothing, changes no ranking,
749
- // and invalidating there would only discard a still-valid cache. Only the query cache is
750
- // affected; the polyphonic subject dictionary is built from facts/gists, which an embedding
751
- // batch never touches.
752
- if (committed > 0) beam.caches.queryCache?.invalidate();
753
757
  } catch (error) {
754
758
  // Background embedding generation is best-effort: a failing provider, a closed DB
755
759
  // during shutdown, or a transient API error must never disrupt the synchronous
@@ -57,6 +57,7 @@ import type {
57
57
  SleepResult,
58
58
  } from "./types";
59
59
 
60
+ export { clipRecallContent } from "./recall";
60
61
  export { initBeam } from "./schema";
61
62
  export type * from "./types";
62
63
 
@@ -1,3 +1,4 @@
1
+ import { popLoopPhase, pushLoopPhase, withLoopPhase } from "@oh-my-pi/pi-utils";
1
2
  import { normalizedRecallWeights, polyphonicRecallEnabled, temporalHalflifeHours } from "../../config";
2
3
  import { hasCjk, matchesWordForm } from "../../util/regex";
3
4
  import { embedQuery } from "../embeddings";
@@ -923,35 +924,37 @@ export async function recall(
923
924
  const derived = query.length > 0 ? await embedQuery(query) : null;
924
925
  temporalOptions.queryEmbedding = derived === null ? null : Array.from(derived);
925
926
  }
926
- let weights = normalizedRecallWeights(
927
- options.vecWeight ?? beam.config.vecWeight,
928
- options.ftsWeight ?? beam.config.ftsWeight,
929
- options.importanceWeight ?? beam.config.importanceWeight,
930
- );
931
- if (options.useIntent === true) {
932
- const intent = classifyIntent(query);
933
- weights = adjustWeights(weights[0], weights[1], weights[2], intent);
934
- }
935
- const useSynonyms = options.useSynonyms !== false;
936
- const tokens = expandedTokens(query, useSynonyms);
937
- const tokenGroups = expandedTokenGroups(query, useSynonyms);
938
- const candidates = collectMemoryCandidates(beam, query, topK, temporalOptions);
939
- const scored: RecallResult[] = [];
940
- for (const candidate of candidates) {
941
- const result = scoreCandidate(candidate, tokens, tokenGroups, weights, temporalOptions);
942
- if (result !== null) scored.push(result);
943
- }
944
- scored.sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
945
- let finalResults = dedupCrossTierSummaryLinks(beam, dedupeResults(scored));
946
- if (query.length > 0 && tokens.length >= 4 && finalResults.length > topK)
947
- finalResults = diversifyByCoverage(finalResults, tokens, topK);
948
- if (options.useMmr === true && finalResults.length > 1) {
949
- finalResults = rerankRecallResults(finalResults, options.mmrLambda ?? 0.7, topK);
950
- } else {
951
- finalResults = finalResults.slice(0, topK);
952
- }
953
- if (temporalOptions.updateRecallCounts !== false) updateRecallCounts(beam, finalResults, temporalOptions);
954
- return finalResults;
927
+ return withLoopPhase("mnemopi.recall", () => {
928
+ let weights = normalizedRecallWeights(
929
+ options.vecWeight ?? beam.config.vecWeight,
930
+ options.ftsWeight ?? beam.config.ftsWeight,
931
+ options.importanceWeight ?? beam.config.importanceWeight,
932
+ );
933
+ if (options.useIntent === true) {
934
+ const intent = classifyIntent(query);
935
+ weights = adjustWeights(weights[0], weights[1], weights[2], intent);
936
+ }
937
+ const useSynonyms = options.useSynonyms !== false;
938
+ const tokens = expandedTokens(query, useSynonyms);
939
+ const tokenGroups = expandedTokenGroups(query, useSynonyms);
940
+ const candidates = collectMemoryCandidates(beam, query, topK, temporalOptions);
941
+ const scored: RecallResult[] = [];
942
+ for (const candidate of candidates) {
943
+ const result = scoreCandidate(candidate, tokens, tokenGroups, weights, temporalOptions);
944
+ if (result !== null) scored.push(result);
945
+ }
946
+ scored.sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
947
+ let finalResults = dedupCrossTierSummaryLinks(beam, dedupeResults(scored));
948
+ if (query.length > 0 && tokens.length >= 4 && finalResults.length > topK)
949
+ finalResults = diversifyByCoverage(finalResults, tokens, topK);
950
+ if (options.useMmr === true && finalResults.length > 1) {
951
+ finalResults = rerankRecallResults(finalResults, options.mmrLambda ?? 0.7, topK);
952
+ } else {
953
+ finalResults = finalResults.slice(0, topK);
954
+ }
955
+ if (temporalOptions.updateRecallCounts !== false) updateRecallCounts(beam, finalResults, temporalOptions);
956
+ return finalResults;
957
+ });
955
958
  }
956
959
 
957
960
  function diversifyByCoverage(
@@ -1034,37 +1037,44 @@ export async function recallEnhanced(
1034
1037
  let cache: QueryCache<RecallResult> | null = null;
1035
1038
  let token = "";
1036
1039
  let scope = "";
1037
- if (cacheEnabled) {
1038
- beam.caches.queryCache ??= new QueryCache<RecallResult>({
1039
- maxSize: ENHANCED_RECALL_CACHE_MAX_ENTRIES,
1040
- ttlSeconds: ENHANCED_RECALL_CACHE_TTL_SECONDS,
1041
- });
1042
- cache = beam.caches.queryCache;
1043
- // Explicit hooks invalidate on this beam's own writes; the token also catches writes
1044
- // no hook covers (sleep, graph ingest) and commits from other connections.
1045
- token = databaseWriteToken(beam);
1046
- if (beam.caches.queryCacheToken !== token) {
1047
- cache.invalidate();
1048
- beam.caches.queryCacheToken = token;
1049
- }
1050
- scope = enhancedRecallCacheScope(beam, topK, options, polyphonic);
1051
- const cached = cache.get(query, queryEmbedding, scope);
1052
- if (cached !== null) {
1053
- const results = structuredClone(cached) as RecallResult[];
1054
- if (countRecalls) countRecallsKeepingCache(beam, results, runOptions, token);
1055
- return results;
1040
+ pushLoopPhase("mnemopi.recall");
1041
+ try {
1042
+ if (cacheEnabled) {
1043
+ beam.caches.queryCache ??= new QueryCache<RecallResult>({
1044
+ maxSize: ENHANCED_RECALL_CACHE_MAX_ENTRIES,
1045
+ ttlSeconds: ENHANCED_RECALL_CACHE_TTL_SECONDS,
1046
+ });
1047
+ cache = beam.caches.queryCache;
1048
+ // Explicit hooks invalidate on this beam's own writes; the token also catches writes
1049
+ // no hook covers (sleep, graph ingest) and commits from other connections.
1050
+ token = databaseWriteToken(beam);
1051
+ if (beam.caches.queryCacheToken !== token) {
1052
+ cache.invalidate();
1053
+ beam.caches.queryCacheToken = token;
1054
+ }
1055
+ scope = enhancedRecallCacheScope(beam, topK, options, polyphonic);
1056
+ const cached = cache.get(query, queryEmbedding, scope);
1057
+ if (cached !== null) {
1058
+ const results = structuredClone(cached) as RecallResult[];
1059
+ if (countRecalls) countRecallsKeepingCache(beam, results, runOptions, token);
1060
+ return results;
1061
+ }
1056
1062
  }
1063
+ } finally {
1064
+ popLoopPhase();
1057
1065
  }
1058
1066
 
1059
1067
  const results = polyphonic
1060
1068
  ? await polyphonicRecallEnhanced(beam, query, topK, runOptions)
1061
1069
  : await linearRecallEnhanced(beam, query, topK, runOptions);
1062
- // A write committed while recall awaited may be missing from this ranking: skip the
1063
- // put and keep the stale token so the next lookup starts from an empty cache.
1064
- const cacheable = cache !== null && databaseWriteToken(beam) === token;
1065
- if (cacheable) cache?.put(query, structuredClone(results), queryEmbedding, scope);
1066
- if (countRecalls) countRecallsKeepingCache(beam, results, runOptions, cacheable ? token : null);
1067
- return results;
1070
+ return withLoopPhase("mnemopi.recall", () => {
1071
+ // A write committed while recall awaited may be missing from this ranking: skip the
1072
+ // put and keep the stale token so the next lookup starts from an empty cache.
1073
+ const cacheable = cache !== null && databaseWriteToken(beam) === token;
1074
+ if (cacheable) cache?.put(query, structuredClone(results), queryEmbedding, scope);
1075
+ if (countRecalls) countRecallsKeepingCache(beam, results, runOptions, cacheable ? token : null);
1076
+ return results;
1077
+ });
1068
1078
  }
1069
1079
 
1070
1080
  async function linearRecallEnhanced(
@@ -1084,14 +1094,16 @@ async function linearRecallEnhanced(
1084
1094
  ...enhancedOptions,
1085
1095
  updateRecallCounts: false,
1086
1096
  });
1087
- if (options.includeFacts === true) {
1088
- const facts = factRecall(beam, query, factRecallLimit(topK));
1089
- results.push(...facts);
1090
- }
1091
- results.sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
1092
- const finalResults = rerankRecallResults(results, options.mmrLambda ?? 0.7, topK);
1093
- if (enhancedOptions.updateRecallCounts !== false) updateRecallCounts(beam, finalResults, enhancedOptions);
1094
- return finalResults;
1097
+ return withLoopPhase("mnemopi.recall", () => {
1098
+ if (options.includeFacts === true) {
1099
+ const facts = factRecall(beam, query, factRecallLimit(topK));
1100
+ results.push(...facts);
1101
+ }
1102
+ results.sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
1103
+ const finalResults = rerankRecallResults(results, options.mmrLambda ?? 0.7, topK);
1104
+ if (enhancedOptions.updateRecallCounts !== false) updateRecallCounts(beam, finalResults, enhancedOptions);
1105
+ return finalResults;
1106
+ });
1095
1107
  }
1096
1108
 
1097
1109
  /**
@@ -1,5 +1,5 @@
1
1
  import type { Database, SQLQueryBindings } from "bun:sqlite";
2
- import { logger } from "@oh-my-pi/pi-utils";
2
+ 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";
@@ -307,9 +307,11 @@ function proactiveLinkIfEnabled(
307
307
  async function runFactExtraction(beam: BeamMemoryState, memoryId: string, content: string): Promise<void> {
308
308
  try {
309
309
  const extracted = await extractFactCategoriesSafe(content);
310
- if (countExtractedFactCategories(extracted) === 0) return;
311
- storeExtractedFactCategories(beam, extracted, 0, memoryId);
312
- invalidateCaches(beam);
310
+ withLoopPhase("mnemopi.extract", () => {
311
+ if (countExtractedFactCategories(extracted) === 0) return;
312
+ storeExtractedFactCategories(beam, extracted, 0, memoryId);
313
+ invalidateCaches(beam);
314
+ });
313
315
  } catch {
314
316
  // Background fact extraction is best-effort and never surfaces to the caller.
315
317
  }
@@ -740,7 +742,7 @@ export function updateWorking(
740
742
  export function get(beam: BeamMemoryState, memoryId: string): Row | null {
741
743
  using workingStatement = beam.db.prepare(`
742
744
  SELECT id, content, source, timestamp, session_id,
743
- importance, metadata_json, veracity, created_at
745
+ importance, metadata_json, veracity, created_at, valid_until, superseded_by
744
746
  FROM working_memory
745
747
  WHERE id = ?
746
748
  `);
@@ -749,7 +751,7 @@ export function get(beam: BeamMemoryState, memoryId: string): Row | null {
749
751
 
750
752
  using episodicStatement = beam.db.prepare(`
751
753
  SELECT id, content, source, timestamp, session_id,
752
- importance, metadata_json, veracity, created_at
754
+ importance, metadata_json, veracity, created_at, valid_until, superseded_by
753
755
  FROM episodic_memory
754
756
  WHERE id = ? AND (session_id = ? OR scope = 'global')
755
757
  `);
@@ -11,6 +11,7 @@ import {
11
11
  fetchWithRetry,
12
12
  getFastembedCacheDir,
13
13
  logger,
14
+ withLoopPhase,
14
15
  } from "@oh-my-pi/pi-utils";
15
16
  import { LRUCache } from "@oh-my-pi/pi-utils/lru";
16
17
  import type { EmbeddingModel } from "fastembed";
@@ -294,9 +295,11 @@ export function embeddingDimFor(modelName: string): number {
294
295
  async function collectMatrix(batches: EmbeddingOutput): Promise<EmbeddingMatrix> {
295
296
  const rows: Vector[] = [];
296
297
  for await (const batch of batches) {
297
- for (const row of batch) {
298
- rows.push(new Float32Array(row));
299
- }
298
+ withLoopPhase("mnemopi.embed", () => {
299
+ for (const row of batch) {
300
+ rows.push(new Float32Array(row));
301
+ }
302
+ });
300
303
  }
301
304
  return rows;
302
305
  }
@@ -388,12 +391,15 @@ async function embedApi(texts: readonly string[]): Promise<EmbeddingMatrix | nul
388
391
  if (!response.ok) {
389
392
  return null;
390
393
  }
391
- const { data: rows } = (await response.json()) as { data?: Array<{ embedding: number[] }> };
392
- if (rows === undefined) {
393
- return null;
394
- }
395
- apiCallCount += 1;
396
- return rows.map(row => new Float32Array(row.embedding));
394
+ const data = (await response.json()) as { data?: Array<{ embedding: number[] }> };
395
+ return withLoopPhase("mnemopi.embed", () => {
396
+ const { data: rows } = data;
397
+ if (rows === undefined) {
398
+ return null;
399
+ }
400
+ apiCallCount += 1;
401
+ return rows.map(row => new Float32Array(row.embedding));
402
+ });
397
403
  } catch (error) {
398
404
  logger.debug("mnemopi embedding request failed", { status: extractHttpStatusFromError(error) });
399
405
  return null;
@@ -525,7 +531,7 @@ export async function embed(texts: readonly string[]): Promise<EmbeddingMatrix |
525
531
  return null;
526
532
  }
527
533
  try {
528
- const vectors = await collectMatrix(model.embed([...texts]));
534
+ const vectors = await withLoopPhase("mnemopi.embed", () => collectMatrix(model.embed([...texts])));
529
535
  if (vectors.length === 1) {
530
536
  const vector = vectors[0];
531
537
  if (vector !== undefined) {
@@ -571,17 +571,12 @@ export class EpisodicGraph {
571
571
  this.addEdge(edge);
572
572
  edges.push(edge);
573
573
  }
574
- const contextualScore = Math.max(
575
- lexicalScore,
576
- entityScore,
577
- this.temporalContextScore(memoryId, otherId),
578
- );
579
- if (!wroteCtxEdge && contextualScore >= minLinkScore) {
574
+ if (!wroteCtxEdge && entityScore >= minLinkScore) {
580
575
  const ctxEdge = {
581
576
  source: memoryId,
582
577
  target: otherId,
583
578
  edgeType: "ctx",
584
- weight: contextualScore,
579
+ weight: entityScore,
585
580
  timestamp,
586
581
  };
587
582
  this.addEdge(ctxEdge);
@@ -736,18 +731,6 @@ export class EpisodicGraph {
736
731
  return Math.round(overlapScore(left, right) * 1000) / 1000;
737
732
  }
738
733
 
739
- private temporalContextScore(sourceMemoryId: string, targetMemoryId: string): number {
740
- const left = this.db.query("SELECT time_scope FROM gists WHERE memory_id = ?").get(sourceMemoryId) as {
741
- time_scope: string | null;
742
- } | null;
743
- if (left?.time_scope === null || left?.time_scope === undefined) return 0;
744
- const right = this.db.query("SELECT time_scope FROM gists WHERE memory_id = ?").get(targetMemoryId) as {
745
- time_scope: string | null;
746
- } | null;
747
- if (right?.time_scope === null || right?.time_scope === undefined) return 0;
748
- return left.time_scope === right.time_scope ? DEFAULT_LINK_THRESHOLD : 0;
749
- }
750
-
751
734
  private memoryFeatures(memoryId: string): Set<string> {
752
735
  const gistRows = this.db.query("SELECT * FROM gists WHERE memory_id = ?").all(memoryId) as GistRow[];
753
736
  const factRows = this.db.query("SELECT * FROM facts WHERE source_msg_id = ?").all(memoryId) as FactRow[];
@@ -1,3 +1,4 @@
1
+ import { withLoopPhase } from "@oh-my-pi/pi-utils";
1
2
  import { getDiagnostics, safeForLog } from "./extraction/diagnostics";
2
3
  import { callHostLlm, getHostLlmBackend } from "./llm-backends";
3
4
  import {
@@ -219,51 +220,58 @@ export function countExtractedFactCategories(extracted: ExtractedFactCategories)
219
220
 
220
221
  /** Parse extractor output without discarding MEMORIA categories or KG triples. */
221
222
  export function parseExtractedFactCategories(rawOutput: string | null | undefined): ExtractedFactCategories {
222
- if (rawOutput === null || rawOutput === undefined) {
223
- return emptyFactCategories();
224
- }
225
- const raw = rawOutput.trim();
226
- if (raw === "" || raw.toUpperCase() === "NO_FACTS") {
227
- return emptyFactCategories();
228
- }
229
- const rawClean = stripFence(raw);
230
- if (rawClean.startsWith("{")) {
231
- try {
232
- const parsed: unknown = JSON.parse(rawClean);
233
- if (isRecord(parsed)) {
234
- return {
235
- facts: normalizeFactArray(parsed.facts, { fields: FACT_TEXT_FIELD_KEYS }),
236
- instructions: normalizeFactArray(parsed.instructions, { fields: INSTRUCTION_TEXT_FIELD_KEYS }),
237
- preferences: normalizeFactArray(parsed.preferences, { fields: PREFERENCE_TEXT_FIELD_KEYS }),
238
- timelines: normalizeFactArray(parsed.timelines, { fields: TIMELINE_TEXT_FIELD_KEYS, joinFields: true }),
239
- kg: normalizeKgArray(parsed.kg),
240
- };
241
- }
242
- } catch {
243
- const matches = [...raw.matchAll(/"([^"]{10,})"/g)].map(m => m[1]).filter((v): v is string => v !== undefined);
244
- if (matches.length > 0) {
245
- return {
246
- ...emptyFactCategories(),
247
- facts: matches
248
- .map(normalizeFact)
249
- .filter(f => f !== "")
250
- .slice(0, FLAT_FACT_LIMIT),
251
- };
223
+ return withLoopPhase("mnemopi.extract", () => {
224
+ if (rawOutput === null || rawOutput === undefined) {
225
+ return emptyFactCategories();
226
+ }
227
+ const raw = rawOutput.trim();
228
+ if (raw === "" || raw.toUpperCase() === "NO_FACTS") {
229
+ return emptyFactCategories();
230
+ }
231
+ const rawClean = stripFence(raw);
232
+ if (rawClean.startsWith("{")) {
233
+ try {
234
+ const parsed: unknown = JSON.parse(rawClean);
235
+ if (isRecord(parsed)) {
236
+ return {
237
+ facts: normalizeFactArray(parsed.facts, { fields: FACT_TEXT_FIELD_KEYS }),
238
+ instructions: normalizeFactArray(parsed.instructions, { fields: INSTRUCTION_TEXT_FIELD_KEYS }),
239
+ preferences: normalizeFactArray(parsed.preferences, { fields: PREFERENCE_TEXT_FIELD_KEYS }),
240
+ timelines: normalizeFactArray(parsed.timelines, {
241
+ fields: TIMELINE_TEXT_FIELD_KEYS,
242
+ joinFields: true,
243
+ }),
244
+ kg: normalizeKgArray(parsed.kg),
245
+ };
246
+ }
247
+ } catch {
248
+ const matches = [...raw.matchAll(/"([^"]{10,})"/g)]
249
+ .map(m => m[1])
250
+ .filter((v): v is string => v !== undefined);
251
+ if (matches.length > 0) {
252
+ return {
253
+ ...emptyFactCategories(),
254
+ facts: matches
255
+ .map(normalizeFact)
256
+ .filter(f => f !== "")
257
+ .slice(0, FLAT_FACT_LIMIT),
258
+ };
259
+ }
252
260
  }
253
261
  }
254
- }
255
- const cleaned: string[] = [];
256
- for (const line of raw.split("\n")) {
257
- const fact = line.replace(/^[\s\d.\-*]+/, "").trim();
258
- if (fact.length > 10) {
259
- const normalized = normalizeFact(fact);
260
- if (normalized !== "") {
261
- cleaned.push(normalized);
262
+ const cleaned: string[] = [];
263
+ for (const line of raw.split("\n")) {
264
+ const fact = line.replace(/^[\s\d.\-*]+/, "").trim();
265
+ if (fact.length > 10) {
266
+ const normalized = normalizeFact(fact);
267
+ if (normalized !== "") {
268
+ cleaned.push(normalized);
269
+ }
262
270
  }
271
+ if (cleaned.length >= FLAT_FACT_LIMIT) break;
263
272
  }
264
- if (cleaned.length >= FLAT_FACT_LIMIT) break;
265
- }
266
- return { ...emptyFactCategories(), facts: cleaned };
273
+ return { ...emptyFactCategories(), facts: cleaned };
274
+ });
267
275
  }
268
276
 
269
277
  /** Parse extractor output into the legacy flat string fact list. */
@@ -283,42 +291,44 @@ function addUnique(out: string[], value: string): void {
283
291
  }
284
292
 
285
293
  export function heuristicExtractFacts(text: string): string[] {
286
- const normalized = text.replace(/\s+/g, " ").trim();
287
- if (normalized === "") {
288
- return [];
289
- }
290
- const facts: string[] = [];
291
- const clauses = normalized.split(/(?:[.!?;]+|\s+and\s+|\s+but\s+)/i);
292
- for (const clause of clauses) {
293
- const c = clause.trim();
294
- let value = /\bmy name is\s+([^,.!?;]+)/i.exec(c)?.[1];
295
- if (value !== undefined) addUnique(facts, `The user's name is ${value}`);
296
- value = /\bi (?:am|work as)\s+(?:an?\s+)?([^,.!?;]+)/i.exec(c)?.[1];
297
- if (value !== undefined) addUnique(facts, `The user is ${value}`);
298
- value = /\bi work (?:at|for)\s+([^,.!?;]+)/i.exec(c)?.[1];
299
- if (value !== undefined) addUnique(facts, `The user works at ${value}`);
300
- value = /\bi (?:live in|am based in)\s+([^,.!?;]+)/i.exec(c)?.[1];
301
- if (value !== undefined) addUnique(facts, `The user lives in ${value}`);
302
- value = /\bi (?:use|uses|am using)\s+([^,.!?;]+)/i.exec(c)?.[1];
303
- if (value !== undefined) addUnique(facts, `The user uses ${value}`);
304
- value = /\bi (?:like|love|prefer|enjoy)\s+([^,.!?;]+)/i.exec(c)?.[1];
305
- if (value !== undefined) addUnique(facts, `The user prefers ${value}`);
306
- value = /\bi (?:hate|dislike|do not like|don't like)\s+([^,.!?;]+)/i.exec(c)?.[1];
307
- if (value !== undefined) addUnique(facts, `The user dislikes ${value}`);
308
- // Require an explicit `i` or `you` subject before `always|never`. The
309
- // other heuristics in this block all need an `i` subject (`i live in …`,
310
- // `i use …`) which keeps them from matching narrative prose; the
311
- // `Instruction:` pattern used to match any `always|never` token, so
312
- // assistant prose like "the panel never populates" became stored as a
313
- // user `Instruction:` memory (coding-agent issue #3372). Subject
314
- // constraint mirrors how the rest of the heuristics filter for first- /
315
- // second-person assertions and keeps narrative third-person prose out.
316
- const instruction = /\b(?:i|you)\s+(always|never)\s+([^,.!?;]+)/i.exec(c);
317
- if (instruction?.[1] !== undefined && instruction[2] !== undefined) {
318
- addUnique(facts, `Instruction: ${instruction[1].toLowerCase()} ${instruction[2]}`);
294
+ return withLoopPhase("mnemopi.extract", () => {
295
+ const normalized = text.replace(/\s+/g, " ").trim();
296
+ if (normalized === "") {
297
+ return [];
319
298
  }
320
- }
321
- return facts.slice(0, 5);
299
+ const facts: string[] = [];
300
+ const clauses = normalized.split(/(?:[.!?;]+|\s+and\s+|\s+but\s+)/i);
301
+ for (const clause of clauses) {
302
+ const c = clause.trim();
303
+ let value = /\bmy name is\s+([^,.!?;]+)/i.exec(c)?.[1];
304
+ if (value !== undefined) addUnique(facts, `The user's name is ${value}`);
305
+ value = /\bi (?:am|work as)\s+(?:an?\s+)?([^,.!?;]+)/i.exec(c)?.[1];
306
+ if (value !== undefined) addUnique(facts, `The user is ${value}`);
307
+ value = /\bi work (?:at|for)\s+([^,.!?;]+)/i.exec(c)?.[1];
308
+ if (value !== undefined) addUnique(facts, `The user works at ${value}`);
309
+ value = /\bi (?:live in|am based in)\s+([^,.!?;]+)/i.exec(c)?.[1];
310
+ if (value !== undefined) addUnique(facts, `The user lives in ${value}`);
311
+ value = /\bi (?:use|uses|am using)\s+([^,.!?;]+)/i.exec(c)?.[1];
312
+ if (value !== undefined) addUnique(facts, `The user uses ${value}`);
313
+ value = /\bi (?:like|love|prefer|enjoy)\s+([^,.!?;]+)/i.exec(c)?.[1];
314
+ if (value !== undefined) addUnique(facts, `The user prefers ${value}`);
315
+ value = /\bi (?:hate|dislike|do not like|don't like)\s+([^,.!?;]+)/i.exec(c)?.[1];
316
+ if (value !== undefined) addUnique(facts, `The user dislikes ${value}`);
317
+ // Require an explicit `i` or `you` subject before `always|never`. The
318
+ // other heuristics in this block all need an `i` subject (`i live in …`,
319
+ // `i use …`) which keeps them from matching narrative prose; the
320
+ // `Instruction:` pattern used to match any `always|never` token, so
321
+ // assistant prose like "the panel never populates" became stored as a
322
+ // user `Instruction:` memory (coding-agent issue #3372). Subject
323
+ // constraint mirrors how the rest of the heuristics filter for first- /
324
+ // second-person assertions and keeps narrative third-person prose out.
325
+ const instruction = /\b(?:i|you)\s+(always|never)\s+([^,.!?;]+)/i.exec(c);
326
+ if (instruction?.[1] !== undefined && instruction[2] !== undefined) {
327
+ addUnique(facts, `Instruction: ${instruction[1].toLowerCase()} ${instruction[2]}`);
328
+ }
329
+ }
330
+ return facts.slice(0, 5);
331
+ });
322
332
  }
323
333
 
324
334
  async function tryHostExtraction(prompt: string): Promise<[boolean, string | null]> {
@@ -345,7 +355,7 @@ async function localFallback(
345
355
  try {
346
356
  const raw = await callLocalLlm(prompt);
347
357
  if (raw !== null) {
348
- const extracted = parseExtractedFactCategories(cleanOutput(raw));
358
+ const extracted = withLoopPhase("mnemopi.extract", () => parseExtractedFactCategories(cleanOutput(raw)));
349
359
  const count = countExtractedFactCategories(extracted);
350
360
  if (count > 0) {
351
361
  diag.recordSuccess("local", count);
@@ -380,7 +390,7 @@ export async function extractFactCategories(
380
390
  return emptyFactCategories();
381
391
  }
382
392
  const input = clipToWindow(text, DEFAULT_INPUT_CHARS);
383
- const prompt = buildExtractionPrompt(input);
393
+ const prompt = withLoopPhase("mnemopi.extract", () => buildExtractionPrompt(input));
384
394
 
385
395
  // Configured completion (host-injected runtime LLM, e.g. the coding-agent's smol
386
396
  // or a local on-device model). Mirrors consolidation's precedence: when a
@@ -453,7 +463,7 @@ export async function extractFactCategories(
453
463
  try {
454
464
  const raw = await callRemoteLlm(prompt, 0, options);
455
465
  if (raw !== null) {
456
- const extracted = parseExtractedFactCategories(cleanOutput(raw));
466
+ const extracted = withLoopPhase("mnemopi.extract", () => parseExtractedFactCategories(cleanOutput(raw)));
457
467
  const count = countExtractedFactCategories(extracted);
458
468
  if (count > 0) {
459
469
  diag.recordSuccess("remote", count);
@@ -1,5 +1,6 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
3
+ import { popLoopPhase, pushLoopPhase, withLoopPhase } from "@oh-my-pi/pi-utils";
3
4
 
4
5
  import { dbPath as configuredDbPath } from "../config";
5
6
  import { closeQuietly } from "../db";
@@ -406,55 +407,62 @@ export class Mnemopi {
406
407
  #closed = false;
407
408
 
408
409
  constructor(options: MnemopiOptions = {}) {
409
- this.sessionId = options.sessionId ?? options.session_id ?? "default";
410
- this.bank = options.bank ?? "default";
411
-
412
- this.authorId = options.authorId ?? options.author_id ?? null;
413
- this.authorType = options.authorType ?? options.author_type ?? null;
414
- this.channelId = options.channelId ?? options.channel_id ?? this.sessionId;
415
- this.dbPath = resolveDbPath(options, this.bank);
416
- this.runtimeOptions = resolveRuntimeOptions(options);
417
-
418
- this.beam = new BeamMemory({
419
- sessionId: this.sessionId,
420
- dbPath: options.db === undefined ? this.dbPath : ":memory:",
421
- authorId: this.authorId,
422
- authorType: this.authorType,
423
- channelId: this.channelId,
424
- proactiveLinking: options.proactiveLinking,
425
- polyphonicRecall: options.polyphonicRecall,
426
- enhancedRecall: options.enhancedRecall,
427
- });
428
- this.#ownsDb = options.db === undefined;
429
- if (options.db !== undefined) {
430
- const opened = this.beam.db;
431
- initBeam(options.db);
432
- Object.defineProperty(this.beam, "db", { value: options.db });
433
- Object.defineProperty(this.beam, "annotations", {
434
- value: buildBeamAnnotations(options.db, this.dbPath),
435
- });
436
- Object.defineProperty(this.beam, "episodicGraph", {
437
- value: buildEpisodicGraph(options.db, this.dbPath),
410
+ pushLoopPhase("mnemopi.open");
411
+ try {
412
+ this.sessionId = options.sessionId ?? options.session_id ?? "default";
413
+ this.bank = options.bank ?? "default";
414
+
415
+ this.authorId = options.authorId ?? options.author_id ?? null;
416
+ this.authorType = options.authorType ?? options.author_type ?? null;
417
+ this.channelId = options.channelId ?? options.channel_id ?? this.sessionId;
418
+ this.dbPath = resolveDbPath(options, this.bank);
419
+ this.runtimeOptions = resolveRuntimeOptions(options);
420
+
421
+ this.beam = new BeamMemory({
422
+ sessionId: this.sessionId,
423
+ dbPath: options.db === undefined ? this.dbPath : ":memory:",
424
+ authorId: this.authorId,
425
+ authorType: this.authorType,
426
+ channelId: this.channelId,
427
+ proactiveLinking: options.proactiveLinking,
428
+ polyphonicRecall: options.polyphonicRecall,
429
+ enhancedRecall: options.enhancedRecall,
438
430
  });
439
- closeQuietly(opened);
440
- }
441
- this.conn = this.beam.db;
442
- this.db = this.beam.db;
443
- // Wipe-and-rebuild stale embeddings when the configured model changed since
444
- // the vectors were written. Runs inside the runtime scope so
445
- // `currentEmbeddingModel()` reflects this instance's configured model.
446
- // Skipped for read-only opens (`reconcile: false`) so an ephemeral stats
447
- // reader never triggers a destructive migration whose async rebuild it would
448
- // exit before completing — which would otherwise lose the embeddings.
449
- if (options.reconcile !== false) {
450
- this.#withRuntimeOptions(() => reconcileEmbeddingModel(this.beam));
431
+ this.#ownsDb = options.db === undefined;
432
+ if (options.db !== undefined) {
433
+ const opened = this.beam.db;
434
+ initBeam(options.db);
435
+ Object.defineProperty(this.beam, "db", { value: options.db });
436
+ Object.defineProperty(this.beam, "annotations", {
437
+ value: buildBeamAnnotations(options.db, this.dbPath),
438
+ });
439
+ Object.defineProperty(this.beam, "episodicGraph", {
440
+ value: buildEpisodicGraph(options.db, this.dbPath),
441
+ });
442
+ closeQuietly(opened);
443
+ }
444
+ this.conn = this.beam.db;
445
+ this.db = this.beam.db;
446
+ // Wipe-and-rebuild stale embeddings when the configured model changed since
447
+ // the vectors were written. Runs inside the runtime scope so
448
+ // `currentEmbeddingModel()` reflects this instance's configured model.
449
+ // Skipped for read-only opens (`reconcile: false`) so an ephemeral stats
450
+ // reader never triggers a destructive migration whose async rebuild it would
451
+ // exit before completing — which would otherwise lose the embeddings.
452
+ if (options.reconcile !== false) {
453
+ withMnemopiRuntimeOptions(this.runtimeOptions, () => reconcileEmbeddingModel(this.beam));
454
+ }
455
+ } finally {
456
+ popLoopPhase();
451
457
  }
452
458
  }
453
459
 
454
460
  close(): void {
455
- if (this.#closed) return;
456
- this.#closed = true;
457
- if (this.#ownsDb) this.beam.close();
461
+ withLoopPhase("mnemopi.close", () => {
462
+ if (this.#closed) return;
463
+ this.#closed = true;
464
+ if (this.#ownsDb) this.beam.close();
465
+ });
458
466
  }
459
467
 
460
468
  async flushExtractions(): Promise<void> {
@@ -462,12 +470,14 @@ export class Mnemopi {
462
470
  }
463
471
 
464
472
  remember(memory: string | RememberInput, options: RememberFacadeOptions = {}): string {
465
- const content = typeof memory === "string" ? memory : memory.content;
466
- return this.#withRuntimeOptions(() => this.beam.remember(content, toRememberOptions(memory, options)));
473
+ return this.#withRuntimeOptions("mnemopi.retain", () => {
474
+ const content = typeof memory === "string" ? memory : memory.content;
475
+ return this.beam.remember(content, toRememberOptions(memory, options));
476
+ });
467
477
  }
468
478
 
469
479
  recall(query: string, topK = 5, options: RecallFacadeOptions = {}): Promise<RecallResult[]> {
470
- return this.#withRuntimeOptions(() => this.beam.recall(query, topK, toRecallOptions(options)));
480
+ return this.#withRuntimeOptions("mnemopi.recall", () => this.beam.recall(query, topK, toRecallOptions(options)));
471
481
  }
472
482
 
473
483
  recallEnhanced(
@@ -475,7 +485,7 @@ export class Mnemopi {
475
485
  topK = 5,
476
486
  options: RecallFacadeOptions & RecallEnhancedOptions = {},
477
487
  ): Promise<RecallResult[]> {
478
- return this.#withRuntimeOptions(() =>
488
+ return this.#withRuntimeOptions("mnemopi.recall", () =>
479
489
  this.beam.recallEnhanced(query, topK, {
480
490
  ...toRecallOptions(options),
481
491
  useCache: options.useCache,
@@ -485,7 +495,7 @@ export class Mnemopi {
485
495
  }
486
496
 
487
497
  getContext(limit = 10): unknown[] {
488
- return this.#withRuntimeOptions(() => this.beam.getContext(limit));
498
+ return this.#withRuntimeOptions("mnemopi.read", () => this.beam.getContext(limit));
489
499
  }
490
500
 
491
501
  getStats(
@@ -493,62 +503,70 @@ export class Mnemopi {
493
503
  authorType: string | null = null,
494
504
  channelId: string | null = null,
495
505
  ): MemoryFacadeStats {
496
- const working = this.#withRuntimeOptions(() => this.beam.getWorkingStats(authorId, authorType, channelId));
497
- const episodic = this.#withRuntimeOptions(() => this.beam.getEpisodicStats(authorId, authorType, channelId));
498
- const totalMemories = countRows(this.conn, "SELECT COUNT(*) AS total FROM working_memory");
499
- const totalSessions = countRows(this.conn, "SELECT COUNT(DISTINCT session_id) AS total FROM working_memory");
500
- using lastStatement = this.conn.prepare("SELECT timestamp FROM working_memory ORDER BY timestamp DESC LIMIT 1");
501
- const last = lastStatement.get() as {
502
- timestamp: string | null;
503
- } | null;
504
- const tripleTotal = countRows(this.conn, "SELECT COUNT(*) AS total FROM triples");
505
- let banks = ["default"];
506
- if (this.dbPath !== undefined && this.dbPath !== ":memory:") {
507
- const dataDir = dataDirForDbPath(this.dbPath);
508
- banks = new BankManager(dataDir).listBanks();
509
- }
510
- return {
511
- total_memories: totalMemories,
512
- total_sessions: totalSessions,
513
- sources: sourceCounts(this.conn),
514
- last_memory: last?.timestamp ?? null,
515
- database: this.dbPath ?? ":memory:",
516
- mode: "beam",
517
- banks,
518
- beam: { working_memory: working, episodic_memory: episodic, triples: { total: tripleTotal } },
519
- };
506
+ return withLoopPhase("mnemopi.stats", () => {
507
+ const working = withMnemopiRuntimeOptions(this.runtimeOptions, () =>
508
+ this.beam.getWorkingStats(authorId, authorType, channelId),
509
+ );
510
+ const episodic = withMnemopiRuntimeOptions(this.runtimeOptions, () =>
511
+ this.beam.getEpisodicStats(authorId, authorType, channelId),
512
+ );
513
+ const totalMemories = countRows(this.conn, "SELECT COUNT(*) AS total FROM working_memory");
514
+ const totalSessions = countRows(this.conn, "SELECT COUNT(DISTINCT session_id) AS total FROM working_memory");
515
+ using lastStatement = this.conn.prepare(
516
+ "SELECT timestamp FROM working_memory ORDER BY timestamp DESC LIMIT 1",
517
+ );
518
+ const last = lastStatement.get() as {
519
+ timestamp: string | null;
520
+ } | null;
521
+ const tripleTotal = countRows(this.conn, "SELECT COUNT(*) AS total FROM triples");
522
+ let banks = ["default"];
523
+ if (this.dbPath !== undefined && this.dbPath !== ":memory:") {
524
+ const dataDir = dataDirForDbPath(this.dbPath);
525
+ banks = new BankManager(dataDir).listBanks();
526
+ }
527
+ return {
528
+ total_memories: totalMemories,
529
+ total_sessions: totalSessions,
530
+ sources: sourceCounts(this.conn),
531
+ last_memory: last?.timestamp ?? null,
532
+ database: this.dbPath ?? ":memory:",
533
+ mode: "beam",
534
+ banks,
535
+ beam: { working_memory: working, episodic_memory: episodic, triples: { total: tripleTotal } },
536
+ };
537
+ });
520
538
  }
521
539
 
522
540
  get(memoryId: string): unknown | null {
523
- return this.#withRuntimeOptions(() => this.beam.get(memoryId));
541
+ return this.#withRuntimeOptions("mnemopi.read", () => this.beam.get(memoryId));
524
542
  }
525
543
 
526
544
  forget(memoryId: string): boolean {
527
- return this.#withRuntimeOptions(() => this.beam.forgetWorking(memoryId));
545
+ return this.#withRuntimeOptions("mnemopi.edit", () => this.beam.forgetWorking(memoryId));
528
546
  }
529
547
 
530
548
  update(memoryId: string, content: string | null = null, importance: number | null = null): boolean {
531
- return this.#withRuntimeOptions(() => this.beam.updateWorking(memoryId, content, importance));
549
+ return this.#withRuntimeOptions("mnemopi.edit", () => this.beam.updateWorking(memoryId, content, importance));
532
550
  }
533
551
 
534
552
  sleep(dryRun = false): SleepResult {
535
- return this.#withRuntimeOptions(() => this.beam.sleep(dryRun));
553
+ return this.#withRuntimeOptions("mnemopi.consolidate", () => this.beam.sleep(dryRun));
536
554
  }
537
555
 
538
556
  sleepAllSessions(dryRun = false): SleepResult {
539
- return this.#withRuntimeOptions(() => this.beam.sleepAllSessions(dryRun));
557
+ return this.#withRuntimeOptions("mnemopi.consolidate", () => this.beam.sleepAllSessions(dryRun));
540
558
  }
541
559
 
542
560
  scratchpadWrite(content: string): string {
543
- return this.#withRuntimeOptions(() => this.beam.scratchpadWrite(content));
561
+ return this.#withRuntimeOptions("mnemopi.retain", () => this.beam.scratchpadWrite(content));
544
562
  }
545
563
 
546
564
  scratchpadRead(): unknown[] {
547
- return this.#withRuntimeOptions(() => this.beam.scratchpadRead());
565
+ return this.#withRuntimeOptions("mnemopi.read", () => this.beam.scratchpadRead());
548
566
  }
549
567
 
550
568
  scratchpadClear(): void {
551
- this.#withRuntimeOptions(() => this.beam.scratchpadClear());
569
+ this.#withRuntimeOptions("mnemopi.edit", () => this.beam.scratchpadClear());
552
570
  }
553
571
 
554
572
  addMemory(memory: string | RememberInput, options: RememberFacadeOptions = {}): string {
@@ -574,8 +592,8 @@ export class Mnemopi {
574
592
  consolidate(dryRun = false): SleepResult {
575
593
  return this.sleep(dryRun);
576
594
  }
577
- #withRuntimeOptions<T>(fn: () => T): T {
578
- return withMnemopiRuntimeOptions(this.runtimeOptions, fn);
595
+ #withRuntimeOptions<T>(label: string, fn: () => T): T {
596
+ return withLoopPhase(label, () => withMnemopiRuntimeOptions(this.runtimeOptions, fn));
579
597
  }
580
598
  }
581
599
 
@@ -1,5 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import { logger } from "@oh-my-pi/pi-utils";
2
+ import { logger, withLoopPhase } from "@oh-my-pi/pi-utils";
3
3
  import { type Env, polyphonicRecallEnabled } from "../config";
4
4
  import { closeQuietly, type DatabasePath, openDatabase } from "../db";
5
5
  import { backfillConsolidatedFacts, ensureVeracityConsolidator } from "./beam/consolidate";
@@ -759,11 +759,15 @@ export function polyphonicRecall(
759
759
  topK = 10,
760
760
  options: PolyphonicRecallOptions = {},
761
761
  ): PolyphonicMemoryResult[] {
762
- return getPolyphonicEngine(beam).recall(
763
- query,
764
- options.queryEmbedding ?? null,
765
- topK,
766
- options.contextBudget ?? 4000,
767
- options,
768
- );
762
+ // Voice fan-out, graph walk and fusion run synchronously on the caller's event loop;
763
+ // label them so a stall is attributable.
764
+ return withLoopPhase("mnemopi.recall", () => {
765
+ return getPolyphonicEngine(beam).recall(
766
+ query,
767
+ options.queryEmbedding ?? null,
768
+ topK,
769
+ options.contextBudget ?? 4000,
770
+ options,
771
+ );
772
+ });
769
773
  }