Files
openclaw/src/plugins/memory-state.ts
Peter Steinberger c7e7ac2728 refactor: remove expired plugin compatibility surfaces (#111451)
* docs(secrets): remove retired web credential paths

* refactor(web): remove retired provider compatibility paths

* refactor(providers): delete retired compatibility routes

* refactor(secrets): remove retired credential aliases

* refactor(plugin-sdk): delete retired compatibility surfaces

* docs(plugin-sdk): remove retired migration guidance

* chore(plugin-sdk): refresh rebased surface budgets

* chore(plugin-sdk): refresh API removal baseline

* refactor(compat): migrate retired internal callers

* chore(plugin-sdk): refresh current-main baselines

* test(config): migrate plugin-owned secret assertions

* test(gateway): narrow plugin secret refs

* fix(plugin-sdk): preserve private boundary type identity

* chore(compat): remove stale sweep references

* chore(lint): lower max-lines budget

* refactor(secrets): remove unused web helper

* build(plugin-sdk): drop removed compat entries

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): use Linux API baseline hash

* fix(plugin-sdk): preserve private bundled build entries

* fix(plugin-sdk): package private runtime facades

* fix(plugins): preserve external credential contracts
2026-07-19 11:04:48 -07:00

506 lines
16 KiB
TypeScript

