Files
openclaw/extensions/memory-core/src/cli-runtime-common.ts
T
Peter Steinberger fa03d9b913 refactor: consolidate coercion helpers (#121366)
* refactor: consolidate coercion helpers

* fix: remove duplicate coercion imports

* fix: preserve serialized coercion guard

* chore: ratchet coercion helper carve-outs

* fix(test): keep gauntlet subprocess startup lean

* fix: preserve imported session timestamp semantics

* fix: preserve catalog timestamp string semantics

* chore: align plugin SDK surface ratchet

* fix: preserve trajectory and SDK string contracts

* fix(test): preserve QA record assertion semantics

* fix: complete standalone record guard rename

* refactor(cron): use canonical string coercion

* fix(acpx): preserve Pi timestamp parsing

* test(channels): adapt custody test harnesses

* test(telegram): classify media harness as test support

* test(acpx): split timestamp contract coverage

* test(channels): support generated custody contracts

* chore: ban the full coercion helper name set

Extends the declaration guard to all eleven consolidated helper names and
renames the cron schedule-identity readNumber wrapper to readScheduleInteger
so the banned generic name cannot regrow.

* fix(scripts): repair release-validation guard drift and lint cause

Restores the renamed isJsonRecord guard in assertTrustedWorkflowHarness after
main added isRecord call sites in parallel, and attaches the caught YAML error
as the thrown error cause (preserve-caught-error was red on main).

* fix: preserve Claude timestamp string semantics

* fix: preserve persisted timestamp string semantics

* fix: preserve date-first timestamp contracts

* fix(openai): harden delegation failure formatting

* chore: close coercion helper guard gaps

* test(openai): model non-error delegation rejection

* chore: refresh plugin SDK API contract

* fix(tasks): use canonical string field reader

* fix(ai): use canonical provider error field coercion

* fix(browser): migrate native bootstrap coercion

* docs(plugin-sdk): clarify text record export compatibility

* fix(gateway): normalize approval execution identity

* test(outbound): isolate message action poll harness
2026-08-11 00:02:18 -07:00

370 lines
12 KiB
TypeScript

import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { listAgentIds } from "openclaw/plugin-sdk/agent-runtime";
import { isUsageCountedSessionTranscriptFileName } from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
import {
normalizeExtraMemoryPathEntries,
type MemoryExtraPath,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
defaultRuntime,
formatErrorMessage,
getMemorySearchManager,
getRuntimeConfig,
listMemoryFiles,
normalizeExtraMemoryPaths,
resolveCommandSecretRefsViaGateway,
resolveDefaultAgentId,
resolveSessionTranscriptsDirForAgent,
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 (trimmed) {
return trimmed;
}
return 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 (trimmed) {
return [trimmed];
}
return 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: {
cfg: OpenClawConfig;
agentId: string;
purpose?: MemoryManagerPurpose;
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.acquireLocalService) {
managerParams.acquireLocalService = params.acquireLocalService;
}
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager(managerParams),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
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;
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({
cfg,
agentId,
purpose: params.purpose,
acquireLocalService: params.acquireLocalService,
run: async (manager) => params.run({ manager, cfg, agentId }),
});
}
return cfg;
}
export type MemorySourceName = "memory" | "sessions";
type SourceScan = {
source: MemorySourceName;
totalFiles: number | null;
issues: string[];
};
export type MemorySourceScan = {
sources: SourceScan[];
totalFiles: number | null;
issues: string[];
};
async function checkReadableFile(pathname: string): Promise<{ exists: boolean; issue?: string }> {
try {
await fs.access(pathname, fsSync.constants.R_OK);
return { exists: true };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return { exists: false };
}
return {
exists: true,
issue: `${shortenHomePath(pathname)} not readable (${code ?? "error"})`,
};
}
}
async function scanSessionFiles(agentId: string): Promise<SourceScan> {
const issues: string[] = [];
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
try {
const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
const totalFiles = entries.filter(
(entry) => entry.isFile() && isUsageCountedSessionTranscriptFileName(entry.name),
).length;
return { source: "sessions", totalFiles, issues };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
issues.push(`sessions directory missing (${shortenHomePath(sessionsDir)})`);
return { source: "sessions", totalFiles: 0, issues };
}
issues.push(
`sessions directory not accessible (${shortenHomePath(sessionsDir)}): ${code ?? "error"}`,
);
return { source: "sessions", totalFiles: null, issues };
}
}
async function scanMemoryFiles(
workspaceDir: string,
extraPaths: MemoryExtraPath[] = [],
): Promise<SourceScan> {
const issues: string[] = [];
const memoryFile = path.join(workspaceDir, "MEMORY.md");
const memoryDir = path.join(workspaceDir, "memory");
const primary = await checkReadableFile(memoryFile);
if (primary.issue) {
issues.push(primary.issue);
}
const resolvedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths);
for (const extraPath of resolvedExtraPaths) {
try {
const stat = await fs.lstat(extraPath);
if (stat.isSymbolicLink()) {
continue;
}
const extraCheck = await checkReadableFile(extraPath);
if (extraCheck.issue) {
issues.push(extraCheck.issue);
}
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
issues.push(`additional memory path missing (${shortenHomePath(extraPath)})`);
} else {
issues.push(
`additional memory path not accessible (${shortenHomePath(extraPath)}): ${code ?? "error"}`,
);
}
}
}
let dirReadable: boolean | null;
try {
await fs.access(memoryDir, fsSync.constants.R_OK);
dirReadable = true;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
issues.push(`memory directory missing (${shortenHomePath(memoryDir)})`);
dirReadable = false;
} else {
issues.push(
`memory directory not accessible (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
);
dirReadable = null;
}
}
let listed: string[] = [];
let listedOk = false;
try {
listed = await listMemoryFiles(workspaceDir, extraPaths);
listedOk = true;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (dirReadable !== null) {
issues.push(
`memory directory scan failed (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
);
dirReadable = null;
}
}
let totalFiles: number | null;
if (dirReadable === null) {
totalFiles = null;
} else {
const files = new Set<string>(listedOk ? listed : []);
if (!listedOk) {
if (primary.exists) {
files.add(memoryFile);
}
}
totalFiles = files.size;
}
if ((totalFiles ?? 0) === 0 && issues.length === 0) {
issues.push(`no memory files found in ${shortenHomePath(workspaceDir)}`);
}
return { source: "memory", totalFiles, issues };
}
export async function scanMemorySources(params: {
workspaceDir: string;
agentId: string;
sources: MemorySourceName[];
extraPaths?: MemoryExtraPath[];
}): Promise<MemorySourceScan> {
const scans: SourceScan[] = [];
const extraPaths = params.extraPaths ?? [];
for (const source of params.sources) {
if (source === "memory") {
scans.push(await scanMemoryFiles(params.workspaceDir, extraPaths));
}
if (source === "sessions") {
scans.push(await scanSessionFiles(params.agentId));
}
}
const issues = scans.flatMap((scan) => scan.issues);
const totals = scans.map((scan) => scan.totalFiles);
const numericTotals = totals.filter((total): total is number => total !== null);
const totalFiles = totals.some((total) => total === null)
? null
: numericTotals.reduce((sum, total) => sum + total, 0);
return { sources: scans, totalFiles, issues };
}