fix(mnemopi): address review — read-only opens skip reconcile; preserve MNEMOPI_EMBEDDING_MODEL precedence (#2476)
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user