@oh-my-pi/pi-mnemopi 18.18.3 → 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 +7 -0
- package/dist/types/core/beam/helpers.d.ts +18 -0
- package/package.json +5 -5
- package/src/core/beam/helpers.ts +67 -17
- package/src/core/beam/store.ts +10 -24
- package/src/core/embeddings.ts +17 -10
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.
|
|
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.
|
|
46
|
-
"@oh-my-pi/pi-catalog": "18.18.
|
|
47
|
-
"@oh-my-pi/pi-natives": "18.18.
|
|
48
|
-
"@oh-my-pi/pi-utils": "18.18.
|
|
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",
|
package/src/core/beam/helpers.ts
CHANGED
|
@@ -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
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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,
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
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) {
|
package/src/core/beam/store.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/src/core/embeddings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
517
|
+
localModelPromises.clear();
|
|
511
518
|
localModelInitializer = defaultLocalModelInitializer;
|
|
512
519
|
apiCallCount = 0;
|
|
513
520
|
queryCache.clear();
|