Files
openclaw/extensions/memory-core/src/cli-runtime-common.ts
Peter Steinberger 75fcb1fbb9 fix(memory): reject unknown --agent ids and keep the hint runnable (#126570)
The memory CLI resolved --agent by returning the caller's string verbatim,
so an id that is not configured produced a confident empty result:
`memory status` rendered a panel for it, `memory index` fabricated a
workspace-<id> path, and `memory search` reported No matches. A typo read
as an empty memory rather than a nonexistent agent, while hooks, status
--usage, capability, migrate, and session targets already rejected unknown
ids.

Consolidate that duplicated check into resolveConfiguredAgentId beside the
agent roster owner, reuse it at the matching core sites, and route memory
to it through the existing memory-core host-runtime facade so no new
plugin SDK surface is added.

The canonical hint uses formatCliCommand rather than a literal: under a
profile or container the bare command is wrong, so consolidating on a
literal would have regressed the hooks and migrate hints and left the
status, capability, and session-target hints unrunnable.
2026-08-20 00:32:59 -07:00

271 lines
9.3 KiB
TypeScript

import {
normalizeExtraMemoryPathEntries,
type MemoryExtraPath,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
listAgentIds,
resolveConfiguredAgentId,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
defaultRuntime,
formatErrorMessage,
getMemorySearchManager,
getRuntimeConfig,
resolveCommandSecretRefsViaGateway,
resolveDefaultAgentId,
shortenHomePath,
theme,
type OpenClawConfig,
withManager,
} from "./cli.host.runtime.js";
import type { MemoryCoreAcquireLocalService } from "./memory/embedding-local-service.js";
import type { ShortTermAuditSummary } from "./short-term-promotion.js";
const { warn } = theme;
export type MemoryManager = NonNullable<
Awaited<ReturnType<typeof getMemorySearchManager>>["manager"]
>;
type MemoryManagerPurpose = Parameters<typeof getMemorySearchManager>[0]["purpose"];
function getMemoryCommandSecretTargetIds(): Set<string> {
return new Set(["memory.search.remote.apiKey", "agents.entries.*.memory.search.remote.apiKey"]);
}
function isMemorySecretOwnerFailure(error: unknown, message: string): boolean {
const candidate = error && typeof error === "object" ? (error as Record<string, unknown>) : {};
if (
candidate.ownerKind === "capability" &&
typeof candidate.ownerId === "string" &&
candidate.ownerId.startsWith("memory-provider:")
) {
return true;
}
if (
Array.isArray(candidate.paths) &&
candidate.paths.some(
(entry) => typeof entry === "string" && entry.includes("memory.search.remote.apiKey"),
)
) {
return true;
}
// Gateway RPC errors preserve the typed owner's redacted message even when
// structured owner fields are unavailable to the CLI process.
return message.includes("capability:memory-provider:");
}
async function loadMemoryCommandConfig(
commandName: string,
mode?: "enforce_resolved" | "read_only_status",
) {
const config = getRuntimeConfig({ skipPluginValidation: true });
try {
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
config,
commandName,
targetIds: getMemoryCommandSecretTargetIds(),
...(mode ? { mode } : {}),
});
return { config: resolvedConfig, diagnostics };
} catch (error) {
const code =
error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: "";
const message = formatErrorMessage(error);
if (
mode !== "read_only_status" ||
isMemorySecretOwnerFailure(error, message) ||
(code !== "SECRET_SURFACE_UNAVAILABLE" && !message.includes("SECRET_SURFACE_UNAVAILABLE"))
) {
throw error;
}
return {
config,
diagnostics: [
`${commandName}: ${message}; continuing with degraded read-only config so healthy memory surfaces remain visible.`,
],
};
}
}
function emitMemorySecretResolveDiagnostics(
diagnostics: string[],
params?: { json?: boolean },
): void {
if (diagnostics.length === 0) {
return;
}
const toStderr = params?.json === true;
for (const entry of diagnostics) {
const message = warn(`[secrets] ${entry}`);
if (toStderr) {
defaultRuntime.error(message);
} else {
defaultRuntime.log(message);
}
}
}
export function resolveMemoryPluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
const entry = asNullableRecord(cfg.plugins?.entries?.["memory-core"]);
return asNullableRecord(entry?.config) ?? {};
}
export function formatAuditCounts(audit: ShortTermAuditSummary): string {
const scriptCoverage = audit.conceptTagScripts
? [
audit.conceptTagScripts.latinEntryCount > 0
? `${audit.conceptTagScripts.latinEntryCount} latin`
: null,
audit.conceptTagScripts.cjkEntryCount > 0
? `${audit.conceptTagScripts.cjkEntryCount} cjk`
: null,
audit.conceptTagScripts.mixedEntryCount > 0
? `${audit.conceptTagScripts.mixedEntryCount} mixed`
: null,
audit.conceptTagScripts.otherEntryCount > 0
? `${audit.conceptTagScripts.otherEntryCount} other`
: null,
]
.filter(Boolean)
.join(", ")
: "";
const suffix = scriptCoverage ? ` · scripts=${scriptCoverage}` : "";
return `${audit.entryCount} entries · ${audit.promotedCount} promoted · ${audit.conceptTaggedEntryCount} concept-tagged · ${audit.spacedEntryCount} spaced${suffix}`;
}
function resolveAgent(cfg: OpenClawConfig, agent?: string) {
const trimmed = agent?.trim();
if (agent !== undefined && !trimmed) {
throw new Error("--agent must not be blank");
}
return trimmed ? resolveConfiguredAgentId(cfg, trimmed) : resolveDefaultAgentId(cfg);
}
export function buildCliMemorySearchSessionKey(agentId: string): string {
return buildAgentSessionKey({
agentId,
channel: "cli",
peer: { kind: "direct", id: "memory-search" },
dmScope: "per-channel-peer",
});
}
function resolveAgentIds(cfg: OpenClawConfig, agent?: string): string[] {
const trimmed = agent?.trim();
if (agent !== undefined && !trimmed) {
throw new Error("--agent must not be blank");
}
return trimmed ? [resolveConfiguredAgentId(cfg, trimmed)] : listAgentIds(cfg);
}
export function formatExtraPaths(workspaceDir: string, extraPaths: MemoryExtraPath[]): string[] {
return normalizeExtraMemoryPathEntries(workspaceDir, extraPaths).map((entry) => {
const root = shortenHomePath(entry.path);
return entry.pattern ? `${root} (pattern: ${entry.pattern})` : root;
});
}
async function withMemoryManagerForAgent(params: {
commandName: string;
cfg: OpenClawConfig;
agentId: string;
purpose?: MemoryManagerPurpose;
inspectSources?: boolean;
acquireLocalService?: MemoryCoreAcquireLocalService;
run: (manager: MemoryManager) => Promise<void>;
}): Promise<void> {
const managerParams: Parameters<typeof getMemorySearchManager>[0] = {
cfg: params.cfg,
agentId: params.agentId,
};
if (params.purpose) {
managerParams.purpose = params.purpose;
}
if (params.inspectSources) {
managerParams.inspectSources = true;
}
if (params.acquireLocalService) {
managerParams.acquireLocalService = params.acquireLocalService;
}
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager(managerParams),
onMissing: (error) => {
if (!error?.trim()) {
defaultRuntime.log("Memory search disabled.");
return;
}
defaultRuntime.error(`${params.commandName} failed (${params.agentId}): ${error}`);
process.exitCode = 1;
},
onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: async (manager) => {
await manager.close?.();
},
run: params.run,
});
}
export async function withMemoryCommand(params: {
commandName: string;
agent?: string;
allAgents?: boolean;
diagnosticsToStderr?: boolean;
purpose?: MemoryManagerPurpose;
inspectSources?: boolean;
acquireLocalService?: MemoryCoreAcquireLocalService;
run: (context: { manager: MemoryManager; cfg: OpenClawConfig; agentId: string }) => Promise<void>;
}): Promise<OpenClawConfig> {
const { config: cfg, diagnostics } = await loadMemoryCommandConfig(
params.commandName,
params.purpose === "status" ? "read_only_status" : undefined,
);
emitMemorySecretResolveDiagnostics(diagnostics, { json: params.diagnosticsToStderr });
const agentIds = params.allAgents
? resolveAgentIds(cfg, params.agent)
: [resolveAgent(cfg, params.agent)];
for (const agentId of agentIds) {
await withMemoryManagerForAgent({
commandName: params.commandName,
cfg,
agentId,
purpose: params.purpose,
inspectSources: params.inspectSources,
acquireLocalService: params.acquireLocalService,
run: async (manager) => params.run({ manager, cfg, agentId }),
});
}
return cfg;
}
type SourceScan = {
source: "memory" | "sessions";
totalFiles: number | null;
issues: string[];
};
export type MemorySourceScan = {
sources: SourceScan[];
totalFiles: number | null;
issues: string[];
};
export async function scanMemoryManagerSources(
status: ReturnType<MemoryManager["status"]>,
): Promise<MemorySourceScan | undefined> {
if (!status.sourceCounts?.length) {
return undefined;
}
const sources = status.sourceCounts.map(
(entry): SourceScan => ({
source: entry.source,
totalFiles: entry.eligible ?? null,
issues: entry.issues ?? [],
}),
);
const totalFiles = sources.some((entry) => entry.totalFiles === null)
? null
: sources.reduce((total, entry) => total + (entry.totalFiles ?? 0), 0);
return { sources, totalFiles, issues: sources.flatMap((entry) => entry.issues) };
}
export function formatMemoryIndexOutcome(
status: ReturnType<MemoryManager["status"]>,
scan: MemorySourceScan | undefined,
agentId: string,
): string {
const indexedFiles = status.files ?? 0;
if (indexedFiles === 0 && status.workspaceDir && scan?.totalFiles === 0) {
return `No memory files found in ${shortenHomePath(status.workspaceDir)}; nothing indexed (${agentId}).`;
}
const fileLabel = indexedFiles === 1 ? "file" : "files";
return `Memory index updated (${agentId}): ${indexedFiles} ${fileLabel} indexed.`;
}