diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4306590fd..16f11c730 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/mnemopi/backend.ts b/packages/coding-agent/src/mnemopi/backend.ts index a4ce091c7..291ec9233 100644 --- a/packages/coding-agent/src/mnemopi/backend.ts +++ b/packages/coding-agent/src/mnemopi/backend.ts @@ -305,6 +305,7 @@ function createStatsMemory(config: MnemopiBackendConfig, bank: string): Mnemopi authorType: "agent", channelId: bank, ...providerOptions, + reconcile: false, } as ConstructorParameters[0]); } diff --git a/packages/coding-agent/src/mnemopi/config.ts b/packages/coding-agent/src/mnemopi/config.ts index f0def843b..db5de0c08 100644 --- a/packages/coding-agent/src/mnemopi/config.ts +++ b/packages/coding-agent/src/mnemopi/config.ts @@ -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, diff --git a/packages/coding-agent/test/mnemopi-embedding-variant.test.ts b/packages/coding-agent/test/mnemopi-embedding-variant.test.ts index 8cbaa17db..669f2931f 100644 --- a/packages/coding-agent/test/mnemopi-embedding-variant.test.ts +++ b/packages/coding-agent/test/mnemopi-embedding-variant.test.ts @@ -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; + } + }); }); diff --git a/packages/mnemopi/CHANGELOG.md b/packages/mnemopi/CHANGELOG.md index a4c8ad3cd..b8e071b39 100644 --- a/packages/mnemopi/CHANGELOG.md +++ b/packages/mnemopi/CHANGELOG.md @@ -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 diff --git a/packages/mnemopi/src/core/memory.ts b/packages/mnemopi/src/core/memory.ts index 90d911710..912d787cd 100644 --- a/packages/mnemopi/src/core/memory.ts +++ b/packages/mnemopi/src/core/memory.ts @@ -45,6 +45,13 @@ export interface MnemopiOptions { readonly llm?: false | MnemopiLlmRuntimeOptions | Model | 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 { diff --git a/packages/mnemopi/test/embedding-model-reconcile.test.ts b/packages/mnemopi/test/embedding-model-reconcile.test.ts index 9612fde59..890cd1e97 100644 --- a/packages/mnemopi/test/embedding-model-reconcile.test.ts +++ b/packages/mnemopi/test/embedding-model-reconcile.test.ts @@ -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(); + } + }); });