diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index 6db3674ab1f4..bb0666be7fb3 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -58,6 +58,15 @@ explicitly to use Gemini, Voyage, Mistral, DeepInfra, Bedrock, GitHub Copilot, Ollama, a local GGUF model, or an OpenAI-compatible `/v1/embeddings` endpoint. Legacy configs that still say `provider: "auto"` resolve to `openai`. + +Changing the embedding provider, model, provider settings, sources, scope, +chunking, or tokenizer can make the existing SQLite vector index incompatible. +OpenClaw pauses vector search and reports an index identity warning instead of +automatically re-embedding everything. Rebuild when you are ready with +`openclaw memory status --index --agent ` or +`openclaw memory index --force --agent `. + + If OpenAI embeddings are unreachable from your network, memory recall fails open instead of blocking the turn. Set the existing `memorySearch.provider` field to a reachable local, Ollama, regional, or OpenAI-compatible provider to restore @@ -155,7 +164,8 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible | `outputDimensionality` | `number` | `3072` | For Embedding 2: 768, 1536, or 3072 | - Changing model or `outputDimensionality` triggers an automatic full reindex. + Changing model or `outputDimensionality` changes the index identity. OpenClaw + pauses vector search until you explicitly rebuild the memory index. diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index f47d5eb5b8f8..43f5acf62ac7 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -71,6 +71,28 @@ type MemoryManagerPurpose = Parameters[0]["purpos type MemorySourceName = "memory" | "sessions"; +function formatMemoryIndexIdentityWarning( + status: ReturnType, + agentId: string, +): { + reason: string; + fix: string; +} | null { + const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity); + const reason = + (indexIdentity?.status === "mismatched" || indexIdentity?.status === "missing") && + typeof indexIdentity.reason === "string" + ? indexIdentity.reason + : undefined; + if (!reason) { + return null; + } + return { + reason, + fix: `Run: openclaw memory status --index --agent ${agentId}`, + }; +} + type SourceScan = { source: MemorySourceName; totalFiles: number | null; @@ -868,6 +890,12 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`); } } + const identityWarning = formatMemoryIndexIdentityWarning(status, agentId); + if (identityWarning) { + lines.push(`${label("Index identity")} ${warn(identityWarning.reason)}`); + lines.push(`${label("Vector search")} ${warn("paused until memory is rebuilt")}`); + lines.push(`${label("Fix")} ${muted(identityWarning.fix)}`); + } if (status.sourceCounts?.length) { lines.push(label("By source")); for (const entry of status.sourceCounts) { @@ -1256,6 +1284,15 @@ export async function runMemorySearch( defaultRuntime.writeJson({ results }); return; } + const identityWarning = + typeof manager.status === "function" + ? formatMemoryIndexIdentityWarning(manager.status(), agentId) + : null; + if (identityWarning) { + defaultRuntime.error( + `Memory index warning: ${identityWarning.reason}. Vector memory search is paused until the index is rebuilt. ${identityWarning.fix}`, + ); + } if (results.length === 0) { defaultRuntime.log("No matches."); return; diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index f468e0b08a3c..b65ca5f20a07 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -415,6 +415,36 @@ describe("memory cli", () => { expect(close).toHaveBeenCalled(); }); + it("prints index identity mismatch reasons", async () => { + const close = vi.fn(async () => {}); + mockManager({ + status: () => + makeMemoryStatus({ + dirty: true, + provider: "ollama", + model: "nomic-embed-text", + requestedProvider: "ollama", + custom: { + indexIdentity: { + status: "mismatched", + reason: "index was built for provider openai, expected ollama", + }, + }, + }), + close, + }); + + const log = spyRuntimeLogs(defaultRuntime); + await runMemoryCli(["status"]); + + expectLogged(log, "Provider: ollama (requested: ollama)"); + expectLogged(log, "Dirty: yes"); + expectLogged(log, "Index identity: index was built for provider openai, expected ollama"); + expectLogged(log, "Vector search: paused until memory is rebuilt"); + expectLogged(log, "Fix: Run: openclaw memory status --index --agent main"); + expect(close).toHaveBeenCalled(); + }); + it("keeps plain status from probing vector or embeddings", async () => { const close = vi.fn(async () => {}); const probeVectorAvailability = vi.fn(async () => { diff --git a/extensions/memory-core/src/memory-tool-manager-mock.ts b/extensions/memory-core/src/memory-tool-manager-mock.ts index d7e2b661b495..6d5d63661db5 100644 --- a/extensions/memory-core/src/memory-tool-manager-mock.ts +++ b/extensions/memory-core/src/memory-tool-manager-mock.ts @@ -86,6 +86,10 @@ export function setMemoryWorkspaceDir(next: string): void { workspaceDir = next; } +export function setMemoryCustomStatus(next: Record | undefined): void { + customStatus = next; +} + export function setMemorySearchImpl(next: SearchImpl): void { searchImpl = next; } @@ -130,6 +134,10 @@ export function getMemorySearchManagerMockCalls(): number { return getMemorySearchManagerMock.mock.calls.length; } +export function getMemorySyncMockCalls(): number { + return stubManager.sync.mock.calls.length; +} + export function getMemorySearchManagerMockConfigs(): unknown[] { return getMemorySearchManagerMock.mock.calls.map(([params]) => params.cfg); } diff --git a/extensions/memory-core/src/memory/embedding.test-mocks.ts b/extensions/memory-core/src/memory/embedding.test-mocks.ts index 978f41dea88b..3fbc88e85a74 100644 --- a/extensions/memory-core/src/memory/embedding.test-mocks.ts +++ b/extensions/memory-core/src/memory/embedding.test-mocks.ts @@ -26,6 +26,7 @@ export function resetEmbeddingMocks(): void { } vi.mock("./embeddings.js", () => ({ + resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, createEmbeddingProvider: async () => ({ requestedProvider: "openai", provider: { diff --git a/extensions/memory-core/src/memory/embeddings.ts b/extensions/memory-core/src/memory/embeddings.ts index bf1bb966bf1b..4f2d40627591 100644 --- a/extensions/memory-core/src/memory/embeddings.ts +++ b/extensions/memory-core/src/memory/embeddings.ts @@ -146,6 +146,17 @@ export function resolveEmbeddingProviderFallbackModel( return adapter?.defaultModel ?? fallbackSourceModel; } +export function resolveEmbeddingProviderAdapterId( + providerId: string, + config?: MemoryEmbeddingProviderCreateOptions["config"], +): string | undefined { + try { + return getAdapter(providerId, config).id; + } catch { + return undefined; + } +} + async function createWithAdapter( adapter: MemoryEmbeddingProviderAdapter, options: CreateEmbeddingProviderOptions, diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index c9143742a617..488531393adf 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -13,6 +13,7 @@ import "./test-runtime-mocks.js"; import type { MemoryIndexManager } from "./index.js"; import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js"; import { LOCAL_EMBEDDING_WORKER_ERROR_CODES } from "./manager-local-worker-errors.js"; +import type { MemoryIndexMeta } from "./manager-reindex-state.js"; import { closeMemoryIndexManagersForAgent, EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js"; import { DEFAULT_LOCAL_MODEL, @@ -58,6 +59,14 @@ vi.mock("./embeddings.js", () => { providerId === "gemini" || providerId === "fallback-provider" ? `${providerId}-embed` : fallbackSourceModel, + resolveEmbeddingProviderAdapterId: ( + providerId: string, + config?: { + models?: { + providers?: Record; + }; + }, + ) => config?.models?.providers?.[providerId]?.api ?? providerId, createEmbeddingProvider: async (options: { provider?: string; model?: string; @@ -77,7 +86,9 @@ vi.mock("./embeddings.js", () => { }; } const providerId = - options.provider === "gemini" || options.provider === "fallback-provider" + options.provider === "gemini" || + options.provider === "fallback-provider" || + options.provider === "ollama" ? options.provider : "mock"; const model = options.model ?? "mock-embed"; @@ -261,8 +272,9 @@ describe("memory index", () => { extraPaths?: string[]; sources?: Array<"memory" | "sessions">; sessionMemory?: boolean; - provider?: "openai" | "gemini" | "fallback-provider"; + provider?: string; fallback?: "none" | "gemini" | "fallback-provider"; + providerAliases?: NonNullable["providers"]>; model?: string; outputDimensionality?: number; multimodal?: { @@ -302,6 +314,7 @@ describe("memory index", () => { }, list: [{ id: "main", default: true }], }, + models: params.providerAliases ? { providers: params.providerAliases } : undefined, }; } @@ -323,9 +336,12 @@ describe("memory index", () => { return manager; } - async function getFreshManager(cfg: TestCfg): Promise { + async function getFreshManager( + cfg: TestCfg, + purpose?: "default" | "status" | "cli", + ): Promise { const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js"); - return await getRequiredMemoryIndexManager({ cfg, agentId: "main" }); + return await getRequiredMemoryIndexManager({ cfg, agentId: "main", purpose }); } async function expectHybridKeywordSearchFindsMemory(cfg: TestCfg) { @@ -389,6 +405,406 @@ describe("memory index", () => { } }); + it("does not full-reindex on search when existing metadata belongs to another provider", async () => { + const dbPath = path.join(workspaceDir, "index-provider-cutover.sqlite"); + const oldCfg = createCfg({ + storePath: dbPath, + model: "old-embed", + hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, + }); + const oldManager = await getFreshManager(oldCfg); + await oldManager.sync({ reason: "test", force: true }); + await oldManager.close?.(); + + const nextCfg = createCfg({ + storePath: dbPath, + provider: "gemini", + model: "new-embed", + hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, + }); + const nextManager = await getFreshManager(nextCfg); + try { + expect(nextManager.status().dirty).toBe(true); + expect(nextManager.status().custom?.indexIdentity).toEqual({ + status: "mismatched", + reason: "index was built for model old-embed, expected new-embed", + }); + embedBatchCalls = 0; + + const results = await nextManager.search("alpha"); + + expect(results).toStrictEqual([]); + expect(embedBatchCalls).toBe(0); + expect(nextManager.status().dirty).toBe(true); + + await fs.writeFile( + path.join(memoryDir, "2026-01-12.md"), + "# Log\nAlpha memory line changed.\nZebra memory line.", + ); + await nextManager.sync({ reason: "watch" }); + + expect(embedBatchCalls).toBe(0); + const stillPausedResults = await nextManager.search("alpha"); + expect(stillPausedResults).toStrictEqual([]); + expect(nextManager.status().dirty).toBe(true); + expect(nextManager.status().custom?.indexIdentity).toEqual({ + status: "mismatched", + reason: "index was built for model old-embed, expected new-embed", + }); + } finally { + await nextManager.close?.(); + } + }); + + it("keeps status clean when configured provider alias resolves to indexed adapter", async () => { + const dbPath = path.join(workspaceDir, "index-provider-alias-status.sqlite"); + const oldCfg = createCfg({ + storePath: dbPath, + provider: "ollama", + model: "ollama-embed", + hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, + }); + const oldManager = await getFreshManager(oldCfg); + await oldManager.sync({ reason: "test", force: true }); + await oldManager.close?.(); + + const aliasCfg = createCfg({ + storePath: dbPath, + provider: "ollama-west", + providerAliases: { + "ollama-west": { + api: "ollama", + baseUrl: "http://127.0.0.1:11434", + models: [], + }, + }, + model: "ollama-embed", + hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, + }); + const statusManager = await getFreshManager(aliasCfg, "status"); + try { + const status = statusManager.status(); + + expect(status.dirty).toBe(false); + expect(status.custom?.indexIdentity).toEqual({ status: "valid" }); + } finally { + await statusManager.close?.(); + } + }); + + it("does not search stale rows when index metadata is missing", async () => { + const dbPath = path.join(workspaceDir, "index-missing-meta-cutover.sqlite"); + const cfg = createCfg({ + storePath: dbPath, + hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, + }); + const oldManager = await getFreshManager(cfg); + await oldManager.sync({ reason: "test", force: true }); + await oldManager.close?.(); + await fs.rm(path.join(memoryDir, "2026-01-12.md")); + + const nextManager = await getFreshManager(cfg); + try { + ( + nextManager as unknown as { + db: { exec: (sql: string) => void }; + } + ).db.exec(`DELETE FROM meta WHERE key = 'memory_index_meta_v1'`); + expect(nextManager.status().custom?.indexIdentity).toEqual({ + status: "missing", + reason: "index metadata is missing", + }); + + const results = await nextManager.search("alpha"); + + expect(results).toStrictEqual([]); + expect(nextManager.status().dirty).toBe(true); + expect(nextManager.status().custom?.indexIdentity).toEqual({ + status: "missing", + reason: "index metadata is missing", + }); + } finally { + await nextManager.close?.(); + } + }); + + it("does not search stale provider rows after embeddings become unavailable", async () => { + const dbPath = path.join(workspaceDir, "index-provider-unavailable-cutover.sqlite"); + const oldCfg = createCfg({ + storePath: dbPath, + model: "semantic-embed", + hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, + }); + const oldManager = await getFreshManager(oldCfg); + await oldManager.sync({ reason: "test", force: true }); + await oldManager.close?.(); + + forceNoProvider = true; + const nextManager = await getFreshManager(oldCfg); + try { + const results = await nextManager.search("alpha"); + + expect(results).toStrictEqual([]); + expect(nextManager.status().dirty).toBe(true); + expect(nextManager.status().custom?.indexIdentity).toMatchObject({ + status: "mismatched", + }); + } finally { + await nextManager.close?.(); + } + }); + + it("clears dirty after sessions-only identity reindex", async () => { + try { + vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-only-reindex")); + const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile( + path.join(sessionsDir, "session-identity.jsonl"), + [ + JSON.stringify({ + type: "session", + id: "session-identity", + timestamp: "2026-04-07T15:24:04.113Z", + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + timestamp: "2026-04-07T15:25:04.113Z", + content: [{ type: "text", text: "Session-only identity marker." }], + }, + }), + ].join("\n") + "\n", + "utf8", + ); + + const dbPath = path.join(workspaceDir, "index-sessions-only-cutover.sqlite"); + const oldCfg = createCfg({ + storePath: dbPath, + sources: ["sessions"], + sessionMemory: true, + model: "old-embed", + }); + const oldManager = await getFreshManager(oldCfg); + await oldManager.sync({ reason: "test", force: true }); + await oldManager.close?.(); + + const nextCfg = createCfg({ + storePath: dbPath, + sources: ["sessions"], + sessionMemory: true, + provider: "gemini", + model: "new-embed", + }); + const nextManager = await getFreshManager(nextCfg); + try { + expect(nextManager.status().dirty).toBe(true); + + await nextManager.sync({ reason: "test", force: true }); + + expect(nextManager.status().dirty).toBe(false); + expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "valid" }); + } finally { + await nextManager.close?.(); + } + } finally { + vi.unstubAllEnvs(); + } + }); + + it("marks sessions-only indexes dirty when metadata is missing but chunks exist", async () => { + try { + vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-missing-meta")); + const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile( + path.join(sessionsDir, "session-missing-meta.jsonl"), + [ + JSON.stringify({ + type: "session", + id: "session-missing-meta", + timestamp: "2026-04-07T15:24:04.113Z", + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + timestamp: "2026-04-07T15:25:04.113Z", + content: [{ type: "text", text: "Sessions missing metadata marker." }], + }, + }), + ].join("\n") + "\n", + "utf8", + ); + + const dbPath = path.join(workspaceDir, "index-sessions-missing-meta.sqlite"); + const cfg = createCfg({ + storePath: dbPath, + sources: ["sessions"], + sessionMemory: true, + }); + const oldManager = await getFreshManager(cfg); + await oldManager.sync({ reason: "test", force: true }); + await oldManager.close?.(); + + const nextManager = await getFreshManager(cfg); + try { + ( + nextManager as unknown as { + db: { exec: (sql: string) => void }; + } + ).db.exec(`DELETE FROM meta WHERE key = 'memory_index_meta_v1'`); + + const status = nextManager.status(); + + expect(status.dirty).toBe(true); + expect(status.custom?.indexIdentity).toEqual({ + status: "missing", + reason: "index metadata is missing", + }); + } finally { + await nextManager.close?.(); + } + } finally { + vi.unstubAllEnvs(); + } + }); + + it("keeps provider cutover vector search paused during targeted session sync", async () => { + try { + vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-targeted-cutover")); + const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); + await fs.mkdir(sessionsDir, { recursive: true }); + const sessionFile = path.join(sessionsDir, "session-targeted-cutover.jsonl"); + await fs.writeFile( + sessionFile, + [ + JSON.stringify({ + type: "session", + id: "session-targeted-cutover", + timestamp: "2026-04-07T15:24:04.113Z", + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + timestamp: "2026-04-07T15:25:04.113Z", + content: [{ type: "text", text: "Targeted cutover marker." }], + }, + }), + ].join("\n") + "\n", + "utf8", + ); + + const dbPath = path.join(workspaceDir, "index-targeted-session-cutover.sqlite"); + const oldCfg = createCfg({ + storePath: dbPath, + sources: ["memory", "sessions"], + sessionMemory: true, + model: "old-embed", + }); + const oldManager = await getFreshManager(oldCfg); + await oldManager.sync({ reason: "test", force: true }); + await oldManager.close?.(); + + const nextCfg = createCfg({ + storePath: dbPath, + sources: ["memory", "sessions"], + sessionMemory: true, + provider: "gemini", + model: "new-embed", + }); + const nextManager = await getFreshManager(nextCfg); + try { + expect(nextManager.status().dirty).toBe(true); + embedBatchCalls = 0; + + await nextManager.sync({ reason: "test", sessionFiles: [sessionFile] }); + + expect(embedBatchCalls).toBe(0); + expect(nextManager.status().dirty).toBe(true); + expect(nextManager.status().custom?.indexIdentity).toEqual({ + status: "mismatched", + reason: "index was built for model old-embed, expected new-embed", + }); + const results = await nextManager.search("alpha"); + expect(results).toStrictEqual([]); + } finally { + await nextManager.close?.(); + } + } finally { + vi.unstubAllEnvs(); + } + }); + + it("preserves memory dirty events raised during session identity reindex", async () => { + try { + vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-dirty-during-session")); + const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile( + path.join(sessionsDir, "session-dirty-during-reindex.jsonl"), + [ + JSON.stringify({ + type: "session", + id: "session-dirty-during-reindex", + timestamp: "2026-04-07T15:24:04.113Z", + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + timestamp: "2026-04-07T15:25:04.113Z", + content: [{ type: "text", text: "Dirty during session marker." }], + }, + }), + ].join("\n") + "\n", + "utf8", + ); + + const dbPath = path.join(workspaceDir, "index-dirty-during-session.sqlite"); + const oldCfg = createCfg({ + storePath: dbPath, + sources: ["memory", "sessions"], + sessionMemory: true, + model: "old-embed", + }); + const oldManager = await getFreshManager(oldCfg); + await oldManager.sync({ reason: "test", force: true }); + await oldManager.close?.(); + + const nextCfg = createCfg({ + storePath: dbPath, + sources: ["memory", "sessions"], + sessionMemory: true, + provider: "gemini", + model: "new-embed", + }); + const nextManager = await getFreshManager(nextCfg); + try { + const fields = nextManager as unknown as { + dirty: boolean; + syncSessionFiles: (params: unknown) => Promise; + }; + const syncSessionFiles = fields.syncSessionFiles.bind(nextManager); + fields.syncSessionFiles = async (params) => { + fields.dirty = true; + await syncSessionFiles(params); + }; + + await nextManager.sync({ reason: "test", force: true }); + + expect(nextManager.status().dirty).toBe(true); + expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "valid" }); + } finally { + await nextManager.close?.(); + } + } finally { + vi.unstubAllEnvs(); + } + }); + it("closes embedding providers when memory index managers close", async () => { const cfg = createCfg({ storePath: indexMainPath, @@ -593,7 +1009,7 @@ describe("memory index", () => { waitForEmbeddingRetry: (delayMs: number, action: string) => Promise; } ).provider = { - id: "openai", + id: "mock", model: "mock-embed", embedQuery: async () => { queryCalls += 1; @@ -637,7 +1053,7 @@ describe("memory index", () => { }; } ).provider = { - id: "openai", + id: "mock", model: "mock-embed", embedQuery: async () => { queryCalls += 1; @@ -696,6 +1112,76 @@ describe("memory index", () => { expect(status.vector?.available).toBeUndefined(); }); + it("marks older vector indexes dirty after vector store probing", async () => { + const dbPath = path.join(workspaceDir, "index-vector-missing-dims.sqlite"); + const legacyCfg = createCfg({ + storePath: dbPath, + provider: "gemini", + vectorEnabled: false, + }); + const legacyManager = await getFreshManager(legacyCfg); + await legacyManager.sync({ reason: "test", force: true }); + await legacyManager.close?.(); + + const cfg = createCfg({ + storePath: dbPath, + provider: "gemini", + vectorEnabled: true, + }); + const manager = await getFreshManager(cfg); + try { + const metaAccess = manager as unknown as { + readMeta(): MemoryIndexMeta | null; + }; + const meta = metaAccess.readMeta(); + if (!meta) { + throw new Error("expected index metadata"); + } + expect(meta.vectorDims).toBeUndefined(); + + await manager.probeVectorStoreAvailability?.(); + const status = manager.status(); + + expect(status.dirty).toBe(true); + expect(status.custom?.indexIdentity).toEqual({ + status: "mismatched", + reason: "index vector dimensions are missing", + }); + } finally { + await manager.close?.(); + } + }); + + it("keeps empty vector indexes clean after vector store probing", async () => { + await fs.rm(path.join(memoryDir, "2026-01-12.md")); + const dbPath = path.join(workspaceDir, "index-empty-vector.sqlite"); + const legacyCfg = createCfg({ + storePath: dbPath, + provider: "gemini", + vectorEnabled: false, + }); + const legacyManager = await getFreshManager(legacyCfg); + await legacyManager.sync({ reason: "test", force: true }); + await legacyManager.close?.(); + + const cfg = createCfg({ + storePath: dbPath, + provider: "gemini", + vectorEnabled: true, + }); + const manager = await getFreshManager(cfg, "status"); + try { + await manager.probeVectorStoreAvailability?.(); + + const status = manager.status(); + + expect(status.dirty).toBe(false); + expect(status.custom?.indexIdentity).toEqual({ status: "valid" }); + } finally { + await manager.close?.(); + } + }); + it("caches embedding probe readiness across transient status managers", async () => { const cfg = createCfg({ storePath: path.join(workspaceDir, "index-probe-cache.sqlite") }); const first = requireManager( @@ -778,7 +1264,7 @@ describe("memory index", () => { }); }); - it("activates configured fallback when local embeddings degrade during search", async () => { + it("does not activate fallback during search when index identity is already mismatched", async () => { const cfg = createCfg({ storePath: path.join(workspaceDir, "index-search-degraded-fallback.sqlite"), fallback: "fallback-provider", @@ -810,21 +1296,68 @@ describe("memory index", () => { const results = await manager.search("alpha"); - expect(results.length).toBeGreaterThan(0); - const resultKeys = results.map( - (result) => `${result.source}:${result.path}:${result.startLine}:${result.endLine}`, - ); - expect(new Set(resultKeys).size).toBe(resultKeys.length); - expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain( - "fallback-provider", - ); + expect(results).toStrictEqual([]); + expect(providerCalls.slice(callsBeforeSearch)).toStrictEqual([]); expect( ( manager as unknown as { provider: { id: string } | null; } ).provider?.id, - ).toBe("fallback-provider"); + ).toBe("local"); + }); + + it("rebuilds with fallback provider during explicit identity repair", async () => { + const dbPath = path.join(workspaceDir, "index-cli-fallback-identity-repair.sqlite"); + const oldCfg = createCfg({ + storePath: dbPath, + model: "old-embed", + }); + const oldManager = await getFreshManager(oldCfg); + await oldManager.sync({ reason: "test", force: true }); + await oldManager.close?.(); + + const cfg = createCfg({ + storePath: dbPath, + model: "new-embed", + fallback: "fallback-provider", + }); + const manager = await getFreshManager(cfg); + try { + expect(manager.status().dirty).toBe(true); + const fields = manager as unknown as { + providerInitialized: boolean; + provider: { + id: string; + model: string; + embedQuery: (text: string) => Promise; + embedBatch: (texts: string[]) => Promise; + close: () => Promise; + }; + }; + fields.providerInitialized = true; + fields.provider = { + id: "mock", + model: "new-embed", + embedQuery: async () => { + throw createLocalWorkerExitError(); + }, + embedBatch: async () => { + throw createLocalWorkerExitError(); + }, + close: async () => {}, + }; + + await manager.sync({ reason: "cli" }); + + expect(manager.status().dirty).toBe(false); + expect(manager.status().provider).toBe("fallback-provider"); + expect(manager.status().model).toBe("fallback-provider-embed"); + expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" }); + await expect(manager.search("alpha")).resolves.not.toStrictEqual([]); + } finally { + await manager.close?.(); + } }); it("activates configured fallback after probe-time local degradation", async () => { @@ -866,7 +1399,7 @@ describe("memory index", () => { const results = await manager.search("alpha"); - expect(results.length).toBeGreaterThan(0); + expect(results).toStrictEqual([]); expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain( "fallback-provider", ); @@ -879,6 +1412,73 @@ describe("memory index", () => { ).toBe("fallback-provider"); }); + it("clears identity dirty after status resolves the indexed fallback provider", async () => { + const dbPath = path.join(workspaceDir, "index-status-fallback-identity.sqlite"); + const indexedCfg = createCfg({ + storePath: dbPath, + provider: "fallback-provider", + model: "new-embed", + }); + const indexedManager = await getFreshManager(indexedCfg); + await indexedManager.sync({ reason: "test", force: true }); + await indexedManager.close?.(); + + const cfg = createCfg({ + storePath: dbPath, + fallback: "fallback-provider", + model: "new-embed", + }); + const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js"); + const manager = await getRequiredMemoryIndexManager({ + cfg, + agentId: "main", + purpose: "status", + }); + try { + expect(manager.status().dirty).toBe(true); + + const fields = manager as unknown as { + provider: { + id: string; + model: string; + embedQuery: (text: string) => Promise; + embedBatch: (texts: string[]) => Promise; + close: () => Promise; + }; + providerInitialized: boolean; + providerRuntime: { + id: string; + cacheKeyData: Record; + }; + providerKey: string; + computeProviderKey: () => string; + }; + fields.provider = { + id: "fallback-provider", + model: "new-embed", + embedQuery: async () => [1, 0, 0, 0], + embedBatch: async (texts) => texts.map(() => [1, 0, 0, 0]), + close: async () => {}, + }; + fields.providerRuntime = { + id: "fallback-provider", + cacheKeyData: { + provider: "fallback-provider", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + model: "new-embed", + headers: [], + }, + }; + fields.providerInitialized = true; + fields.providerKey = fields.computeProviderKey(); + + expect(manager.status().dirty).toBe(false); + expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" }); + } finally { + await manager.close?.(); + } + }); + it("streams embedding cache rows during safe reindex", async () => { vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0"); type EmbeddingCacheRow = { diff --git a/extensions/memory-core/src/memory/manager-reindex-state.test.ts b/extensions/memory-core/src/memory/manager-reindex-state.test.ts index 68a83724288c..8ec142c5e4f7 100644 --- a/extensions/memory-core/src/memory/manager-reindex-state.test.ts +++ b/extensions/memory-core/src/memory/manager-reindex-state.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from "vitest"; import { resolveConfiguredScopeHash, resolveConfiguredSourcesForMeta, - shouldRunFullMemoryReindex, + resolveMemoryIndexIdentityState, + isMemoryIndexIdentityDirty, type MemoryIndexMeta, } from "./manager-reindex-state.js"; @@ -21,16 +22,18 @@ function createMeta(overrides: Partial = {}): MemoryIndexMeta { }; } -function createFullReindexParams( +function createIdentityParams( overrides: { meta?: MemoryIndexMeta | null; provider?: { id: string; model: string } | null; providerKey?: string; + providerKeyKnown?: boolean; configuredSources?: MemorySource[]; configuredScopeHash?: string; chunkTokens?: number; chunkOverlap?: number; vectorReady?: boolean; + hasIndexedChunks?: boolean; ftsTokenizer?: string; } = {}, ) { @@ -43,26 +46,41 @@ function createFullReindexParams( chunkTokens: 4000, chunkOverlap: 0, vectorReady: false, + hasIndexedChunks: true, ftsTokenizer: "unicode61", ...overrides, }; } describe("memory reindex state", () => { - it("requires a full reindex when the embedding model changes", () => { + it("marks identity dirty when the embedding model changes", () => { expect( - shouldRunFullMemoryReindex( - createFullReindexParams({ + isMemoryIndexIdentityDirty( + createIdentityParams({ provider: { id: "openai", model: "mock-embed-v2" }, }), ), ).toBe(true); }); - it("requires a full reindex when the provider cache key changes", () => { + it("returns a mismatch reason when provider identity changes", () => { expect( - shouldRunFullMemoryReindex( - createFullReindexParams({ + resolveMemoryIndexIdentityState( + createIdentityParams({ + provider: { id: "ollama", model: "mock-embed-v1" }, + providerKey: "provider-key-ollama", + }), + ), + ).toEqual({ + status: "mismatched", + reason: "index was built for provider openai, expected ollama", + }); + }); + + it("marks identity dirty when the provider cache key changes", () => { + expect( + isMemoryIndexIdentityDirty( + createIdentityParams({ provider: { id: "gemini", model: "gemini-embedding-2-preview" }, providerKey: "provider-key-dims-768", meta: createMeta({ @@ -75,7 +93,30 @@ describe("memory reindex state", () => { ).toBe(true); }); - it("requires a full reindex when extraPaths change", () => { + it("can defer provider key comparison until provider initialization", () => { + expect( + resolveMemoryIndexIdentityState( + createIdentityParams({ + providerKey: undefined, + providerKeyKnown: false, + }), + ), + ).toEqual({ status: "valid" }); + }); + + it("does not mark identity dirty for vector dimensions before chunks exist", () => { + expect( + resolveMemoryIndexIdentityState( + createIdentityParams({ + vectorReady: true, + hasIndexedChunks: false, + meta: createMeta({ vectorDims: undefined }), + }), + ), + ).toEqual({ status: "valid" }); + }); + + it("marks identity dirty when extraPaths change", () => { const workspaceDir = "/tmp/workspace"; const firstScopeHash = resolveConfiguredScopeHash({ workspaceDir, @@ -97,8 +138,8 @@ describe("memory reindex state", () => { }); expect( - shouldRunFullMemoryReindex( - createFullReindexParams({ + isMemoryIndexIdentityDirty( + createIdentityParams({ meta: createMeta({ scopeHash: firstScopeHash }), configuredScopeHash: secondScopeHash, }), @@ -106,17 +147,17 @@ describe("memory reindex state", () => { ).toBe(true); }); - it("requires a full reindex when configured sources add sessions", () => { + it("marks identity dirty when configured sources add sessions", () => { expect( - shouldRunFullMemoryReindex( - createFullReindexParams({ + isMemoryIndexIdentityDirty( + createIdentityParams({ configuredSources: ["memory", "sessions"], }), ), ).toBe(true); }); - it("requires a full reindex when multimodal settings change", () => { + it("marks identity dirty when multimodal settings change", () => { const workspaceDir = "/tmp/workspace"; const firstScopeHash = resolveConfiguredScopeHash({ workspaceDir, @@ -138,8 +179,8 @@ describe("memory reindex state", () => { }); expect( - shouldRunFullMemoryReindex( - createFullReindexParams({ + isMemoryIndexIdentityDirty( + createIdentityParams({ meta: createMeta({ scopeHash: firstScopeHash }), configuredScopeHash: secondScopeHash, }), @@ -149,8 +190,8 @@ describe("memory reindex state", () => { it("keeps older indexes with missing sources compatible with memory-only config", () => { expect( - shouldRunFullMemoryReindex( - createFullReindexParams({ + isMemoryIndexIdentityDirty( + createIdentityParams({ meta: createMeta({ sources: undefined }), configuredSources: resolveConfiguredSourcesForMeta(new Set(["memory"])), }), diff --git a/extensions/memory-core/src/memory/manager-reindex-state.ts b/extensions/memory-core/src/memory/manager-reindex-state.ts index 2e92786119cb..5da56f5c8a69 100644 --- a/extensions/memory-core/src/memory/manager-reindex-state.ts +++ b/extensions/memory-core/src/memory/manager-reindex-state.ts @@ -16,6 +16,19 @@ export type MemoryIndexMeta = { ftsTokenizer?: string; }; +export type MemoryIndexIdentityState = + | { + status: "valid"; + } + | { + status: "missing"; + reason: string; + } + | { + status: "mismatched"; + reason: string; + }; + export function resolveConfiguredSourcesForMeta(sources: Iterable): MemorySource[] { const normalized = Array.from(sources) .filter((source): source is MemorySource => source === "memory" || source === "sessions") @@ -73,31 +86,93 @@ export function resolveConfiguredScopeHash(params: { ); } -export function shouldRunFullMemoryReindex(params: { +export function isMemoryIndexIdentityDirty(params: { meta: MemoryIndexMeta | null; provider: { id: string; model: string } | null; providerKey?: string; + providerKeyKnown?: boolean; configuredSources: MemorySource[]; configuredScopeHash: string; chunkTokens: number; chunkOverlap: number; vectorReady: boolean; + hasIndexedChunks?: boolean; ftsTokenizer: string; }): boolean { + return resolveMemoryIndexIdentityState(params).status !== "valid"; +} + +export function resolveMemoryIndexIdentityState(params: { + meta: MemoryIndexMeta | null; + provider: { id: string; model: string } | null; + providerKey?: string; + providerKeyKnown?: boolean; + configuredSources: MemorySource[]; + configuredScopeHash: string; + chunkTokens: number; + chunkOverlap: number; + vectorReady: boolean; + hasIndexedChunks?: boolean; + ftsTokenizer: string; +}): MemoryIndexIdentityState { const { meta } = params; - return ( - !meta || - (params.provider ? meta.model !== params.provider.model : meta.model !== "fts-only") || - (params.provider ? meta.provider !== params.provider.id : meta.provider !== "none") || - meta.providerKey !== params.providerKey || + if (!meta) { + return { status: "missing", reason: "index metadata is missing" }; + } + const expectedModel = params.provider ? params.provider.model : "fts-only"; + if (meta.model !== expectedModel) { + return { + status: "mismatched", + reason: `index was built for model ${meta.model}, expected ${expectedModel}`, + }; + } + const expectedProvider = params.provider ? params.provider.id : "none"; + if (meta.provider !== expectedProvider) { + return { + status: "mismatched", + reason: `index was built for provider ${meta.provider}, expected ${expectedProvider}`, + }; + } + if (params.providerKeyKnown !== false && meta.providerKey !== params.providerKey) { + return { + status: "mismatched", + reason: "index provider settings changed", + }; + } + if ( configuredMetaSourcesDiffer({ meta, configuredSources: params.configuredSources, - }) || - meta.scopeHash !== params.configuredScopeHash || - meta.chunkTokens !== params.chunkTokens || - meta.chunkOverlap !== params.chunkOverlap || - (params.vectorReady && !meta.vectorDims) || - (meta.ftsTokenizer ?? "unicode61") !== params.ftsTokenizer - ); + }) + ) { + return { + status: "mismatched", + reason: "index sources changed", + }; + } + if (meta.scopeHash !== params.configuredScopeHash) { + return { + status: "mismatched", + reason: "index scope changed", + }; + } + if (meta.chunkTokens !== params.chunkTokens || meta.chunkOverlap !== params.chunkOverlap) { + return { + status: "mismatched", + reason: "index chunking changed", + }; + } + if (params.vectorReady && params.hasIndexedChunks !== false && !meta.vectorDims) { + return { + status: "mismatched", + reason: "index vector dimensions are missing", + }; + } + if ((meta.ftsTokenizer ?? "unicode61") !== params.ftsTokenizer) { + return { + status: "mismatched", + reason: "index FTS tokenizer changed", + }; + } + return { status: "valid" }; } diff --git a/extensions/memory-core/src/memory/manager-search.test.ts b/extensions/memory-core/src/memory/manager-search.test.ts index 6ec768c64a11..be4cdedbf3ab 100644 --- a/extensions/memory-core/src/memory/manager-search.test.ts +++ b/extensions/memory-core/src/memory/manager-search.test.ts @@ -573,7 +573,11 @@ describe("searchVector sqlite-vec KNN", () => { function insertFallbackChunk( db: InstanceType, - params: { id: string; model: string; vector: number[] }, + params: { + id: string; + model: string; + vector: number[]; + }, ): void { db.prepare( "INSERT INTO chunks (id, path, source, start_line, end_line, hash, model, text, embedding, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", diff --git a/extensions/memory-core/src/memory/manager-status-state.test.ts b/extensions/memory-core/src/memory/manager-status-state.test.ts index 6dde45acecc4..1f5a8b3339b9 100644 --- a/extensions/memory-core/src/memory/manager-status-state.test.ts +++ b/extensions/memory-core/src/memory/manager-status-state.test.ts @@ -28,6 +28,17 @@ describe("memory manager status state", () => { ).toBe(true); }); + it("marks status-only managers dirty when index identity mismatches", () => { + expect( + resolveInitialMemoryDirty({ + hasMemorySource: false, + statusOnly: true, + hasIndexedMeta: true, + indexIdentityMismatched: true, + }), + ).toBe(true); + }); + it("reports the requested provider before provider initialization", () => { expect( resolveStatusProviderInfo({ diff --git a/extensions/memory-core/src/memory/manager-status-state.ts b/extensions/memory-core/src/memory/manager-status-state.ts index 217a3fb18719..9f2fd0241390 100644 --- a/extensions/memory-core/src/memory/manager-status-state.ts +++ b/extensions/memory-core/src/memory/manager-status-state.ts @@ -27,8 +27,12 @@ export function resolveInitialMemoryDirty(params: { hasMemorySource: boolean; statusOnly: boolean; hasIndexedMeta: boolean; + indexIdentityMismatched?: boolean; }): boolean { - return params.hasMemorySource && (params.statusOnly ? !params.hasIndexedMeta : true); + return ( + Boolean(params.indexIdentityMismatched) || + (params.hasMemorySource && (params.statusOnly ? !params.hasIndexedMeta : true)) + ); } export function resolveStatusProviderInfo(params: { diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index 855c3d5133e7..ead4abe54a25 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -38,6 +38,7 @@ import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createEmbeddingProvider, + resolveEmbeddingProviderAdapterId, type EmbeddingProvider, type EmbeddingProviderId, type EmbeddingProviderRuntime, @@ -54,8 +55,9 @@ import { import { resolveConfiguredScopeHash, resolveConfiguredSourcesForMeta, - shouldRunFullMemoryReindex, + resolveMemoryIndexIdentityState, type MemoryIndexMeta, + type MemoryIndexIdentityState, } from "./manager-reindex-state.js"; import { shouldSyncSessionsForReindex } from "./manager-session-reindex.js"; import { @@ -67,7 +69,10 @@ import { loadMemorySourceFileState, resolveMemorySourceExistingHash, } from "./manager-source-state.js"; -import { runMemoryTargetedSessionSync } from "./manager-targeted-sync.js"; +import { + markMemoryTargetSessionFilesDirty, + runMemoryTargetedSessionSync, +} from "./manager-targeted-sync.js"; import { recordMemoryWatchEventPath, settleMemoryWatchEventPaths, @@ -269,6 +274,65 @@ export abstract class MemoryManagerSyncOps { options: { source: MemorySource; content?: string }, ): Promise; + protected hasIndexedChunks(): boolean { + const row = this.db.prepare(`SELECT 1 as found FROM chunks LIMIT 1`).get() as + | { found?: number } + | undefined; + return row?.found === 1; + } + + protected resolveCurrentIndexIdentityState(params?: { + meta?: MemoryIndexMeta | null; + provider?: { id: string; model: string } | null; + providerKeyKnown?: boolean; + vectorReady?: boolean; + hasIndexedChunks?: boolean; + }): MemoryIndexIdentityState { + const hasProviderOverride = params && "provider" in params; + const configuredProvider = + this.settings.provider === "none" + ? null + : { + id: + resolveEmbeddingProviderAdapterId(this.settings.provider, this.cfg) ?? + this.settings.provider, + model: this.settings.model, + }; + const provider = hasProviderOverride + ? params.provider! + : this.provider + ? { id: this.provider.id, model: this.provider.model } + : configuredProvider; + const vectorReady = + params && "vectorReady" in params + ? Boolean(params.vectorReady) + : this.vector.available === true; + return resolveMemoryIndexIdentityState({ + meta: params && "meta" in params ? params.meta! : this.readMeta(), + provider, + providerKey: params?.providerKeyKnown === false ? undefined : (this.providerKey ?? undefined), + providerKeyKnown: params?.providerKeyKnown, + configuredSources: resolveConfiguredSourcesForMeta(this.sources), + configuredScopeHash: resolveConfiguredScopeHash({ + workspaceDir: this.workspaceDir, + extraPaths: this.settings.extraPaths, + multimodal: { + enabled: this.settings.multimodal.enabled, + modalities: this.settings.multimodal.modalities, + maxFileBytes: this.settings.multimodal.maxFileBytes, + }, + }), + chunkTokens: this.settings.chunking.tokens, + chunkOverlap: this.settings.chunking.overlap, + vectorReady, + hasIndexedChunks: + params && "hasIndexedChunks" in params + ? Boolean(params.hasIndexedChunks) + : this.hasIndexedChunks(), + ftsTokenizer: this.settings.store.fts.tokenizer, + }); + } + protected resetVectorState(): void { this.vectorReady = null; this.vector.available = null; @@ -1691,60 +1755,69 @@ export abstract class MemoryManagerSyncOps { } const vectorReady = await this.ensureVectorReady(); const meta = this.readMeta(); - const configuredSources = resolveConfiguredSourcesForMeta(this.sources); - const configuredScopeHash = resolveConfiguredScopeHash({ - workspaceDir: this.workspaceDir, - extraPaths: this.settings.extraPaths, - multimodal: { - enabled: this.settings.multimodal.enabled, - modalities: this.settings.multimodal.modalities, - maxFileBytes: this.settings.multimodal.maxFileBytes, - }, - }); const targetSessionFiles = this.normalizeTargetSessionFiles(params?.sessionFiles); const hasTargetSessionFiles = targetSessionFiles !== null; if (params?.reason === "cli" && !params.force && !hasTargetSessionFiles) { await this.markSessionStartupCatchupDirtyFiles(); } - const targetedSessionSync = await runMemoryTargetedSessionSync({ - hasSessionSource: this.sources.has("sessions"), - targetSessionFiles, - reason: params?.reason, - progress: progress ?? undefined, - useUnsafeReindex: - process.env.OPENCLAW_TEST_FAST === "1" && - process.env.OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX === "1", - sessionsDirtyFiles: this.sessionsDirtyFiles, - syncSessionFiles: async (targetedParams) => { - await this.syncSessionFiles(targetedParams); - }, - shouldFallbackOnError: (err) => this.shouldFallbackOnError(err), - activateFallbackProvider: async (reason) => await this.activateFallbackProvider(reason), - runSafeReindex: async (reindexParams) => { - await this.runSafeReindex(reindexParams); - }, - runUnsafeReindex: async (reindexParams) => { - await this.runUnsafeReindex(reindexParams); - }, + const indexIdentity = resolveMemoryIndexIdentityState({ + meta, + // Also detects provider→FTS-only transitions so orphaned old-model FTS rows are cleaned up. + provider: this.provider ? { id: this.provider.id, model: this.provider.model } : null, + providerKey: this.providerKey ?? undefined, + configuredSources: resolveConfiguredSourcesForMeta(this.sources), + configuredScopeHash: resolveConfiguredScopeHash({ + workspaceDir: this.workspaceDir, + extraPaths: this.settings.extraPaths, + multimodal: { + enabled: this.settings.multimodal.enabled, + modalities: this.settings.multimodal.modalities, + maxFileBytes: this.settings.multimodal.maxFileBytes, + }, + }), + chunkTokens: this.settings.chunking.tokens, + chunkOverlap: this.settings.chunking.overlap, + vectorReady, + hasIndexedChunks: this.hasIndexedChunks(), + ftsTokenizer: this.settings.store.fts.tokenizer, }); - if (targetedSessionSync.handled) { - this.sessionsDirty = targetedSessionSync.sessionsDirty; - return; - } + const hasIndexedChunks = this.hasIndexedChunks(); + const needsInitialIndex = indexIdentity.status !== "valid" && !hasIndexedChunks; + const needsExplicitIdentityReindex = + params?.reason === "cli" && indexIdentity.status !== "valid" && !hasTargetSessionFiles; const needsFullReindex = (params?.force && !hasTargetSessionFiles) || - shouldRunFullMemoryReindex({ - meta, - // Also detects provider→FTS-only transitions so orphaned old-model FTS rows are cleaned up. - provider: this.provider ? { id: this.provider.id, model: this.provider.model } : null, - providerKey: this.providerKey ?? undefined, - configuredSources, - configuredScopeHash, - chunkTokens: this.settings.chunking.tokens, - chunkOverlap: this.settings.chunking.overlap, - vectorReady, - ftsTokenizer: this.settings.store.fts.tokenizer, + needsInitialIndex || + needsExplicitIdentityReindex; + if (indexIdentity.status !== "valid" && !needsFullReindex) { + this.dirty = true; + const sessionsDirty = markMemoryTargetSessionFilesDirty({ + sessionsDirtyFiles: this.sessionsDirtyFiles, + targetSessionFiles, }); + if (sessionsDirty) { + this.sessionsDirty = true; + } + return; + } + if (!needsFullReindex) { + const targetedSessionSync = await runMemoryTargetedSessionSync({ + hasSessionSource: this.sources.has("sessions"), + targetSessionFiles, + reason: params?.reason, + progress: progress ?? undefined, + sessionsDirtyFiles: this.sessionsDirtyFiles, + syncSessionFiles: async (targetedParams) => { + await this.syncSessionFiles(targetedParams); + }, + shouldFallbackOnError: (err) => this.shouldFallbackOnError(err), + activateFallbackProvider: async (reason) => await this.activateFallbackProvider(reason), + }); + if (targetedSessionSync.handled) { + this.sessionsDirty = targetedSessionSync.sessionsDirty; + return; + } + } try { if (needsFullReindex) { if ( @@ -1794,20 +1867,17 @@ export abstract class MemoryManagerSyncOps { const activated = this.shouldFallbackOnError(err) && (await this.activateFallbackProvider(reason)); if (activated) { - await this.runSafeReindex({ - reason: params?.reason ?? "fallback", - force: true, - progress: progress ?? undefined, - }); + if (needsFullReindex && !hasTargetSessionFiles) { + await this.runSafeReindex({ + reason: params?.reason ?? "fallback", + force: true, + progress: progress ?? undefined, + }); + } return; } if (!this.provider && this.fts.enabled && this.shouldFallbackOnError(err)) { - log.warn(`memory embeddings unavailable; rebuilding lexical memory index only: ${reason}`); - await this.runSafeReindex({ - reason: params?.reason ?? "embedding-degraded", - force: true, - progress: progress ?? undefined, - }); + log.warn(`memory embeddings unavailable; leaving memory index dirty: ${reason}`); return; } throw err; @@ -1965,6 +2035,9 @@ export abstract class MemoryManagerSyncOps { } else { this.sessionsDirty = false; } + if (!shouldSyncMemory) { + this.dirty = false; + } const meta: MemoryIndexMeta = { model: this.provider?.model ?? "fts-only", @@ -2045,6 +2118,9 @@ export abstract class MemoryManagerSyncOps { } else { this.sessionsDirty = false; } + if (!shouldSyncMemory) { + this.dirty = false; + } const nextMeta: MemoryIndexMeta = { model: this.provider?.model ?? "fts-only", diff --git a/extensions/memory-core/src/memory/manager-sync-yield.test.ts b/extensions/memory-core/src/memory/manager-sync-yield.test.ts index 833ec3cb2445..8df997bd44b9 100644 --- a/extensions/memory-core/src/memory/manager-sync-yield.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-yield.test.ts @@ -38,6 +38,7 @@ vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => { }); vi.mock("./embeddings.js", () => ({ + resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, createEmbeddingProvider: vi.fn(), })); diff --git a/extensions/memory-core/src/memory/manager-targeted-sync.test.ts b/extensions/memory-core/src/memory/manager-targeted-sync.test.ts index ecd545bb90ab..2f1b3bbfcb4e 100644 --- a/extensions/memory-core/src/memory/manager-targeted-sync.test.ts +++ b/extensions/memory-core/src/memory/manager-targeted-sync.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { clearMemorySyncedSessionFiles, + markMemoryTargetSessionFilesDirty, runMemoryTargetedSessionSync, } from "./manager-targeted-sync.js"; @@ -18,61 +19,48 @@ describe("memory targeted session sync", () => { expect(sessionsDirty).toBe(true); }); - it("runs a full reindex after fallback activates during targeted sync", async () => { - const activateFallbackProvider = vi.fn(async () => true); - const runSafeReindex = vi.fn(async () => {}); - const runUnsafeReindex = vi.fn(async () => {}); + it("marks target sessions dirty while identity sync is paused", () => { + const targetSessionPath = "/tmp/paused-target.jsonl"; + const sessionsDirtyFiles = new Set(["/tmp/other-dirty.jsonl"]); - await runMemoryTargetedSessionSync({ + const sessionsDirty = markMemoryTargetSessionFilesDirty({ + sessionsDirtyFiles, + targetSessionFiles: [targetSessionPath], + }); + + expect(sessionsDirty).toBe(true); + expect(sessionsDirtyFiles.has(targetSessionPath)).toBe(true); + expect(sessionsDirtyFiles.has("/tmp/other-dirty.jsonl")).toBe(true); + }); + + it("leaves targeted sessions dirty after fallback activates during targeted sync", async () => { + const activateFallbackProvider = vi.fn(async () => true); + const syncSessionFiles = vi + .fn() + .mockRejectedValueOnce(new Error("embedding backend failed")) + .mockResolvedValueOnce(undefined); + const sessionsDirtyFiles = new Set(["/tmp/targeted-fallback.jsonl", "/tmp/other-dirty.jsonl"]); + + const result = await runMemoryTargetedSessionSync({ hasSessionSource: true, targetSessionFiles: new Set(["/tmp/targeted-fallback.jsonl"]), reason: "post-compaction", progress: undefined, - useUnsafeReindex: false, - sessionsDirtyFiles: new Set(), - syncSessionFiles: async () => { - throw new Error("embedding backend failed"); - }, + sessionsDirtyFiles, + syncSessionFiles, shouldFallbackOnError: () => true, activateFallbackProvider, - runSafeReindex, - runUnsafeReindex, }); expect(activateFallbackProvider).toHaveBeenCalledWith("embedding backend failed"); - expect(runSafeReindex).toHaveBeenCalledWith({ - reason: "post-compaction", - force: true, + expect(syncSessionFiles).toHaveBeenCalledTimes(1); + expect(syncSessionFiles).toHaveBeenCalledWith({ + needsFullReindex: false, + targetSessionFiles: ["/tmp/targeted-fallback.jsonl"], progress: undefined, }); - expect(runUnsafeReindex).not.toHaveBeenCalled(); - }); - - it("uses the unsafe reindex path when enabled", async () => { - const runSafeReindex = vi.fn(async () => {}); - const runUnsafeReindex = vi.fn(async () => {}); - - await runMemoryTargetedSessionSync({ - hasSessionSource: true, - targetSessionFiles: new Set(["/tmp/targeted-fallback.jsonl"]), - reason: "post-compaction", - progress: undefined, - useUnsafeReindex: true, - sessionsDirtyFiles: new Set(), - syncSessionFiles: async () => { - throw new Error("embedding backend failed"); - }, - shouldFallbackOnError: () => true, - activateFallbackProvider: async () => true, - runSafeReindex, - runUnsafeReindex, - }); - - expect(runUnsafeReindex).toHaveBeenCalledWith({ - reason: "post-compaction", - force: true, - progress: undefined, - }); - expect(runSafeReindex).not.toHaveBeenCalled(); + expect(result).toEqual({ handled: true, sessionsDirty: true }); + expect(sessionsDirtyFiles.has("/tmp/targeted-fallback.jsonl")).toBe(true); + expect(sessionsDirtyFiles.has("/tmp/other-dirty.jsonl")).toBe(true); }); }); diff --git a/extensions/memory-core/src/memory/manager-targeted-sync.ts b/extensions/memory-core/src/memory/manager-targeted-sync.ts index 0074b6198989..a4e4bb1c1e6b 100644 --- a/extensions/memory-core/src/memory/manager-targeted-sync.ts +++ b/extensions/memory-core/src/memory/manager-targeted-sync.ts @@ -22,12 +22,23 @@ export function clearMemorySyncedSessionFiles(params: { return params.sessionsDirtyFiles.size > 0; } +export function markMemoryTargetSessionFilesDirty(params: { + sessionsDirtyFiles: Set; + targetSessionFiles?: Iterable | null; +}): boolean { + if (params.targetSessionFiles) { + for (const targetSessionFile of params.targetSessionFiles) { + params.sessionsDirtyFiles.add(targetSessionFile); + } + } + return params.sessionsDirtyFiles.size > 0; +} + export async function runMemoryTargetedSessionSync(params: { hasSessionSource: boolean; targetSessionFiles: Set | null; reason?: string; progress?: TargetedSyncProgress; - useUnsafeReindex: boolean; sessionsDirtyFiles: Set; syncSessionFiles: (params: { needsFullReindex: boolean; @@ -36,16 +47,6 @@ export async function runMemoryTargetedSessionSync(params: { }) => Promise; shouldFallbackOnError: (err: unknown) => boolean; activateFallbackProvider: (reason: string) => Promise; - runSafeReindex: (params: { - reason?: string; - force?: boolean; - progress?: TargetedSyncProgress; - }) => Promise; - runUnsafeReindex: (params: { - reason?: string; - force?: boolean; - progress?: TargetedSyncProgress; - }) => Promise; }): Promise<{ handled: boolean; sessionsDirty: boolean }> { if (!params.hasSessionSource || !params.targetSessionFiles) { return { @@ -74,19 +75,12 @@ export async function runMemoryTargetedSessionSync(params: { if (!activated) { throw err; } - const reindexParams = { - reason: params.reason, - force: true, - progress: params.progress, - }; - if (params.useUnsafeReindex) { - await params.runUnsafeReindex(reindexParams); - } else { - await params.runSafeReindex(reindexParams); - } return { handled: true, - sessionsDirty: params.sessionsDirtyFiles.size > 0, + sessionsDirty: markMemoryTargetSessionFilesDirty({ + sessionsDirtyFiles: params.sessionsDirtyFiles, + targetSessionFiles: params.targetSessionFiles, + }), }; } } diff --git a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts index 5f640f58a527..17e3998e5e53 100644 --- a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts +++ b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts @@ -15,6 +15,7 @@ vi.mock("./embeddings.js", () => ({ provider: null, providerUnavailableReason: "No embeddings provider available.", }), + resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, resolveEmbeddingProviderFallbackModel: () => "fts-only", })); diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 1b8402e551b3..86ec3c879868 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -47,6 +47,7 @@ import { resolveMemoryProviderState, type MemoryProviderLifecycleState, } from "./manager-provider-state.js"; +import type { MemoryIndexIdentityState } from "./manager-reindex-state.js"; import { resolveMemorySearchPreflight } from "./manager-search-preflight.js"; import { searchKeyword, searchVector } from "./manager-search.js"; import { @@ -171,6 +172,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem protected override sessionsDirty = false; protected override sessionsDirtyFiles = new Set(); protected override sessionPendingFiles = new Set(); + private indexIdentityDirty = false; protected override sessionDeltas = new Map< string, { lastSize: number; pendingBytes: number; pendingMessages: number } @@ -183,6 +185,10 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem private readonlyRecoverySuccesses = 0; private readonlyRecoveryFailures = 0; private readonlyRecoveryLastError?: string; + private indexIdentityState: MemoryIndexIdentityState = { + status: "missing", + reason: "index metadata is missing", + }; private static async loadProviderResult(params: { cfg: OpenClawConfig; @@ -267,6 +273,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem if (meta?.vectorDims) { this.vector.dims = meta.vectorDims; } + const initialIndexIdentity = this.resolveCurrentIndexIdentityState({ + meta, + providerKeyKnown: Boolean(params.providerResult), + }); + this.indexIdentityState = initialIndexIdentity; + this.indexIdentityDirty = + initialIndexIdentity.status === "mismatched" || + (initialIndexIdentity.status === "missing" && this.sources.has("memory")); const transient = params.purpose === "status" || params.purpose === "cli"; if (!transient) { this.ensureWatcher(); @@ -377,6 +391,23 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem } } + private refreshIndexIdentityDirty(params?: { providerKeyKnown?: boolean }) { + const provider = this.providerInitialized + ? this.provider + ? { id: this.provider.id, model: this.provider.model } + : null + : undefined; + const state = this.resolveCurrentIndexIdentityState({ + ...(provider !== undefined ? { provider } : {}), + providerKeyKnown: params?.providerKeyKnown, + }); + this.indexIdentityState = state; + this.indexIdentityDirty = + state.status === "mismatched" || + (state.status === "missing" && (this.sources.has("memory") || this.hasIndexedChunks())); + return state; + } + async search( query: string, opts?: { @@ -423,6 +454,27 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem if (preflight.shouldInitializeProvider) { await this.ensureProviderInitialized(); } + if (!this.provider && this.providerLifecycle.mode === "degraded") { + const activatedFallback = await this.activateFallbackProvider( + this.providerLifecycle.reason, + ).catch((fallbackErr: unknown) => { + log.warn( + `memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`, + ); + return false; + }); + if (activatedFallback) { + this.refreshIndexIdentityDirty({ + providerKeyKnown: this.providerInitialized, + }); + } + } + const indexIdentity = this.refreshIndexIdentityDirty({ + providerKeyKnown: this.providerInitialized, + }); + if (indexIdentity.status !== "valid") { + return []; + } const minScore = opts?.minScore ?? this.settings.query.minScore; const maxResults = opts?.maxResults ?? this.settings.query.maxResults; const searchSources = @@ -443,20 +495,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem Math.max(1, Math.floor(maxResults * hybrid.candidateMultiplier)), ); - if (!this.provider && this.providerLifecycle.mode === "degraded") { - const activatedFallback = await this.activateFallbackProvider( - this.providerLifecycle.reason, - ).catch((fallbackErr: unknown) => { - log.warn( - `memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`, - ); - return false; - }); - if (activatedFallback) { - await this.runSafeReindex({ reason: "fallback", force: true }); - } - } - // FTS-only mode: no embedding provider available if (!this.provider) { if (!this.fts.enabled || !this.fts.available) { @@ -552,7 +590,13 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem }) : false; if (activatedFallback) { - await this.runSafeReindex({ reason: "fallback", force: true }); + if ( + this.refreshIndexIdentityDirty({ + providerKeyKnown: this.providerInitialized, + }).status !== "valid" + ) { + return []; + } keywordResults = await loadKeywordResults(); queryVec = await this.embedQueryWithRetry(cleaned); } else if (!this.provider && this.fts.enabled && this.fts.available) { @@ -856,6 +900,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem } status(): MemoryProviderStatus { + this.refreshIndexIdentityDirty({ + providerKeyKnown: this.providerInitialized, + }); const sourceFilter = this.buildSourceFilter(); const aggregateState = collectMemoryStatusAggregate({ db: { @@ -884,7 +931,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem backend: "builtin", files: aggregateState.files, chunks: aggregateState.chunks, - dirty: this.dirty || this.sessionsDirty, + dirty: this.dirty || this.sessionsDirty || this.indexIdentityDirty, workspaceDir: this.workspaceDir, dbPath: this.settings.store.path, provider: providerInfo.provider, @@ -937,6 +984,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem searchMode: providerInfo.searchMode, providerState: this.providerLifecycle, providerUnavailableReason: this.providerUnavailableReason, + indexIdentity: this.indexIdentityState, readonlyRecovery: { attempts: this.readonlyRecoveryAttempts, successes: this.readonlyRecoverySuccesses, diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index b33aba6277a4..90a5191370ab 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -126,6 +126,7 @@ vi.mock("./sqlite-vec.js", () => ({ })); vi.mock("./embeddings.js", () => ({ + resolveEmbeddingProviderAdapterId: (providerId: string) => providerId, createEmbeddingProvider: async () => ({ requestedProvider: "openai", provider: { diff --git a/extensions/memory-core/src/tools.shared.ts b/extensions/memory-core/src/tools.shared.ts index 124913b96bef..3362ec999112 100644 --- a/extensions/memory-core/src/tools.shared.ts +++ b/extensions/memory-core/src/tools.shared.ts @@ -117,15 +117,25 @@ export function createMemoryTool(params: { }; } -export function buildMemorySearchUnavailableResult(error: string | undefined) { +export function buildMemorySearchUnavailableResult( + error: string | undefined, + overrides?: { + warning?: string; + action?: string; + }, +) { const reason = (error ?? "memory search unavailable").trim() || "memory search unavailable"; const isQuotaError = /insufficient_quota|quota|429/.test(normalizeLowercaseStringOrEmpty(reason)); - const warning = isQuotaError - ? "Memory search is unavailable because the embedding provider quota is exhausted." - : "Memory search is unavailable due to an embedding/provider error."; - const action = isQuotaError - ? "Top up or switch embedding provider, then retry memory_search." - : "Check embedding provider configuration and retry memory_search."; + const warning = + overrides?.warning ?? + (isQuotaError + ? "Memory search is unavailable because the embedding provider quota is exhausted." + : "Memory search is unavailable due to an embedding/provider error."); + const action = + overrides?.action ?? + (isQuotaError + ? "Top up or switch embedding provider, then retry memory_search." + : "Check embedding provider configuration and retry memory_search."); return { results: [], disabled: true, diff --git a/extensions/memory-core/src/tools.test.ts b/extensions/memory-core/src/tools.test.ts index d4c2df9d7ceb..1a3345d9c73c 100644 --- a/extensions/memory-core/src/tools.test.ts +++ b/extensions/memory-core/src/tools.test.ts @@ -3,8 +3,10 @@ import { getMemorySearchManagerMockCalls, getMemorySearchManagerMockConfigs, getMemorySearchManagerMockParams, + getMemorySyncMockCalls, resetMemoryToolMockState, setMemoryBackend, + setMemoryCustomStatus, setMemorySearchImpl, setMemorySearchManagerImpl, } from "./memory-tool-manager-mock.js"; @@ -256,6 +258,39 @@ describe("memory_search unavailable payloads", () => { expect(searchCalls).toBe(2); }); + it("returns unavailable metadata when the index identity is paused", async () => { + let searchCalls = 0; + setMemorySearchImpl(async () => { + searchCalls += 1; + return []; + }); + const reason = "index was built for provider openai, expected ollama"; + setMemoryCustomStatus({ + indexIdentity: { + status: "mismatched", + reason, + }, + }); + + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { list: [{ id: "main", default: true }] }, + memory: { citations: "off" }, + }, + }); + const result = await tool.execute("paused-index", { query: "hidden thread codename" }); + + expectUnavailableMemorySearchDetails(result.details, { + error: reason, + warning: + "Tell the user: memory search is paused because the memory index was built with a different embedding provider/model/settings.", + action: + "Tell the user to run: openclaw memory status --index or openclaw memory index --force.", + }); + expect(searchCalls).toBe(1); + expect(getMemorySyncMockCalls()).toBe(0); + }); + it("returns structured search debug metadata for qmd results", async () => { setMemoryBackend("qmd"); setMemorySearchImpl(async (opts) => { diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 84391bd7984b..f199e7bc5b5e 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -18,6 +18,7 @@ import { resolveMemoryDreamingConfig, resolveMemoryDeepDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; +import { asRecord } from "./dreaming-shared.js"; import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; import { recordShortTermRecalls } from "./short-term-promotion.js"; import { @@ -109,6 +110,28 @@ async function runMemorySearchToolWithDeadline(params: { } } +const PAUSED_MEMORY_INDEX_WARNING = + "Tell the user: memory search is paused because the memory index was built with a different embedding provider/model/settings."; +const PAUSED_MEMORY_INDEX_ACTION = + "Tell the user to run: openclaw memory status --index or openclaw memory index --force."; + +function resolvePausedMemoryIndexIdentityReason(status: { custom?: unknown }): string | undefined { + const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity); + if (indexIdentity?.status !== "mismatched" && indexIdentity?.status !== "missing") { + return undefined; + } + return typeof indexIdentity.reason === "string" && indexIdentity.reason.trim() + ? indexIdentity.reason.trim() + : "memory index identity is missing or mismatched"; +} + +function buildPausedMemoryIndexUnavailableResult(reason: string) { + return buildMemorySearchUnavailableResult(reason, { + warning: PAUSED_MEMORY_INDEX_WARNING, + action: PAUSED_MEMORY_INDEX_ACTION, + }); +} + function sortMemorySearchToolResults(results: T[]): T[] { return results.toSorted((left, right) => { if (left.score !== right.score) { @@ -316,7 +339,7 @@ export function createMemorySearchTool(options: { label: "Memory Search", name: "memory_search", description: - "Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.", + "Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable; you must tell the user and include the warning/action guidance.", parameters: MemorySearchSchema, execute: ({ cfg, agentId }) => @@ -400,6 +423,7 @@ export function createMemorySearchTool(options: { let model: string | undefined; let fallback: unknown; let searchMode: string | undefined; + let pausedIndexIdentityReason: string | undefined; let searchDebug: | { backend: string; @@ -447,9 +471,21 @@ export function createMemorySearchTool(options: { activeMemory = refreshed; rawResults = await activeMemory.manager.search(query, searchOptions); } + const statusBeforeRetry = activeMemory.manager.status(); + pausedIndexIdentityReason = + resolvePausedMemoryIndexIdentityReason(statusBeforeRetry); + if (pausedIndexIdentityReason) { + return; + } if (rawResults.length === 0 && activeMemory.manager.sync) { await activeMemory.manager.sync({ reason: "search", force: true }); rawResults = await activeMemory.manager.search(query, searchOptions); + pausedIndexIdentityReason = resolvePausedMemoryIndexIdentityReason( + activeMemory.manager.status(), + ); + if (pausedIndexIdentityReason) { + return; + } } rawResults = await filterMemorySearchHitsBySessionVisibility({ cfg, @@ -500,6 +536,11 @@ export function createMemorySearchTool(options: { hits: rawResults.length, }; }); + if (pausedIndexIdentityReason) { + return jsonResult( + buildPausedMemoryIndexUnavailableResult(pausedIndexIdentityReason), + ); + } } const supplementResults = shouldQuerySupplements ? await runUnavailablePhase(