Files
openclaw/src/plugins/memory-state.ts
T
Huvee ddddb3dec1 fix(memory-wiki): drop malformed public artifacts and honor readMemoryArtifacts in status (#100900)
Two crashes when a memory plugin misbehaves in bridge mode:

- listActiveMemoryPublicArtifacts sorted plugin-returned artifacts
without
  validating them; an artifact missing any of the string fields the
  comparator dereferences (kind, workspaceDir, relativePath,
absolutePath,
  contentType) crashed wiki status and every other bridge consumer with
  "Cannot read properties of undefined (reading 'localeCompare')".
  @mem0/openclaw-mem0 <= 1.0.14 shipped record-shaped artifacts with
none
  of those fields, typed against a drifted SDK stub. Validate the shape,
  drop malformed entries (and non-array listings), and warn once naming
  the offending plugin -- the same treatment agentIds already got.

- resolveMemoryWikiStatus gated artifact counting on vaultMode/enabled
but
  not bridge.readMemoryArtifacts, so the wiki.status gateway method
still
  enumerated artifacts (and hit the crash above) with the flag off, even
  though the sync path (bridge.ts) and CLI gateway routing honor it. The
  documented workaround therefore never worked for the wiki_status agent
  tool. Add the flag to the gate; the count reports null when imports
are
  disabled, matching non-bridge modes.
2026-07-06 09:14:21 -07:00

394 lines
12 KiB
TypeScript

/** Registry state for plugin memory runtimes, prompt supplements, and flush planning. */
import type { MemoryCitationsMode } from "../config/types.memory.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { MemorySearchManager } from "../memory-host-sdk/host/types.js";
const log = createSubsystemLogger("plugins/memory-state");
export type MemoryPromptSectionBuilder = (params: {
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}) => string[];
export type MemoryCorpusSearchResult = {
corpus: string;
path: string;
title?: string;
kind?: string;
score: number;
snippet: string;
id?: string;
startLine?: number;
endLine?: number;
citation?: string;
source?: string;
provenanceLabel?: string;
sourceType?: string;
sourcePath?: string;
updatedAt?: string;
};
export type MemoryCorpusGetResult = {
corpus: string;
path: string;
title?: string;
kind?: string;
content: string;
fromLine: number;
lineCount: number;
id?: string;
provenanceLabel?: string;
sourceType?: string;
sourcePath?: string;
updatedAt?: string;
};
export type MemoryCorpusSupplement = {
search(params: {
query: string;
maxResults?: number;
agentSessionKey?: string;
}): Promise<MemoryCorpusSearchResult[]>;
get(params: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentSessionKey?: string;
}): Promise<MemoryCorpusGetResult | null>;
};
export type MemoryCorpusSupplementRegistration = {
pluginId: string;
supplement: MemoryCorpusSupplement;
};
export type MemoryPromptSupplementRegistration = {
pluginId: string;
builder: MemoryPromptSectionBuilder;
};
export type MemoryFlushPlan = {
softThresholdTokens: number;
forceFlushTranscriptBytes: number;
reserveTokensFloor: number;
model?: string;
prompt: string;
systemPrompt: string;
relativePath: string;
};
export type MemoryFlushPlanResolver = (params: {
cfg?: OpenClawConfig;
nowMs?: number;
}) => MemoryFlushPlan | null;
export type RegisteredMemorySearchManager = MemorySearchManager;
export type MemoryRuntimeQmdConfig = {
command?: string;
};
export type MemoryRuntimeBackendConfig =
| {
backend: "builtin";
}
| {
backend: "qmd";
qmd?: MemoryRuntimeQmdConfig;
};
export type MemoryPluginRuntime = {
getMemorySearchManager(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status" | "cli";
}): Promise<{
manager: RegisteredMemorySearchManager | null;
debug?: {
backend?: "builtin" | "qmd";
purpose?: "default" | "status" | "cli";
managerMs?: number;
managerCacheState?:
| "cached-full-hit"
| "cached-full-miss"
| "transient-cli"
| "transient-status"
| "pending-create-wait"
| "fallback-builtin"
| "recent-failure-cooldown";
qmdIdentityHash?: string;
failureCode?: "qmd-unavailable";
};
error?: string;
}>;
resolveMemoryBackendConfig(params: {
cfg: OpenClawConfig;
agentId: string;
}): MemoryRuntimeBackendConfig;
closeMemorySearchManager?(params: { cfg: OpenClawConfig; agentId: string }): Promise<void>;
closeAllMemorySearchManagers?(): Promise<void>;
};
export type MemoryPluginPublicArtifactContentType = "markdown" | "json" | "text";
export type MemoryPluginPublicArtifact = {
kind: string;
workspaceDir: string;
relativePath: string;
absolutePath: string;
agentIds: string[];
contentType: MemoryPluginPublicArtifactContentType;
};
export type MemoryPluginPublicArtifactsProvider = {
listArtifacts(params: { cfg: OpenClawConfig }): Promise<MemoryPluginPublicArtifact[]>;
};
export type MemoryPluginCapability = {
promptBuilder?: MemoryPromptSectionBuilder;
flushPlanResolver?: MemoryFlushPlanResolver;
runtime?: MemoryPluginRuntime;
publicArtifacts?: MemoryPluginPublicArtifactsProvider;
};
export type MemoryPluginCapabilityRegistration = {
pluginId: string;
capability: MemoryPluginCapability;
};
const LEGACY_MEMORY_COMPAT_PLUGIN_ID = "legacy-memory-v1";
type MemoryPluginState = {
capability?: MemoryPluginCapabilityRegistration;
corpusSupplements: MemoryCorpusSupplementRegistration[];
promptSupplements: MemoryPromptSupplementRegistration[];
};
const memoryPluginState: MemoryPluginState = {
corpusSupplements: [],
promptSupplements: [],
};
export function registerMemoryCorpusSupplement(
pluginId: string,
supplement: MemoryCorpusSupplement,
): void {
const next = memoryPluginState.corpusSupplements.filter(
(registration) => registration.pluginId !== pluginId,
);
next.push({ pluginId, supplement });
memoryPluginState.corpusSupplements = next;
}
export function registerMemoryCapability(
pluginId: string,
capability: MemoryPluginCapability,
): void {
const existingCapability = memoryPluginState.capability?.capability;
// A selected memory plugin can add bridge artifacts while memory-core owns sidecar runtime hooks.
const shouldPreserveExisting =
existingCapability &&
Boolean(capability.publicArtifacts) &&
!capability.promptBuilder &&
!capability.flushPlanResolver &&
!capability.runtime;
memoryPluginState.capability = {
pluginId,
capability: {
...(shouldPreserveExisting ? existingCapability : {}),
...capability,
},
};
}
function patchMemoryCapability(pluginId: string, patch: MemoryPluginCapability): void {
const current =
memoryPluginState.capability?.pluginId === pluginId
? memoryPluginState.capability.capability
: {};
registerMemoryCapability(pluginId, { ...current, ...patch });
}
export function getMemoryCapabilityRegistration(): MemoryPluginCapabilityRegistration | undefined {
return memoryPluginState.capability
? {
pluginId: memoryPluginState.capability.pluginId,
capability: { ...memoryPluginState.capability.capability },
}
: undefined;
}
export function listMemoryCorpusSupplements(): MemoryCorpusSupplementRegistration[] {
return [...memoryPluginState.corpusSupplements];
}
/** @deprecated Use registerMemoryCapability(pluginId, { promptBuilder }) instead. */
export function registerMemoryPromptSection(builder: MemoryPromptSectionBuilder): void {
registerMemoryPromptSectionForPlugin(LEGACY_MEMORY_COMPAT_PLUGIN_ID, builder);
}
export function registerMemoryPromptSectionForPlugin(
pluginId: string,
builder: MemoryPromptSectionBuilder,
): void {
patchMemoryCapability(pluginId, { promptBuilder: builder });
}
export function registerMemoryPromptSupplement(
pluginId: string,
builder: MemoryPromptSectionBuilder,
): void {
const next = memoryPluginState.promptSupplements.filter(
(registration) => registration.pluginId !== pluginId,
);
next.push({ pluginId, builder });
memoryPluginState.promptSupplements = next;
}
export function buildMemoryPromptSection(params: {
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}): string[] {
const primary = normalizeMemoryPromptLines(
memoryPluginState.capability?.capability.promptBuilder?.(params) ?? [],
);
const supplements = memoryPluginState.promptSupplements
// Keep supplement order stable even if plugin registration order changes.
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId))
.flatMap((registration) => normalizeMemoryPromptLines(registration.builder(params)));
return [...primary, ...supplements];
}
function normalizeMemoryPromptLines(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((line): line is string => typeof line === "string");
}
export function listMemoryPromptSupplements(): MemoryPromptSupplementRegistration[] {
return [...memoryPluginState.promptSupplements];
}
/** @deprecated Use registerMemoryCapability(pluginId, { flushPlanResolver }) instead. */
export function registerMemoryFlushPlanResolver(resolver: MemoryFlushPlanResolver): void {
registerMemoryFlushPlanResolverForPlugin(LEGACY_MEMORY_COMPAT_PLUGIN_ID, resolver);
}
export function registerMemoryFlushPlanResolverForPlugin(
pluginId: string,
resolver: MemoryFlushPlanResolver,
): void {
patchMemoryCapability(pluginId, { flushPlanResolver: resolver });
}
export function resolveMemoryFlushPlan(params: {
cfg?: OpenClawConfig;
nowMs?: number;
}): MemoryFlushPlan | null {
return memoryPluginState.capability?.capability.flushPlanResolver?.(params) ?? null;
}
/** @deprecated Use registerMemoryCapability(pluginId, { runtime }) instead. */
export function registerMemoryRuntime(runtime: MemoryPluginRuntime): void {
registerMemoryRuntimeForPlugin(LEGACY_MEMORY_COMPAT_PLUGIN_ID, runtime);
}
export function registerMemoryRuntimeForPlugin(
pluginId: string,
runtime: MemoryPluginRuntime,
): void {
patchMemoryCapability(pluginId, { runtime });
}
export function getMemoryRuntime(): MemoryPluginRuntime | undefined {
return memoryPluginState.capability?.capability.runtime;
}
export function hasMemoryRuntime(): boolean {
return getMemoryRuntime() !== undefined;
}
function cloneMemoryPublicArtifact(
artifact: MemoryPluginPublicArtifact,
): MemoryPluginPublicArtifact {
const agentIds = Array.isArray(artifact.agentIds) ? artifact.agentIds : [];
return {
...artifact,
agentIds: [...agentIds],
};
}
// The sort below dereferences these fields, so a plugin-supplied artifact
// missing any of them would crash every status/bridge consumer.
function isValidMemoryPublicArtifact(
artifact: MemoryPluginPublicArtifact | null | undefined,
): artifact is MemoryPluginPublicArtifact {
return (
typeof artifact?.kind === "string" &&
typeof artifact.workspaceDir === "string" &&
typeof artifact.relativePath === "string" &&
typeof artifact.absolutePath === "string" &&
typeof artifact.contentType === "string"
);
}
export async function listActiveMemoryPublicArtifacts(params: {
cfg: OpenClawConfig;
}): Promise<MemoryPluginPublicArtifact[]> {
const pluginId = memoryPluginState.capability?.pluginId;
const listed =
(await memoryPluginState.capability?.capability.publicArtifacts?.listArtifacts(params)) ?? [];
if (!Array.isArray(listed)) {
log.warn(`ignoring public memory artifacts from plugin "${pluginId}": not an array`);
return [];
}
const artifacts = listed.filter(isValidMemoryPublicArtifact);
if (artifacts.length < listed.length) {
log.warn(
`ignoring ${listed.length - artifacts.length} malformed public memory artifact(s) from plugin "${pluginId}": artifacts must include string kind, workspaceDir, relativePath, absolutePath, and contentType`,
);
}
return artifacts.map(cloneMemoryPublicArtifact).toSorted((left, right) => {
const workspaceOrder = left.workspaceDir.localeCompare(right.workspaceDir);
if (workspaceOrder !== 0) {
return workspaceOrder;
}
const relativePathOrder = left.relativePath.localeCompare(right.relativePath);
if (relativePathOrder !== 0) {
return relativePathOrder;
}
const kindOrder = left.kind.localeCompare(right.kind);
if (kindOrder !== 0) {
return kindOrder;
}
const contentTypeOrder = left.contentType.localeCompare(right.contentType);
if (contentTypeOrder !== 0) {
return contentTypeOrder;
}
const agentOrder = left.agentIds.join("\0").localeCompare(right.agentIds.join("\0"));
if (agentOrder !== 0) {
return agentOrder;
}
return left.absolutePath.localeCompare(right.absolutePath);
});
}
export function restoreMemoryPluginState(state: MemoryPluginState): void {
memoryPluginState.capability = state.capability
? {
pluginId: state.capability.pluginId,
capability: { ...state.capability.capability },
}
: undefined;
memoryPluginState.corpusSupplements = [...state.corpusSupplements];
memoryPluginState.promptSupplements = [...state.promptSupplements];
}
export function clearMemoryPluginState(): void {
memoryPluginState.capability = undefined;
memoryPluginState.corpusSupplements = [];
memoryPluginState.promptSupplements = [];
}