@oh-my-pi/pi-mnemopi 17.0.0 → 17.0.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
+ ## [17.0.1] - 2026-07-16
6
+
7
+ ### Fixed
8
+
9
+ - Fixed working-memory TTL trim silently deleting restored or imported durable rows: rows keeping `consolidated_at = NULL` with an old `timestamp` are no longer trimmed when flagged `IMPORTED`, `importFromDict` stamps imported rows as consolidated, and every working-memory delete path (trim, `forgetWorking`, force-import overwrite) now cascades linked annotations, embeddings, facts, memoria projections, gists, and graph edges instead of leaving orphans. ([#4819](https://github.com/can1357/oh-my-pi/issues/4819))
10
+ - Fixed Mnemopi local embeddings on Windows loading an unrelated `onnxruntime.dll` from the inherited system path instead of fastembed's cached ORT runtime. ([#4849](https://github.com/can1357/oh-my-pi/issues/4849))
11
+
5
12
  ## [16.3.9] - 2026-07-06
6
13
 
7
14
  ### Fixed
@@ -10,5 +10,32 @@ export interface FastembedRuntimeInstallPlan {
10
10
  }
11
11
  /** Build the deterministic fastembed runtime install plan used by local embeddings. */
12
12
  export declare function fastembedRuntimeInstallPlan(): FastembedRuntimeInstallPlan;
13
+ /** Inputs for selecting the Windows DLL directory paired with a fastembed installation. */
14
+ export interface WindowsFastembedRuntimeOptions {
15
+ /** Resolved fastembed package entry whose dependency graph owns the ORT binding. */
16
+ fastembedEntry: string;
17
+ /** Directory containing fastembed's manifest and nested dependency graph. */
18
+ fastembedPackageDir: string;
19
+ /** Native architecture to select; defaults to the current process architecture. */
20
+ arch?: string;
21
+ /** Environment receiving the DLL search path; defaults to the subprocess environment. */
22
+ env?: NodeJS.ProcessEnv;
23
+ }
24
+ /** The ORT module and DLL directory selected from fastembed's own dependency graph. */
25
+ export interface WindowsFastembedRuntime {
26
+ /** Resolved entry for fastembed's own ONNX Runtime dependency. */
27
+ ortEntry: string;
28
+ /** Package directory containing the selected ORT manifest and native assets. */
29
+ ortPackageDir: string;
30
+ /** Directory prepended to `PATH` so Windows finds the paired native DLL. */
31
+ dllDir: string;
32
+ }
33
+ /**
34
+ * Prepend the ORT DLL directory paired with fastembed before Bun loads its
35
+ * native binding. Compiled Windows binaries extract `.node` files to a
36
+ * temporary directory, so the default DLL search can otherwise select an
37
+ * unrelated `onnxruntime.dll` from the inherited system path.
38
+ */
39
+ export declare function prepareWindowsFastembedRuntime({ fastembedEntry, fastembedPackageDir, arch, env, }: WindowsFastembedRuntimeOptions): Promise<WindowsFastembedRuntime>;
13
40
  export declare function loadFastembed(): Promise<FastembedModule>;
14
41
  export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-mnemopi",
4
- "version": "17.0.0",
4
+ "version": "17.0.2",
5
5
  "description": "Local SQLite memory engine for Oh My Pi agents",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,9 +39,9 @@
39
39
  "fmt": "biome format --write ."
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "17.0.0",
43
- "@oh-my-pi/pi-catalog": "17.0.0",
44
- "@oh-my-pi/pi-utils": "17.0.0",
42
+ "@oh-my-pi/pi-ai": "17.0.2",
43
+ "@oh-my-pi/pi-catalog": "17.0.2",
44
+ "@oh-my-pi/pi-utils": "17.0.2",
45
45
  "lru-cache": "11.5.2"
46
46
  },
47
47
  "peerDependencies": {
@@ -163,27 +163,106 @@ function findDuplicate(beam: BeamMemoryState, content: string): string | null {
163
163
  return row?.id ?? null;
164
164
  }
165
165
 
166
+ function tableExists(db: BeamMemoryState["db"], table: string): boolean {
167
+ return (
168
+ db
169
+ .prepare("SELECT 1 FROM sqlite_master WHERE type IN ('table','virtual table') AND name = ? LIMIT 1")
170
+ .get(table) !== null
171
+ );
172
+ }
173
+
174
+ /** Tables whose rows point back to a `working_memory` id via `source_memory_id`. */
175
+ const MEMORIA_SOURCE_TABLES = [
176
+ "memoria_facts",
177
+ "memoria_instructions",
178
+ "memoria_kg",
179
+ "memoria_preferences",
180
+ "memoria_timelines",
181
+ ] as const;
182
+
183
+ /**
184
+ * Remove every artifact linked to the given `working_memory` ids so no deletion
185
+ * path leaves orphans behind. Covers annotations, embeddings, extracted facts
186
+ * (`facts.source_msg_id`), memoria projections (`*.source_memory_id`), episodic
187
+ * gists, and the graph edges tied to those memory / gist / fact node ids.
188
+ *
189
+ * Idempotent and schema-tolerant: `gists` / `graph_edges` only exist once an
190
+ * `EpisodicGraph` has initialised, so they are guarded. Callers own the
191
+ * transaction and the base `working_memory` delete.
192
+ */
193
+ function purgeWorkingMemoryArtifacts(db: BeamMemoryState["db"], ids: readonly string[]): void {
194
+ if (ids.length === 0) return;
195
+ const placeholders = ids.map(() => "?").join(", ");
196
+
197
+ const graphRefs = new Set<string>(ids);
198
+ for (const id of ids) graphRefs.add(`gist_${id}`);
199
+ if (tableExists(db, "facts")) {
200
+ const factRows = db.prepare(`SELECT fact_id FROM facts WHERE source_msg_id IN (${placeholders})`).all(...ids) as {
201
+ fact_id: string;
202
+ }[];
203
+ for (const row of factRows) graphRefs.add(row.fact_id);
204
+ db.prepare(`DELETE FROM facts WHERE source_msg_id IN (${placeholders})`).run(...ids);
205
+ }
206
+
207
+ db.prepare(`DELETE FROM annotations WHERE memory_id IN (${placeholders})`).run(...ids);
208
+ db.prepare(`DELETE FROM memory_embeddings WHERE memory_id IN (${placeholders})`).run(...ids);
209
+ for (const table of MEMORIA_SOURCE_TABLES) {
210
+ db.prepare(`DELETE FROM ${table} WHERE source_memory_id IN (${placeholders})`).run(...ids);
211
+ }
212
+
213
+ if (tableExists(db, "gists")) {
214
+ db.prepare(`DELETE FROM gists WHERE memory_id IN (${placeholders})`).run(...ids);
215
+ }
216
+ if (tableExists(db, "graph_edges")) {
217
+ const refs = [...graphRefs];
218
+ const refPlaceholders = refs.map(() => "?").join(", ");
219
+ db.prepare(`DELETE FROM graph_edges WHERE source IN (${refPlaceholders}) OR target IN (${refPlaceholders})`).run(
220
+ ...refs,
221
+ ...refs,
222
+ );
223
+ }
224
+ }
225
+
226
+ /**
227
+ * TTL / overflow trim for transient working memory. Only genuine scratch is
228
+ * eligible: `consolidated_at IS NULL` no longer suffices on its own, since
229
+ * restored or imported durable rows legitimately carry a NULL consolidation
230
+ * marker with an old event timestamp (issue #4819). Rows flagged `IMPORTED`
231
+ * are treated as durable and never trimmed, and trimmed rows cascade all linked
232
+ * artifacts via `purgeWorkingMemoryArtifacts`.
233
+ */
166
234
  function trimWorkingMemory(beam: BeamMemoryState): void {
167
235
  const limit = beam.config.workingMemoryLimit;
168
236
  if (!Number.isFinite(limit) || limit <= 0) return;
169
237
  const ttlHours = beam.config.workingMemoryTtlHours;
170
238
  const cutoff = toUtcIso(new Date(Date.now() - ttlHours * 3_600_000));
171
- beam.db
172
- .prepare(`
173
- DELETE FROM working_memory
174
- WHERE session_id = ?
175
- AND consolidated_at IS NULL
176
- AND (
177
- timestamp < ? OR
178
- id NOT IN (
239
+ transaction(beam.db, () => {
240
+ const ids = (
241
+ beam.db
242
+ .prepare(`
179
243
  SELECT id FROM working_memory
180
- WHERE session_id = ? AND consolidated_at IS NULL
181
- ORDER BY timestamp DESC
182
- LIMIT ?
183
- )
184
- )
185
- `)
186
- .run(beam.sessionId, cutoff, beam.sessionId, limit);
244
+ WHERE session_id = ?
245
+ AND consolidated_at IS NULL
246
+ AND trust_tier IS NOT 'IMPORTED'
247
+ AND (
248
+ timestamp < ? OR
249
+ id NOT IN (
250
+ SELECT id FROM working_memory
251
+ WHERE session_id = ? AND consolidated_at IS NULL AND trust_tier IS NOT 'IMPORTED'
252
+ ORDER BY timestamp DESC
253
+ LIMIT ?
254
+ )
255
+ )
256
+ `)
257
+ .all(beam.sessionId, cutoff, beam.sessionId, limit) as { id: string }[]
258
+ ).map(row => row.id);
259
+ if (ids.length === 0) return;
260
+ const placeholders = ids.map(() => "?").join(", ");
261
+ beam.db
262
+ .prepare(`DELETE FROM working_memory WHERE id IN (${placeholders}) AND session_id = ?`)
263
+ .run(...ids, beam.sessionId);
264
+ purgeWorkingMemoryArtifacts(beam.db, ids);
265
+ });
187
266
  }
