fix(memory): split vector store readiness

This commit is contained in:
Peter Steinberger
2026-05-03 17:18:57 +01:00
parent 3617778aaf
commit a38c2c233a
16 changed files with 276 additions and 36 deletions

View File

@@ -676,14 +676,30 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
let indexError: string | undefined;
const syncFn = manager.sync ? manager.sync.bind(manager) : undefined;
if (deep) {
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
progress.setLabel("Probing vector…");
await manager.probeVectorAvailability();
progress.tick();
progress.setLabel("Probing embeddings…");
embeddingProbe = await manager.probeEmbeddingAvailability();
progress.tick();
});
const initialStatus = manager.status();
const hasVectorStoreProbe =
initialStatus.backend === "builtin" &&
typeof manager.probeVectorStoreAvailability === "function";
await withProgress(
{ label: "Checking memory…", total: hasVectorStoreProbe ? 3 : 2 },
async (progress) => {
progress.setLabel(hasVectorStoreProbe ? "Probing vector store…" : "Probing vectors…");
if (hasVectorStoreProbe) {
await manager.probeVectorStoreAvailability?.();
} else {
await manager.probeVectorAvailability();
}
progress.tick();
progress.setLabel("Probing embeddings…");
embeddingProbe = await manager.probeEmbeddingAvailability();
progress.tick();
if (hasVectorStoreProbe) {
progress.setLabel("Checking semantic vectors…");
await manager.probeVectorAvailability();
progress.tick();
}
},
);
if (opts.index && syncFn) {
await withProgressTotals(
{
@@ -856,20 +872,31 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
}
if (status.vector) {
const vectorState = status.vector.enabled
? status.vector.available === undefined
? "unknown"
: status.vector.available
? "ready"
: "unavailable"
: "disabled";
const vectorColor =
vectorState === "ready"
? theme.success
: vectorState === "unavailable"
? theme.warn
: theme.muted;
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
const formatVectorState = (available: boolean | undefined) =>
status.vector?.enabled
? available === undefined
? "unknown"
: available
? "ready"
: "unavailable"
: "disabled";
const formatVectorLine = (lineLabel: string, state: string) => {
const vectorColor =
state === "ready" ? theme.success : state === "unavailable" ? theme.warn : theme.muted;
lines.push(`${label(lineLabel)} ${colorize(rich, vectorColor, state)}`);
};
if (status.backend === "builtin") {
const storeState = formatVectorState(status.vector.storeAvailable);
formatVectorLine("Vector store", storeState);
if (status.vector.semanticAvailable !== undefined) {
formatVectorLine("Semantic vectors", formatVectorState(status.vector.semanticAvailable));
}
} else {
const vectorState = formatVectorState(
status.vector.semanticAvailable ?? status.vector.available,
);
formatVectorLine("Vector", vectorState);
}
if (status.vector.dims) {
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
}
@@ -1117,7 +1144,8 @@ export async function runMemoryIndex(opts: MemoryCommandOptions) {
}
const postIndexStatus = manager.status();
const vectorEnabled = postIndexStatus.vector?.enabled ?? false;
const vectorAvailable = postIndexStatus.vector?.available;
const vectorAvailable =
postIndexStatus.vector?.storeAvailable ?? postIndexStatus.vector?.available;
const vectorLoadErr = postIndexStatus.vector?.loadError;
if (vectorEnabled && vectorAvailable === false) {
const errDetail = vectorLoadErr ? `: ${vectorLoadErr}` : "";

View File

@@ -105,6 +105,7 @@ describe("memory cli", () => {
function makeMemoryStatus(overrides: Record<string, unknown> = {}) {
return {
backend: "builtin",
files: 0,
chunks: 0,
dirty: false,
@@ -113,7 +114,7 @@ describe("memory cli", () => {
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
vector: { enabled: true, available: true },
vector: { enabled: true, storeAvailable: true, semanticAvailable: true, available: true },
...overrides,
};
}
@@ -226,6 +227,8 @@ describe("memory cli", () => {
fts: { enabled: true, available: true },
vector: {
enabled: true,
storeAvailable: true,
semanticAvailable: true,
available: true,
extensionPath: "/opt/sqlite-vec.dylib",
dims: 1024,
@@ -238,7 +241,8 @@ describe("memory cli", () => {
await runMemoryCli(["status"]);
expect(probeVectorAvailability).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: ready"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector store: ready"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Semantic vectors: ready"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector dims: 1024"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector path: /opt/sqlite-vec.dylib"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("FTS: ready"));
@@ -274,7 +278,7 @@ describe("memory cli", () => {
expect(probeVectorAvailability).not.toHaveBeenCalled();
expect(probeEmbeddingAvailability).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Provider: auto"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: unknown"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector store: unknown"));
expect(close).toHaveBeenCalled();
});
@@ -350,6 +354,8 @@ describe("memory cli", () => {
dirty: true,
vector: {
enabled: true,
storeAvailable: false,
semanticAvailable: false,
available: false,
loadError: "load failed",
},
@@ -360,16 +366,19 @@ describe("memory cli", () => {
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--agent", "main"]);
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: unavailable"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector store: unavailable"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Semantic vectors: unavailable"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector error: load failed"));
expect(close).toHaveBeenCalled();
});
it("prints embeddings status when deep", async () => {
const close = vi.fn(async () => {});
const probeVectorStoreAvailability = vi.fn(async () => true);
const probeVectorAvailability = vi.fn(async () => true);
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
mockManager({
probeVectorStoreAvailability,
probeVectorAvailability,
probeEmbeddingAvailability,
status: () => makeMemoryStatus({ files: 1, chunks: 1 }),
@@ -379,12 +388,89 @@ describe("memory cli", () => {
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--deep"]);
expect(probeVectorStoreAvailability).toHaveBeenCalled();
expect(probeVectorAvailability).toHaveBeenCalled();
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Embeddings: ready"));
expect(close).toHaveBeenCalled();
});
it("prints vector store separately from embedding readiness when deep", async () => {
const close = vi.fn(async () => {});
const probeVectorStoreAvailability = vi.fn(async () => true);
const probeVectorAvailability = vi.fn(async () => false);
const probeEmbeddingAvailability = vi.fn(async () => ({
ok: false,
error: "No embedding provider available",
}));
mockManager({
probeVectorStoreAvailability,
probeVectorAvailability,
probeEmbeddingAvailability,
status: () =>
makeMemoryStatus({
provider: "none",
requestedProvider: "auto",
vector: {
enabled: true,
storeAvailable: true,
semanticAvailable: false,
available: false,
},
}),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--deep"]);
expect(probeVectorStoreAvailability).toHaveBeenCalled();
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(probeVectorAvailability).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector store: ready"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Semantic vectors: unavailable"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Embeddings: unavailable"));
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Embeddings error: No embedding provider available"),
);
expect(close).toHaveBeenCalled();
});
it("keeps non-builtin deep status on the semantic vector probe", async () => {
const close = vi.fn(async () => {});
const probeVectorStoreAvailability = vi.fn(async () => true);
const probeVectorAvailability = vi.fn(async () => true);
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
mockManager({
probeVectorStoreAvailability,
probeVectorAvailability,
probeEmbeddingAvailability,
status: () =>
makeMemoryStatus({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
vector: {
enabled: true,
semanticAvailable: true,
available: true,
},
}),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--deep"]);
expect(probeVectorStoreAvailability).not.toHaveBeenCalled();
expect(probeVectorAvailability).toHaveBeenCalled();
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: ready"));
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Vector store:"));
expect(close).toHaveBeenCalled();
});
it("prints recall-store audit details during status", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
@@ -578,9 +664,11 @@ describe("memory cli", () => {
it("reindexes on status --index", async () => {
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
const probeVectorStoreAvailability = vi.fn(async () => true);
const probeVectorAvailability = vi.fn(async () => true);
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
mockManager({
probeVectorStoreAvailability,
probeVectorAvailability,
probeEmbeddingAvailability,
sync,
@@ -592,6 +680,7 @@ describe("memory cli", () => {
await runMemoryCli(["status", "--index"]);
expectCliSync(sync);
expect(probeVectorStoreAvailability).toHaveBeenCalled();
expect(probeVectorAvailability).toHaveBeenCalled();
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(getMemorySearchManager).toHaveBeenCalledWith({

View File

@@ -406,9 +406,29 @@ describe("memory index", () => {
const status = manager.status();
expect(status.vector?.enabled).toBe(true);
expect(typeof status.vector?.available).toBe("boolean");
expect(status.vector?.storeAvailable).toBe(available);
expect(status.vector?.semanticAvailable).toBe(available);
expect(status.vector?.available).toBe(available);
});
it("probes sqlite vector store availability without initializing embeddings", async () => {
forceNoProvider = true;
const cfg = createCfg({
storePath: path.join(workspaceDir, "index-vector-store-only.sqlite"),
vectorEnabled: true,
});
const manager = await getPersistentManager(cfg);
const available = await manager.probeVectorStoreAvailability?.();
const status = manager.status();
expect(providerCalls).toEqual([]);
expect(typeof status.vector?.storeAvailable).toBe("boolean");
expect(status.vector?.storeAvailable).toBe(available);
expect(status.vector?.semanticAvailable).toBeUndefined();
expect(status.vector?.available).toBeUndefined();
});
it("caches embedding probe readiness across transient status managers", async () => {
const cfg = createCfg({ storePath: path.join(workspaceDir, "index-probe-cache.sqlite") });
const first = requireManager(

View File

@@ -160,6 +160,7 @@ export abstract class MemoryManagerSyncOps {
protected abstract readonly vector: {
enabled: boolean;
available: boolean | null;
semanticAvailable?: boolean;
extensionPath?: string;
loadError?: string;
dims?: number;
@@ -213,6 +214,7 @@ export abstract class MemoryManagerSyncOps {
protected resetVectorState(): void {
this.vectorReady = null;
this.vector.available = null;
this.vector.semanticAvailable = undefined;
this.vector.loadError = undefined;
this.vector.dims = undefined;
this.vectorDegradedWriteWarningShown = false;

View File

@@ -120,6 +120,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
protected readonly vector: {
enabled: boolean;
available: boolean | null;
semanticAvailable?: boolean;
extensionPath?: string;
loadError?: string;
dims?: number;
@@ -806,7 +807,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
: undefined,
vector: {
enabled: this.vector.enabled,
available: this.vector.available ?? undefined,
storeAvailable: this.vector.available ?? undefined,
semanticAvailable: this.vector.semanticAvailable,
available: this.vector.semanticAvailable,
extensionPath: this.vector.extensionPath,
loadError: this.vector.loadError,
dims: this.vector.dims,
@@ -837,14 +840,26 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
async probeVectorAvailability(): Promise<boolean> {
if (!this.vector.enabled) {
this.vector.semanticAvailable = false;
return false;
}
await this.ensureProviderInitialized();
// FTS-only mode: vector search not available
if (!this.provider) {
this.vector.semanticAvailable = false;
return false;
}
return this.ensureVectorReady();
const ready = await this.probeVectorStoreAvailability();
this.vector.semanticAvailable = ready;
return ready;
}
async probeVectorStoreAvailability(): Promise<boolean> {
if (!this.vector.enabled) {
this.vector.available = false;
return false;
}
return await this.ensureVectorReady();
}
private cacheProbeResult(result: MemoryEmbeddingProbeResult): MemoryEmbeddingProbeResult {

View File

@@ -4772,6 +4772,7 @@ describe("QmdMemoryManager", () => {
expect(manager.status().vector).toEqual({
enabled: true,
available: false,
semanticAvailable: false,
loadError: "QMD index has 0 vectors; semantic search is unavailable until embeddings finish",
});
await manager.close();
@@ -4805,6 +4806,7 @@ describe("QmdMemoryManager", () => {
expect(manager.status().vector).toEqual({
enabled: true,
available: true,
semanticAvailable: true,
loadError: undefined,
});
await manager.close();
@@ -4863,6 +4865,7 @@ describe("QmdMemoryManager", () => {
expect(manager.status().vector).toEqual({
enabled: true,
available: false,
semanticAvailable: false,
loadError: "Could not determine QMD vector status from `qmd status`",
});
await manager.close();
@@ -4889,6 +4892,7 @@ describe("QmdMemoryManager", () => {
expect(manager.status().vector).toEqual({
enabled: false,
available: false,
semanticAvailable: false,
loadError: undefined,
});
await manager.close();

View File

@@ -1357,6 +1357,7 @@ export class QmdMemoryManager implements MemorySearchManager {
vector: {
enabled: qmdUsesVectors(this.qmd.searchMode),
available: this.vectorAvailable ?? undefined,
semanticAvailable: this.vectorAvailable ?? undefined,
loadError: this.vectorStatusDetail ?? undefined,
},
batch: {

View File

@@ -325,7 +325,14 @@ async function getBuiltinMemorySearchManager(params: {
}
class BorrowedMemoryManager implements MemorySearchManager {
constructor(private readonly inner: MemorySearchManager) {}
readonly probeVectorStoreAvailability?: () => Promise<boolean>;
constructor(private readonly inner: MemorySearchManager) {
if (inner.probeVectorStoreAvailability) {
const probeVectorStoreAvailability = inner.probeVectorStoreAvailability.bind(inner);
this.probeVectorStoreAvailability = async () => await probeVectorStoreAvailability();
}
}
async search(
query: string,
@@ -517,6 +524,19 @@ class FallbackMemoryManager implements MemorySearchManager {
return this.fallback?.getCachedEmbeddingAvailability?.() ?? null;
}
async probeVectorStoreAvailability() {
this.ensureOpen();
if (!this.primaryFailed) {
return await (this.deps.primary.probeVectorStoreAvailability?.() ??
this.deps.primary.probeVectorAvailability());
}
const fallback = await this.ensureFallback();
return (
(await (fallback?.probeVectorStoreAvailability?.() ?? fallback?.probeVectorAvailability())) ??
false
);
}
async probeVectorAvailability() {
this.ensureOpen();
if (!this.primaryFailed) {