fix(memory): report truthful index outcomes (#123863)

This commit is contained in:
Peter Steinberger
2026-08-14 17:17:16 -07:00
committed by GitHub
parent ad508a51c2
commit 156af00a78
6 changed files with 134 additions and 48 deletions
+4 -1
View File
@@ -50,7 +50,10 @@ openclaw memory index [--agent <id>] [--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`
@@ -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);
@@ -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<MemoryManager["status"]>,
agentId: string,
): Promise<MemorySourceScan | undefined> {
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<MemoryManager["status"]>,
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.`;
}
@@ -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;
+81 -24
View File
@@ -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 () => {
@@ -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: [] });
},
);