From 156af00a782974ae0a8bed541c74543282ce76dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 17:17:16 -0700 Subject: [PATCH] fix(memory): report truthful index outcomes (#123863) --- docs/cli/memory.md | 5 +- .../src/cli-index-search.runtime.ts | 7 +- .../memory-core/src/cli-runtime-common.ts | 29 ++++- .../memory-core/src/cli-status.runtime.ts | 18 +-- extensions/memory-core/src/cli.test.ts | 105 ++++++++++++++---- .../memory-extra-file-path.windows.test.ts | 18 +-- 6 files changed, 134 insertions(+), 48 deletions(-) diff --git a/docs/cli/memory.md b/docs/cli/memory.md index 60642b9e2d08..154ee7a66066 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -50,7 +50,10 @@ openclaw memory index [--agent ] [--force] [--verbose] Same per-agent scoping as `status`. `--force` runs a full reindex instead of an incremental one. `--verbose` prints per-agent provider, model, sources, and -extra-path details before showing indexing progress. +extra-path details before showing indexing progress. The completion message +reports the indexed file count. An empty corpus is a successful no-op: the +command reports the resolved workspace path and that nothing was indexed, and +leaves the missing `memory/` directory for the first memory write to create. ## `memory search` diff --git a/extensions/memory-core/src/cli-index-search.runtime.ts b/extensions/memory-core/src/cli-index-search.runtime.ts index 1a196d672d87..26b76a2d6270 100644 --- a/extensions/memory-core/src/cli-index-search.runtime.ts +++ b/extensions/memory-core/src/cli-index-search.runtime.ts @@ -6,7 +6,9 @@ import { buildCliMemorySearchSessionKey, formatAuditCounts, formatExtraPaths, + formatMemoryIndexOutcome, resolveMemoryPluginConfig, + scanMemoryManagerSources, withMemoryCommand, } from "./cli-runtime-common.js"; import { @@ -149,6 +151,8 @@ export async function runMemoryIndex( }, ); let postIndexStatus = manager.status(); + const scan = await scanMemoryManagerSources(postIndexStatus, agentId); + const outcome = formatMemoryIndexOutcome(postIndexStatus, scan, agentId); let semanticVectorAvailable = postIndexStatus.vector?.semanticAvailable; const vectorStoreAvailable = postIndexStatus.vector?.storeAvailable ?? postIndexStatus.vector?.available; @@ -171,14 +175,13 @@ export async function runMemoryIndex( postIndexStatus.vector?.available ?? postIndexStatus.vector?.storeAvailable; const vectorLoadErr = postIndexStatus.vector?.loadError; + defaultRuntime.log(outcome); if (vectorEnabled && vectorAvailable === false) { // Indexing still persisted chunks/FTS state; keep the command successful but // emit a stderr warning so operators and scripts can detect degraded recall. defaultRuntime.error( `Memory index WARNING (${agentId}): chunks_vec not updated — ${formatMemoryVectorDegradedWriteReason(vectorLoadErr)}. Vector recall degraded.`, ); - } else { - defaultRuntime.log(`Memory index updated (${agentId}).`); } } catch (err) { const message = formatErrorMessage(err); diff --git a/extensions/memory-core/src/cli-runtime-common.ts b/extensions/memory-core/src/cli-runtime-common.ts index f6448475bfef..23eaf3e3519b 100644 --- a/extensions/memory-core/src/cli-runtime-common.ts +++ b/extensions/memory-core/src/cli-runtime-common.ts @@ -216,7 +216,7 @@ export async function withMemoryCommand(params: { } return cfg; } -export type MemorySourceName = "memory" | "sessions"; +type MemorySourceName = "memory" | "sessions"; type SourceScan = { source: MemorySourceName; totalFiles: number | null; @@ -343,7 +343,7 @@ async function scanMemoryFiles( } return { source: "memory", totalFiles, issues }; } -export async function scanMemorySources(params: { +async function scanMemorySources(params: { workspaceDir: string; agentId: string; sources: MemorySourceName[]; @@ -367,3 +367,28 @@ export async function scanMemorySources(params: { : numericTotals.reduce((sum, total) => sum + total, 0); return { sources: scans, totalFiles, issues }; } + +export async function scanMemoryManagerSources( + status: ReturnType, + agentId: string, +): Promise { + const workspaceDir = status.workspaceDir; + if (!workspaceDir) { + return undefined; + } + const sources = (status.sources?.length ? status.sources : ["memory"]) as MemorySourceName[]; + return await scanMemorySources({ workspaceDir, agentId, sources, extraPaths: status.extraPaths }); +} + +export function formatMemoryIndexOutcome( + status: ReturnType, + scan: MemorySourceScan | undefined, + agentId: string, +): string { + if (status.workspaceDir && scan?.totalFiles === 0) { + return `No memory files found in ${shortenHomePath(status.workspaceDir)}; nothing indexed (${agentId}).`; + } + const indexedFiles = status.files ?? 0; + const fileLabel = indexedFiles === 1 ? "file" : "files"; + return `Memory index updated (${agentId}): ${indexedFiles} ${fileLabel} indexed.`; +} diff --git a/extensions/memory-core/src/cli-status.runtime.ts b/extensions/memory-core/src/cli-status.runtime.ts index 6a279a0cdcfd..c0e42b013dfe 100644 --- a/extensions/memory-core/src/cli-status.runtime.ts +++ b/extensions/memory-core/src/cli-status.runtime.ts @@ -7,11 +7,11 @@ import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatAuditCounts, formatExtraPaths, + formatMemoryIndexOutcome, resolveMemoryPluginConfig, - scanMemorySources, + scanMemoryManagerSources, withMemoryCommand, type MemoryManager, - type MemorySourceName, type MemorySourceScan, } from "./cli-runtime-common.js"; import { @@ -231,16 +231,8 @@ export async function runMemoryStatus( } } const status = manager.status(); - const sources = (status.sources?.length ? status.sources : ["memory"]) as MemorySourceName[]; + const scan = await scanMemoryManagerSources(status, agentId); const workspaceDir = status.workspaceDir; - const scan = workspaceDir - ? await scanMemorySources({ - workspaceDir, - agentId, - sources, - extraPaths: status.extraPaths, - }) - : undefined; let audit: ShortTermAuditSummary | undefined; let repair: RepairShortTermPromotionArtifactsResult | undefined; let dreamingAudit: DreamingArtifactsAuditSummary | undefined; @@ -294,7 +286,9 @@ export async function runMemoryStatus( ? `${filesIndexed}/? files · ${chunksIndexed} chunks` : `${filesIndexed}/${totalFiles} files · ${chunksIndexed} chunks`; if (opts.index) { - const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete."; + const line = indexError + ? `Memory index failed: ${indexError}` + : formatMemoryIndexOutcome(status, scan, agentId); defaultRuntime.log(line); } const requestedProvider = status.requestedProvider ?? status.provider; diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index c89743a56ace..548c6442e5f0 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -1312,51 +1312,108 @@ describe("memory cli", () => { }); it("reindexes on status --index", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-08-14", ["# Indexed memory"]); + 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, + status: () => makeMemoryStatus({ workspaceDir, sources: ["memory"], files: 1, chunks: 1 }), + close, + }); + + const log = spyRuntimeLogs(defaultRuntime); + await runMemoryCli(["status", "--index"]); + + expectCliSync(sync); + expect(probeVectorStoreAvailability).toHaveBeenCalled(); + expect(probeVectorAvailability).toHaveBeenCalled(); + expect(probeEmbeddingAvailability).toHaveBeenCalled(); + expect(getMemorySearchManager).toHaveBeenCalledWith({ + cfg: {}, + agentId: "main", + purpose: "cli", + }); + expectLogged(log, "Memory index updated (main): 1 file indexed."); + expect(close).toHaveBeenCalled(); + }); + }); + + it("reports the same truthful no-op from status --index", async () => { + const workspaceDir = path.join(workspaceFixtureRoot, `case-${workspaceCaseId++}`); + await fs.mkdir(workspaceDir, { recursive: true }); 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, + probeVectorAvailability: vi.fn(async () => true), + probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), sync, - status: () => makeMemoryStatus({ files: 1, chunks: 1 }), + status: () => makeMemoryStatus({ workspaceDir, sources: ["memory"] }), close, }); - spyRuntimeLogs(defaultRuntime); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["status", "--index"]); expectCliSync(sync); - expect(probeVectorStoreAvailability).toHaveBeenCalled(); - expect(probeVectorAvailability).toHaveBeenCalled(); - expect(probeEmbeddingAvailability).toHaveBeenCalled(); - expect(getMemorySearchManager).toHaveBeenCalledWith({ - cfg: {}, - agentId: "main", - purpose: "cli", - }); + expectLogged(log, `No memory files found in ${workspaceDir}; nothing indexed (main).`); + expectNotLogged(log, "Memory index complete"); + await expectPathMissing(path.join(workspaceDir, "memory")); expect(close).toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); }); - it("closes manager after index", async () => { + it("reports a truthful no-op when the memory directory is missing", async () => { + const workspaceDir = path.join(workspaceFixtureRoot, `case-${workspaceCaseId++}`); + await fs.mkdir(workspaceDir, { recursive: true }); const close = vi.fn(async () => {}); const sync = vi.fn(async () => {}); - mockManager({ sync, status: () => makeMemoryStatus(), close }); + mockManager({ + sync, + status: () => makeMemoryStatus({ workspaceDir, sources: ["memory"] }), + close, + }); const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["index"]); expectCliSync(sync); - expect(getMemorySearchManager).toHaveBeenCalledWith({ - cfg: {}, - agentId: "main", - purpose: "cli", - }); + expectLogged(log, `No memory files found in ${workspaceDir}; nothing indexed (main).`); + expectNotLogged(log, "Memory index updated"); + await expectPathMissing(path.join(workspaceDir, "memory")); expect(close).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith("Memory index updated (main)."); + expect(process.exitCode).toBeUndefined(); + }); + + it("reports the indexed file count and closes the manager after index", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-08-14", ["# Indexed memory"]); + const close = vi.fn(async () => {}); + const sync = vi.fn(async () => {}); + mockManager({ + sync, + status: () => makeMemoryStatus({ workspaceDir, sources: ["memory"], files: 1 }), + close, + }); + + const log = spyRuntimeLogs(defaultRuntime); + await runMemoryCli(["index"]); + + expectCliSync(sync); + expect(getMemorySearchManager).toHaveBeenCalledWith({ + cfg: {}, + agentId: "main", + purpose: "cli", + }); + expect(close).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("Memory index updated (main): 1 file indexed."); + }); }); it("warns on stderr when index completes without sqlite-vec embeddings", async () => { diff --git a/extensions/memory-core/src/memory-extra-file-path.windows.test.ts b/extensions/memory-core/src/memory-extra-file-path.windows.test.ts index b0cc87a55de2..b69be1abbd9b 100644 --- a/extensions/memory-core/src/memory-extra-file-path.windows.test.ts +++ b/extensions/memory-core/src/memory-extra-file-path.windows.test.ts @@ -6,7 +6,7 @@ import { readMemoryFile, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { scanMemorySources } from "./cli-runtime-common.js"; +import { scanMemoryManagerSources } from "./cli-runtime-common.js"; import { resolveMemoryPathClassification } from "./memory/memory-path-provenance.js"; describe.skipIf(process.platform !== "win32")("Windows explicit memory extra-file casing", () => { @@ -52,12 +52,16 @@ describe.skipIf(process.platform !== "win32")("Windows explicit memory extra-fil }), ).resolves.toMatchObject({ text: "shared Windows memory" }); await expect( - scanMemorySources({ - workspaceDir, - agentId: "main", - sources: ["memory"], - extraPaths: [configuredPath], - }), + scanMemoryManagerSources( + { + backend: "builtin", + provider: "none", + workspaceDir, + sources: ["memory"], + extraPaths: [configuredPath], + }, + "main", + ), ).resolves.toMatchObject({ totalFiles: 1, issues: [] }); }, );