188
267
 
189
268
  function addTemporalAnnotations(beam: BeamMemoryState, memoryId: string, timestamp: string, source: string): void {
@@ -715,7 +794,7 @@ export function forgetWorking(beam: BeamMemoryState, memoryId: string): boolean
715
794
  .run(memoryId, beam.sessionId);
716
795
  deleted = result.changes;
717
796
  if (deleted > 0) {
718
- beam.db.prepare("DELETE FROM annotations WHERE memory_id = ?").run(memoryId);
797
+ purgeWorkingMemoryArtifacts(beam.db, [memoryId]);
719
798
  }
720
799
  });
721
800
  if (deleted > 0) invalidateCaches(beam);
@@ -811,6 +890,10 @@ export function importFromDict(beam: BeamMemoryState, data: Record<string, unkno
811
890
  consolidation_log: { inserted: 0 },
812
891
  } satisfies ImportStats;
813
892
  const db: Database = beam.db;
893
+ // Imported working-memory rows are durable, not scratch: stamp any that
894
+ // arrive unconsolidated so the TTL trim treats them as consolidated and can
895
+ // never silently discard a restored bank (issue #4819).
896
+ const importedAt = toUtcIso();
814
897
  const oldToNewRowid = new Map<number, number>();
815
898
 
816
899
  transaction(db, () => {
@@ -825,6 +908,7 @@ export function importFromDict(beam: BeamMemoryState, data: Record<string, unkno
825
908
  }
826
909
  if (exists) {
827
910
  db.prepare("DELETE FROM working_memory WHERE id = ?").run(id);
911
+ purgeWorkingMemoryArtifacts(db, [id]);
828
912
  stats.working_memory.overwritten++;
829
913
  } else {
830
914
  stats.working_memory.inserted++;
@@ -851,7 +935,7 @@ export function importFromDict(beam: BeamMemoryState, data: Record<string, unkno
851
935
  sqlBinding(item.last_recalled, null),
852
936
  sqlBinding(item.created_at, null),
853
937
  clampVeracity(item.veracity),
854
- sqlBinding(item.consolidated_at, null),
938
+ item.consolidated_at == null ? importedAt : sqlBinding(item.consolidated_at, importedAt),
855
939
  sqlBinding(item.memory_type, "unknown"),
856
940
  sqlBinding(item.embed_text, null),
857
941
  sqlBinding(item.author_id, null),
@@ -48,6 +48,67 @@ export function fastembedRuntimeInstallPlan(): FastembedRuntimeInstallPlan {
48
48
  }
49
49
  let fastembedLoad: Promise<FastembedModule> | null = null;
50
50
 
51
+ /** Inputs for selecting the Windows DLL directory paired with a fastembed installation. */
52
+ export interface WindowsFastembedRuntimeOptions {
53
+ /** Resolved fastembed package entry whose dependency graph owns the ORT binding. */
54
+ fastembedEntry: string;
55
+ /** Directory containing fastembed's manifest and nested dependency graph. */
56
+ fastembedPackageDir: string;
57
+ /** Native architecture to select; defaults to the current process architecture. */
58
+ arch?: string;
59
+ /** Environment receiving the DLL search path; defaults to the subprocess environment. */
60
+ env?: NodeJS.ProcessEnv;
61
+ }
62
+
63
+ /** The ORT module and DLL directory selected from fastembed's own dependency graph. */
64
+ export interface WindowsFastembedRuntime {
65
+ /** Resolved entry for fastembed's own ONNX Runtime dependency. */
66
+ ortEntry: string;
67
+ /** Package directory containing the selected ORT manifest and native assets. */
68
+ ortPackageDir: string;
69
+ /** Directory prepended to `PATH` so Windows finds the paired native DLL. */
70
+ dllDir: string;
71
+ }
72
+
73
+ /**
74
+ * Prepend the ORT DLL directory paired with fastembed before Bun loads its
75
+ * native binding. Compiled Windows binaries extract `.node` files to a
76
+ * temporary directory, so the default DLL search can otherwise select an
77
+ * unrelated `onnxruntime.dll` from the inherited system path.
78
+ */
79
+ export async function prepareWindowsFastembedRuntime({
80
+ fastembedEntry,
81
+ fastembedPackageDir,
82
+ arch = process.arch,
83
+ env = process.env,
84
+ }: WindowsFastembedRuntimeOptions): Promise<WindowsFastembedRuntime> {
85
+ const nestedNodeModules = path.join(fastembedPackageDir, "node_modules");
86
+ const rootNodeModules = path.dirname(fastembedPackageDir);
87
+ const nestedOrtEntry = resolveRuntimeModule(nestedNodeModules, "onnxruntime-node");
88
+ const ortEntry = nestedOrtEntry ?? resolveRuntimeModule(rootNodeModules, "onnxruntime-node");
89
+ const ortPackageDir = path.join(nestedOrtEntry ? nestedNodeModules : rootNodeModules, "onnxruntime-node");
90
+ if (!ortEntry) {
91
+ throw new Error(`Cannot find module onnxruntime-node beside ${fastembedEntry}`);
92
+ }
93
+ const dllGlob = new Bun.Glob(`bin/napi-*/win32/${arch}/onnxruntime.dll`);
94
+ let dllDir: string | undefined;
95
+ for await (const dll of dllGlob.scan({ cwd: ortPackageDir, absolute: true, onlyFiles: true })) {
96
+ dllDir = path.dirname(dll);
97
+ break;
98
+ }
99
+ if (!dllDir) {
100
+ throw new Error(`Cannot find module onnxruntime-node Windows DLL for ${arch} beside ${ortEntry}`);
101
+ }
102
+
103
+ const currentPath = env.PATH;
104
+ const normalizedDllDir = path.resolve(dllDir).toLowerCase();
105
+ const alreadyPresent = currentPath
106
+ ?.split(path.delimiter)
107
+ .some(entry => path.resolve(entry).toLowerCase() === normalizedDllDir);
108
+ if (!alreadyPresent) env.PATH = currentPath ? `${dllDir}${path.delimiter}${currentPath}` : dllDir;
109
+ return { ortEntry, ortPackageDir, dllDir };
110
+ }
111
+
51
112
  export function loadFastembed(): Promise<FastembedModule> {
52
113
  fastembedLoad ??= loadFastembedOnce().catch(error => {
53
114
  fastembedLoad = null;
@@ -57,16 +118,14 @@ export function loadFastembed(): Promise<FastembedModule> {
57
118
  }
58
119
 
59
120
  async function loadFastembedOnce(): Promise<FastembedModule> {
60
- // Dynamic imports: both packages are optional peers that eagerly load
61
- // native addons and may be absent at runtime — a static import would load
62
- // the addon at module-init and crash every consumer without the peers.
63
121
  try {
64
- // Preload the pinned ORT before fastembed's nested ORT — only on Windows,
65
- // where loading the older binding first triggers a DLL-reuse crash.
66
- if (process.platform === "win32") {
67
- await import("onnxruntime-node");
122
+ const requireDirect = createRequire(import.meta.url);
123
+ const manifestPath = requireDirect.resolve("fastembed/package.json");
124
+ const manifest: { version?: unknown } = requireDirect(manifestPath);
125
+ if (manifest.version !== FASTEMBED_SPEC) {
126
+ throw new Error(`Cannot find package fastembed@${FASTEMBED_SPEC}; resolved ${String(manifest.version)}`);
68
127
  }
69
- return await import("fastembed");
128
+ return await loadResolvedFastembed(requireDirect.resolve("fastembed"), path.dirname(manifestPath));
70
129
  } catch (error) {
71
130
  if (!isRecoverableFastembedLoadError(error)) throw error;
72
131
  logger.debug("mnemopi: fastembed not loadable, using on-demand runtime install", {
@@ -76,6 +135,16 @@ async function loadFastembedOnce(): Promise<FastembedModule> {
76
135
  }
77
136
  }
78
137
 
138
+ async function loadResolvedFastembed(entry: string, fastembedPackageDir: string): Promise<FastembedModule> {
139
+ const requireFastembed = createRequire(entry);
140
+ if (process.platform === "win32") {
141
+ const { ortEntry } = await prepareWindowsFastembedRuntime({ fastembedEntry: entry, fastembedPackageDir });
142
+ requireFastembed(ortEntry);
143
+ }
144
+ const loaded: FastembedModule = requireFastembed(entry);
145
+ return loaded;
146
+ }
147
+
79
148
  async function loadFromRuntimeInstall(): Promise<FastembedModule> {
80
149
  const plan = fastembedRuntimeInstallPlan();
81
150
  const runtimeDir = await ensureRuntimeInstalled({
@@ -89,14 +158,9 @@ async function loadFromRuntimeInstall(): Promise<FastembedModule> {
89
158
  // onnxruntime-node, @anush008/tokenizers → platform binding, …) through
90
159
  // the runtime cache.
91
160
  installRuntimeModuleResolver({ runtimeNodeModules: nodeModules });
92
- if (process.platform === "win32") {
93
- const ortEntry = resolveRuntimeModule(nodeModules, "onnxruntime-node");
94
- if (ortEntry) createRequire(ortEntry)(ortEntry);
95
- }
96
161
  const entry = resolveRuntimeModule(nodeModules, "fastembed");
97
162
  if (!entry) throw new Error(`fastembed runtime install at ${runtimeDir} has no loadable entry`);
98
- const requireRuntime = createRequire(entry);
99
- return requireRuntime(entry) as FastembedModule;
163
+ return loadResolvedFastembed(entry, path.join(nodeModules, "fastembed"));
100
164
  }
101
165
 
102
166
  function isRecoverableFastembedLoadError(error: unknown): boolean {