/** Registry state for plugin memory runtimes, prompt supplements, and flush planning. */
import { AsyncLocalStorage } from "node:async_hooks";
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 MemoryPromptSectionParams = {
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
};
export type MemoryPromptSectionBuilder = (params: MemoryPromptSectionParams) => string[];
/**
* Loads and renders prompt state before synchronous prompt assembly.
* Implementations may perform async state reads here, but must validate their
* owner instance before returning lines for the current run.
*/
type MemoryPromptSectionPreparer = (
params: MemoryPromptSectionParams,
) => Promise<readonly string[]>;
export type PreparedMemoryPromptSection = Readonly<{
context: Readonly<{
availableTools: readonly string[];
citationsMode?: MemoryCitationsMode;
agentId?: string;
agentSessionKey?: string;
sandboxed: boolean;
}>;
lines: readonly 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;
};
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;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
}): Promise<MemoryCorpusSearchResult[]>;
get(params: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
}): Promise<MemoryCorpusGetResult | null>;
};
type MemoryCorpusSupplementRegistration = {
pluginId: string;
supplement: MemoryCorpusSupplement;
};
type MemoryPromptSupplementRegistration = {
pluginId: string;
builder: MemoryPromptSectionBuilder;
};
type MemoryPromptPreparationRegistration = {
pluginId: string;
prepare: MemoryPromptSectionPreparer;
};
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;
type MemoryRuntimeQmdConfig = {
command?: string;
};
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>;
};
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;
};
type MemoryPluginCapabilityRegistration = {
pluginId: string;
capability: MemoryPluginCapability;
};
type MemoryPluginState = {
capability?: MemoryPluginCapabilityRegistration;
corpusSupplements: MemoryCorpusSupplementRegistration[];
promptPreparations: MemoryPromptPreparationRegistration[];
promptSupplements: MemoryPromptSupplementRegistration[];
};
const memoryPluginState: MemoryPluginState = {
corpusSupplements: [],
promptPreparations: [],
promptSupplements: [],
};
const preparedMemoryPromptSections = new WeakSet<PreparedMemoryPromptSection>();
const activePreparedMemoryPromptSection = new AsyncLocalStorage<PreparedMemoryPromptSection>();
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,
},
};
}
export function getMemoryCapabilityRegistration(): MemoryPluginCapabilityRegistration | undefined {
return memoryPluginState.capability
? {
pluginId: memoryPluginState.capability.pluginId,
capability: { ...memoryPluginState.capability.capability },
}
: undefined;
}
export function listMemoryCorpusSupplements(): MemoryCorpusSupplementRegistration[] {
return [...memoryPluginState.corpusSupplements];
}
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 registerMemoryPromptPreparation(
pluginId: string,
prepare: MemoryPromptSectionPreparer,
): void {
const next = memoryPluginState.promptPreparations.filter(
(registration) => registration.pluginId !== pluginId,
);
next.push({ pluginId, prepare });
memoryPluginState.promptPreparations = next;
}
function buildSynchronousMemoryPromptSection(params: MemoryPromptSectionParams): {
primary: string[];
supplements: Array<{ pluginId: string; lines: 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))
.map((registration) => ({
pluginId: registration.pluginId,
lines: normalizeMemoryPromptLines(registration.builder(params)),
}));
return { primary, supplements };
}
function cloneMemoryPromptSectionParams(
params: MemoryPromptSectionParams,
): MemoryPromptSectionParams {
return {
availableTools: new Set(params.availableTools),
citationsMode: params.citationsMode,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
};
}
function snapshotMemoryPromptContext(
params: MemoryPromptSectionParams,
): PreparedMemoryPromptSection["context"] {
return Object.freeze({
availableTools: Object.freeze([...params.availableTools].toSorted()),
citationsMode: params.citationsMode,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed === true,
});
}
function preparedMemoryPromptContextMatches(
prepared: PreparedMemoryPromptSection,
params: MemoryPromptSectionParams,
): boolean {
const current = snapshotMemoryPromptContext(params);
return (
prepared.context.citationsMode === current.citationsMode &&
prepared.context.agentId === current.agentId &&
prepared.context.agentSessionKey === current.agentSessionKey &&
prepared.context.sandboxed === current.sandboxed &&
prepared.context.availableTools.length === current.availableTools.length &&
prepared.context.availableTools.every((tool, index) => tool === current.availableTools[index])
);
}
/** Prepare one immutable memory prompt snapshot for a run. */
export async function prepareMemoryPromptSection(
params: MemoryPromptSectionParams,
): Promise<PreparedMemoryPromptSection> {
const runParams = cloneMemoryPromptSectionParams(params);
const context = snapshotMemoryPromptContext(runParams);
const synchronous = buildSynchronousMemoryPromptSection(
cloneMemoryPromptSectionParams(runParams),
);
const preparationRegistrations = [...memoryPluginState.promptPreparations];
const preparedSupplements = await Promise.all(
preparationRegistrations.map(async (registration) => ({
pluginId: registration.pluginId,
lines: normalizeMemoryPromptLines(
await registration.prepare(cloneMemoryPromptSectionParams(runParams)),
),
})),
);
const lines = Object.freeze([
...synchronous.primary,
...[...synchronous.supplements, ...preparedSupplements]
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId))
.flatMap((registration) => registration.lines),
]);
const prepared = Object.freeze({
context,
lines,
});
preparedMemoryPromptSections.add(prepared);
return prepared;
}
/** Keep async preparation run-scoped while a context engine assembles synchronously. */
export async function runWithPreparedMemoryPromptSection<T>(
params: MemoryPromptSectionParams,
run: () => Promise<T>,
): Promise<T> {
const prepared = await prepareMemoryPromptSection(params);
return activePreparedMemoryPromptSection.run(prepared, run);
}
export function getActivePreparedMemoryPromptSection(): PreparedMemoryPromptSection | undefined {
return activePreparedMemoryPromptSection.getStore();
}
export function buildMemoryPromptSection(
params: MemoryPromptSectionParams,
prepared?: PreparedMemoryPromptSection,
): string[] {
if (prepared) {
// Run-scoped prompt state must never cross agent/session/tool boundaries.
if (
!preparedMemoryPromptSections.has(prepared) ||
!preparedMemoryPromptContextMatches(prepared, params)
) {
throw new Error("prepared memory prompt section does not match the current run");
}
return [...prepared.lines];
}
const synchronous = buildSynchronousMemoryPromptSection(params);
return [...synchronous.primary, ...synchronous.supplements.flatMap((entry) => entry.lines)];
}
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];
}
export function listMemoryPromptPreparations(): MemoryPromptPreparationRegistration[] {
return [...memoryPluginState.promptPreparations];
}
export function resolveMemoryFlushPlan(params: {
cfg?: OpenClawConfig;
nowMs?: number;
}): MemoryFlushPlan | null {
return memoryPluginState.capability?.capability.flushPlanResolver?.(params) ?? null;
}
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.promptPreparations = [...state.promptPreparations];
memoryPluginState.promptSupplements = [...state.promptSupplements];
}
export function clearMemoryPluginState(): void {
memoryPluginState.capability = undefined;
memoryPluginState.corpusSupplements = [];
memoryPluginState.promptPreparations = [];
memoryPluginState.promptSupplements = [];
}