From 7d1d721d626027e43d91fd99eb1193b197931652 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 17:40:14 -0700 Subject: [PATCH] fix(memory): qualify stale memory search results (#117706) * fix(memory): qualify stale search results * fix(memory): satisfy staleness checks * fix(memory): normalize absent staleness --- docs/cli/memory.md | 6 +++ .../src/cli-index-search.runtime.ts | 41 +++------------- extensions/memory-core/src/cli.test.ts | 49 ++++++++++++++++++- .../src/memory-tool-manager.test-mocks.ts | 8 ++- extensions/memory-core/src/tools.test.ts | 22 +++++++++ extensions/memory-core/src/tools.ts | 8 ++- .../memory-host-sdk/src/engine-storage.ts | 1 + packages/memory-host-sdk/src/host/types.ts | 22 +++++++++ .../server-methods/memory-search.test.ts | 29 +++++++++++ src/gateway/server-methods/memory-search.ts | 5 ++ .../memory-core-host-engine-storage.ts | 1 + 11 files changed, 155 insertions(+), 37 deletions(-) diff --git a/docs/cli/memory.md b/docs/cli/memory.md index c0100d3a8e1b..a5ba47b378aa 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -64,6 +64,12 @@ openclaw memory search [query] [--query ] [--agent ] [--max-results `: cap result count (positive integer). - `--min-score `: filter out matches below this score. +If the index remains dirty after the bounded search-time refresh, human output +warns that matches may be incomplete. With `--json`, the response adds +`stale: true`, plus `warning` and `action` fields describing how to rebuild the +index. Treat an empty `results` array as authoritative only when `stale` is +absent. + ## `memory promote` Rank short-term candidates from `memory/YYYY-MM-DD.md` and optionally append diff --git a/extensions/memory-core/src/cli-index-search.runtime.ts b/extensions/memory-core/src/cli-index-search.runtime.ts index fbab186c1dbf..abc2d84f1fc6 100644 --- a/extensions/memory-core/src/cli-index-search.runtime.ts +++ b/extensions/memory-core/src/cli-index-search.runtime.ts @@ -2,6 +2,7 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { resolveMemorySearchStaleness } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status"; import { buildCliMemorySearchSessionKey, @@ -40,27 +41,6 @@ import { resolveShortTermRecallStorePath, } from "./short-term-promotion.js"; const { accent, heading, info, muted, success, warn } = theme; -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}`, - }; -} function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string { if (source === "memory") { return shortenHomeInString( @@ -284,10 +264,9 @@ export async function runMemorySearch( process.exitCode = 1; return; } - const workspaceDir = - typeof (manager as { status?: () => { workspaceDir?: string } }).status === "function" - ? manager.status().workspaceDir - : undefined; + const status = manager.status(); + const staleness = resolveMemorySearchStaleness(status, agentId); + const workspaceDir = status.workspaceDir; if (dreamingEnabled) { await recordShortTermRecalls({ workspaceDir, @@ -300,17 +279,11 @@ export async function runMemorySearch( }); } if (opts.json) { - defaultRuntime.writeJson({ results }); + defaultRuntime.writeJson({ results, ...staleness }); 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 (staleness) { + defaultRuntime.error(`${staleness.warning} ${staleness.action}`); } if (results.length === 0) { defaultRuntime.log("No matches."); diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index fb359ec7b6c8..4388d486d3a7 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -199,7 +199,12 @@ describe("memory cli", () => { } function mockManager(manager: Record) { - getMemorySearchManager.mockResolvedValueOnce({ manager }); + getMemorySearchManager.mockResolvedValueOnce({ + manager: { + ...(manager.search && !manager.status ? { status: () => makeMemoryStatus() } : {}), + ...manager, + }, + }); } function setupMemoryStatusWithInactiveSecretDiagnostics(close: ReturnType) { @@ -1723,6 +1728,48 @@ describe("memory cli", () => { expect(close).toHaveBeenCalled(); }); + it("qualifies json search results when the index remains stale", async () => { + const close = vi.fn(async () => {}); + const reason = "index was built for model old-embed, expected new-embed"; + mockManager({ + search: vi.fn(async () => []), + status: () => + makeMemoryStatus({ + dirty: true, + custom: { indexIdentity: { status: "mismatched", reason } }, + }), + close, + }); + + const writeJson = spyRuntimeJson(defaultRuntime); + await runMemoryCli(["search", "hidden codeword", "--agent", "main", "--json"]); + + expect(firstWrittenJsonArg(writeJson)).toEqual({ + results: [], + stale: true, + warning: `Memory index is stale: ${reason}. Search results may be incomplete.`, + action: "Run: openclaw memory status --index --agent main", + }); + }); + + it("warns before reporting no matches from a dirty index", async () => { + const close = vi.fn(async () => {}); + mockManager({ + search: vi.fn(async () => []), + status: () => makeMemoryStatus({ dirty: true }), + close, + }); + + const error = spyRuntimeErrors(defaultRuntime); + const log = spyRuntimeLogs(defaultRuntime); + await runMemoryCli(["search", "hidden codeword"]); + + expect(error).toHaveBeenCalledWith( + "Memory index is dirty. Search results may be incomplete. Run: openclaw memory status --index --agent main", + ); + expect(log).toHaveBeenCalledWith("No matches."); + }); + it("prints no candidates when promote has no short-term recall data", async () => { await withTempWorkspace(async (workspaceDir) => { const close = vi.fn(async () => {}); diff --git a/extensions/memory-core/src/memory-tool-manager.test-mocks.ts b/extensions/memory-core/src/memory-tool-manager.test-mocks.ts index 08b38d53959e..5bd220d8eb1b 100644 --- a/extensions/memory-core/src/memory-tool-manager.test-mocks.ts +++ b/extensions/memory-core/src/memory-tool-manager.test-mocks.ts @@ -38,6 +38,7 @@ type MemoryManagerParams = { let backend: MemoryBackend = "builtin"; let resolvedBackend: MemoryBackend | undefined; let workspaceDir = "/workspace"; +let statusDirty = false; let customStatus: Record | undefined; let searchImpl: SearchImpl = async () => []; let closeImpl: () => Promise = async () => {}; @@ -62,7 +63,7 @@ const stubManager = { backend, files: 1, chunks: 1, - dirty: false, + dirty: statusDirty, workspaceDir, dbPath: "/workspace/.memory/index.sqlite", provider: "builtin", @@ -113,6 +114,10 @@ export function setMemoryCustomStatus(next: Record | undefined) customStatus = next; } +export function setMemoryStatusDirty(next: boolean): void { + statusDirty = next; +} + export function setMemorySearchImpl(next: SearchImpl): void { searchImpl = next; } @@ -145,6 +150,7 @@ export function resetMemoryToolMockState(overrides?: { backend = overrides?.backend ?? "builtin"; resolvedBackend = undefined; workspaceDir = "/workspace"; + statusDirty = false; customStatus = undefined; getManagerImpl = undefined; searchImpl = overrides?.searchImpl ?? (async () => []); diff --git a/extensions/memory-core/src/tools.test.ts b/extensions/memory-core/src/tools.test.ts index 5fa9cc038629..377b623888cc 100644 --- a/extensions/memory-core/src/tools.test.ts +++ b/extensions/memory-core/src/tools.test.ts @@ -18,6 +18,7 @@ import { setResolvedMemoryBackend, setMemorySearchImpl, setMemorySearchManagerImpl, + setMemoryStatusDirty, } from "./memory-tool-manager.test-mocks.js"; import { applyProjectRanking } from "./memory/project-ranking.js"; import { @@ -978,6 +979,27 @@ describe("memory_search unavailable payloads", () => { expect(searchCalls).toBe(2); }); + it("qualifies empty results when the index remains dirty after retry", async () => { + setMemoryStatusDirty(true); + setMemorySearchImpl(async () => []); + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { list: [{ id: "main", default: true }] }, + memory: { citations: "off" }, + }, + }); + + const result = await tool.execute("dirty-index", { query: "hidden codeword" }); + + expect(result.details).toMatchObject({ + results: [], + stale: true, + warning: "Memory index is dirty. Search results may be incomplete.", + action: "Run: openclaw memory status --index --agent main", + }); + expect(getMemorySyncMockCalls()).toBe(1); + }); + it("keeps the zero-hit bootstrap retry for one-shot qmd searches", async () => { setMemoryBackend("qmd"); let searchCalls = 0; diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 60c403d9983f..e6a17347f8ee 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -1,6 +1,7 @@ // Memory Core plugin module implements tools behavior. import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { + resolveMemorySearchStaleness, stripMemoryAnnotationCarriers, type MemoryReadResult, type MemorySource, @@ -479,7 +480,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; you must tell the user and include the warning/action guidance.", + "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 or stale=true, you must tell the user and include the warning/action guidance.", parameters: MemorySearchSchema, execute: ({ cfg, agentId }) => @@ -609,6 +610,9 @@ export function createMemorySearchTool(options: { let fallback: unknown; let searchMode: string | undefined; let pausedIndexIdentityReason: string | undefined; + let staleness: + | Exclude, null> + | undefined; let managerMs: number | undefined; let managerCacheState: string | undefined; let searchDebug: @@ -757,6 +761,7 @@ export function createMemorySearchTool(options: { rawResults = rawResults.filter((hit) => hit.source === "memory"); } const status = activeMemory.manager.status(); + staleness = resolveMemorySearchStaleness(status, agentId) ?? undefined; const payloadResults = rawResults.map((result) => ({ ...result, snippet: stripMemoryAnnotationCarriers(result.snippet), @@ -853,6 +858,7 @@ export function createMemorySearchTool(options: { fallback, citations: citationsMode, mode: searchMode, + ...staleness, debug: searchDebug, }); } finally { diff --git a/packages/memory-host-sdk/src/engine-storage.ts b/packages/memory-host-sdk/src/engine-storage.ts index 186fa600c157..272cd9dc39ea 100644 --- a/packages/memory-host-sdk/src/engine-storage.ts +++ b/packages/memory-host-sdk/src/engine-storage.ts @@ -33,6 +33,7 @@ export { type MemoryReadResult, } from "./host/read-file-shared.js"; export { resolveMemoryBackendConfig } from "./host/backend-config.js"; +export { resolveMemorySearchStaleness } from "./host/types.js"; export type { ResolvedMemoryBackendConfig, ResolvedQmdConfig, diff --git a/packages/memory-host-sdk/src/host/types.ts b/packages/memory-host-sdk/src/host/types.ts index 2b1d559196b2..025ccf23f743 100644 --- a/packages/memory-host-sdk/src/host/types.ts +++ b/packages/memory-host-sdk/src/host/types.ts @@ -161,6 +161,28 @@ export type MemoryProviderStatus = { custom?: Record; }; +export function resolveMemorySearchStaleness( + status: Pick, + agentId?: string, +): { stale: true; warning: string; action: string } | null { + const identity = status.custom?.indexIdentity as Record | undefined; + const identityReason = + (identity?.status === "mismatched" || identity?.status === "missing") && + typeof identity.reason === "string" + ? identity.reason.trim() + : undefined; + if (!status.dirty && !identityReason) { + return null; + } + return { + stale: true, + warning: identityReason + ? `Memory index is stale: ${identityReason}. Search results may be incomplete.` + : "Memory index is dirty. Search results may be incomplete.", + action: `Run: openclaw memory status --index${agentId?.trim() ? ` --agent ${agentId.trim()}` : ""}`, + }; +} + /** Search/read/sync/status contract implemented by memory managers. */ export interface MemorySearchManager { search( diff --git a/src/gateway/server-methods/memory-search.test.ts b/src/gateway/server-methods/memory-search.test.ts index 3fe377ac9f57..0fcf43d17940 100644 --- a/src/gateway/server-methods/memory-search.test.ts +++ b/src/gateway/server-methods/memory-search.test.ts @@ -58,6 +58,7 @@ function createStubManager() { status: vi.fn(() => ({ backend: "builtin" as const, provider: "none", + dirty: false, custom: { searchMode: "fts-only" }, })), close: vi.fn(async () => undefined), @@ -224,4 +225,32 @@ describe("memory.search gateway method", () => { }), ); }); + + it("qualifies results from a dirty index", async () => { + const cfg = createConfig(testState.workspaceDir); + const manager = createStubManager(); + manager.status.mockReturnValue({ + backend: "builtin", + provider: "none", + dirty: true, + custom: { searchMode: "fts-only" }, + }); + getActiveMemorySearchManager.mockResolvedValue({ manager }); + + const respond = await invokeMemorySearch({ query: "hidden codeword" }, cfg); + + expect(respond).toHaveBeenCalledWith( + true, + { + agentId: "main", + provider: "none", + searchMode: "fts-only", + results: [], + stale: true, + warning: "Memory index is dirty. Search results may be incomplete.", + action: "Run: openclaw memory status --index --agent main", + }, + undefined, + ); + }); }); diff --git a/src/gateway/server-methods/memory-search.ts b/src/gateway/server-methods/memory-search.ts index bb2b50c8bafe..c96f772d7e9d 100644 --- a/src/gateway/server-methods/memory-search.ts +++ b/src/gateway/server-methods/memory-search.ts @@ -6,6 +6,7 @@ import type { MemorySearchManager, MemorySearchResult, } from "../../memory-host-sdk/host/types.js"; +import { resolveMemorySearchStaleness } from "../../memory-host-sdk/host/types.js"; import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -18,6 +19,9 @@ export type MemorySearchResponse = { provider: string; searchMode: "hybrid" | "fts-only"; results: MemorySearchResult[]; + stale?: true; + warning?: string; + action?: string; }; function resolveSearchMode(status: MemoryProviderStatus): MemorySearchResponse["searchMode"] { @@ -142,6 +146,7 @@ export const memorySearchHandlers: GatewayRequestHandlers = { provider: status.provider, searchMode: resolveSearchMode(status), results, + ...resolveMemorySearchStaleness(status, agentId), }; respond(true, payload, undefined); } catch (error) { diff --git a/src/plugin-sdk/memory-core-host-engine-storage.ts b/src/plugin-sdk/memory-core-host-engine-storage.ts index 1e4c5b12c475..3c77ff03e516 100644 --- a/src/plugin-sdk/memory-core-host-engine-storage.ts +++ b/src/plugin-sdk/memory-core-host-engine-storage.ts @@ -47,6 +47,7 @@ export { remapChunkLines, requireNodeSqlite, resolveMemoryBackendConfig, + resolveMemorySearchStaleness, runWithConcurrency, splitCuratedMarkdownEntries, statRegularFile,