fix(mnemopi): address review — read-only opens skip reconcile; preserve MNEMOPI_EMBEDDING_MODEL precedence (#2476)

This commit is contained in:
metaphorics
2026-06-14 08:27:15 +09:00
parent dd2e736476
commit d7f4d7c679
7 changed files with 64 additions and 4 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
### Added
- Added the `mnemopi.embeddingVariant` setting (`en` | `multilingual`) selecting a stronger SOTA local embedding model — `en` → `BAAI/bge-base-en-v1.5` (768d), `multilingual` → `intfloat/multilingual-e5-large` (1024d). `mnemopi.embeddingModel` still works as an advanced explicit override (it wins over the variant). Changing the active model wipes and rebuilds stored embeddings on the next start ([#2476](https://github.com/can1357/oh-my-pi/issues/2476))
- Added the `mnemopi.embeddingVariant` setting (`en` | `multilingual`) selecting a stronger SOTA local embedding model — `en` → `BAAI/bge-base-en-v1.5` (768d), `multilingual` → `intfloat/multilingual-e5-large` (1024d). Resolution precedence is `mnemopi.embeddingModel` setting > `MNEMOPI_EMBEDDING_MODEL` env > variant default, so the documented env override is still honored. Changing the active model wipes and rebuilds stored embeddings on the next writable start ([#2476](https://github.com/can1357/oh-my-pi/issues/2476))
## [15.12.5] - 2026-06-13
### Changed
@@ -305,6 +305,7 @@ function createStatsMemory(config: MnemopiBackendConfig, bank: string): Mnemopi
authorType: "agent",
channelId: bank,
...providerOptions,
reconcile: false,
} as ConstructorParameters<typeof Mnemopi>[0]);
}
+4 -1
View File
@@ -55,7 +55,10 @@ export function loadMnemopiConfig(settings: Settings, agentDir: string): Mnemopi
// other than the multilingual variant falls back to the English default.
const variantModel =
embeddingVariant === "multilingual" ? "intfloat/multilingual-e5-large" : "BAAI/bge-base-en-v1.5";
const embeddingModel = embeddingOverride?.trim() || variantModel;
// Precedence: explicit `mnemopi.embeddingModel` setting > `MNEMOPI_EMBEDDING_MODEL`
// env (documented model-level override) > variant-derived default. Without the env
// term a variant default would silently shadow a user's configured env model.
const embeddingModel = embeddingOverride?.trim() || Bun.env.MNEMOPI_EMBEDDING_MODEL?.trim() || variantModel;
return {
dbPath,
baseBank: scope.baseBank,
@@ -33,4 +33,29 @@ describe("loadMnemopiConfig embedding variant resolution", () => {
"BAAI/bge-base-en-v1.5",
);
});
it("honors MNEMOPI_EMBEDDING_MODEL when no explicit model setting is present", () => {
const previous = Bun.env.MNEMOPI_EMBEDDING_MODEL;
Bun.env.MNEMOPI_EMBEDDING_MODEL = "BAAI/bge-large-en-v1.5";
try {
// The documented env override must not be shadowed by the variant default.
expect(embeddingModelFor({ "mnemopi.embeddingVariant": "en" })).toBe("BAAI/bge-large-en-v1.5");
} finally {
if (previous === undefined) delete Bun.env.MNEMOPI_EMBEDDING_MODEL;
else Bun.env.MNEMOPI_EMBEDDING_MODEL = previous;
}
});
it("lets an explicit embeddingModel setting win over the env var", () => {
const previous = Bun.env.MNEMOPI_EMBEDDING_MODEL;
Bun.env.MNEMOPI_EMBEDDING_MODEL = "BAAI/bge-large-en-v1.5";
try {
expect(embeddingModelFor({ "mnemopi.embeddingModel": "openai/text-embedding-3-small" })).toBe(
"openai/text-embedding-3-small",
);
} finally {
if (previous === undefined) delete Bun.env.MNEMOPI_EMBEDDING_MODEL;
else Bun.env.MNEMOPI_EMBEDDING_MODEL = previous;
}
});
});
+1 -1
View File
@@ -4,7 +4,7 @@
### Added
- Added a wipe-and-rebuild reconcile (`reconcileEmbeddingModel`) that runs when the configured embedding model changes. At store open, if the model stamped on stored `memory_embeddings` rows differs from the active `currentEmbeddingModel()`, the stale embeddings and their binary vectors are dropped and every existing memory is enqueued for background re-embedding (in bounded batches) at the new model/dimension. The destructive wipe is skipped whenever it could not be rebuilt — embeddings disabled via the runtime option or the `MNEMOPI_NO_EMBEDDINGS` env, or an unresolved (empty) active model — so a stale-but-valid corpus is never destroyed without a replacement. Recall degrades gracefully (FTS-only) for memories whose vectors are not yet rebuilt ([#2476](https://github.com/can1357/oh-my-pi/issues/2476))
- Added a wipe-and-rebuild reconcile (`reconcileEmbeddingModel`) that runs when the configured embedding model changes. At store open, if the model stamped on stored `memory_embeddings` rows differs from the active `currentEmbeddingModel()`, the stale embeddings and their binary vectors are dropped and every existing memory is enqueued for background re-embedding (in bounded batches) at the new model/dimension. The destructive wipe is skipped whenever it could not be rebuilt — embeddings disabled via the runtime option or the `MNEMOPI_NO_EMBEDDINGS` env, an unresolved (empty) active model, or a read-only open (`reconcile: false`, used by ephemeral stats readers that would exit before the async rebuild finished) — so a stale-but-valid corpus is never destroyed without a replacement. Recall degrades gracefully (FTS-only) for memories whose vectors are not yet rebuilt ([#2476](https://github.com/can1357/oh-my-pi/issues/2476))
## [15.12.4] - 2026-06-13
+13 -1
View File
@@ -45,6 +45,13 @@ export interface MnemopiOptions {
readonly llm?: false | MnemopiLlmRuntimeOptions | Model<Api> | MnemopiLlmCompletion;
/** Escalate best-effort failure logs (embedding pipeline) from debug to warn. */
readonly debug?: boolean;
/**
* When `false`, skip the embedding-model reconcile (wipe-and-rebuild) on open.
* Read-only / ephemeral consumers (e.g. a stats snapshot) set this so an open
* never triggers a destructive migration whose background rebuild the process
* would exit before completing. Defaults to `true`.
*/
readonly reconcile?: boolean;
}
export interface RememberInput extends MemoryInput {
@@ -392,7 +399,12 @@ export class Mnemopi {
// Wipe-and-rebuild stale embeddings when the configured model changed since
// the vectors were written. Runs inside the runtime scope so
// `currentEmbeddingModel()` reflects this instance's configured model.
this.#withRuntimeOptions(() => reconcileEmbeddingModel(this.beam));
// Skipped for read-only opens (`reconcile: false`) so an ephemeral stats
// reader never triggers a destructive migration whose async rebuild it would
// exit before completing — which would otherwise lose the embeddings.
if (options.reconcile !== false) {
this.#withRuntimeOptions(() => reconcileEmbeddingModel(this.beam));
}
}
close(): void {
@@ -137,4 +137,23 @@ describe("reconcileEmbeddingModel on store open", () => {
db.close();
}
});
it("does not reconcile a read-only open (reconcile: false), even on a model change", () => {
const { db } = seedDb(OLD_MODEL);
let memory: Mnemopi | undefined;
try {
// A stats/read-only open is short-lived and would exit before its async
// rebuild completed, so it must not perform the destructive wipe.
memory = new Mnemopi({ db, embeddings: { model: NEW_MODEL, provider: fakeEmbed() }, reconcile: false });
expect(countEmbeddings(memory)).toBe(2);
expect(memory.beam.pendingExtractions.size).toBe(0);
const ep = memory.conn.query("SELECT binary_vector AS v FROM episodic_memory WHERE id = 'ep-1'").get() as {
v: Uint8Array | null;
};
expect(ep.v).not.toBeNull();
} finally {
memory?.close();
db.close();
}
});
});