Files
openclaw/extensions/active-memory/config.ts
Peter Steinberger 409d13f94f refactor(active-memory): split runtime modules (#106285)
* refactor(active-memory): split runtime modules

* fix(active-memory): annotate hook deadline result

* refactor(active-memory): keep runtime modules bounded

* fix(active-memory): use type-only recall imports

* refactor(active-memory): supersede partial split

* fix(gateway): type pruned worker manifest
2026-07-13 10:45:01 -07:00

398 lines
12 KiB
TypeScript

import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { isPathInside } from "openclaw/plugin-sdk/security-runtime";
import {
asOptionalRecord as asRecord,
normalizeLowercaseStringOrEmpty,
normalizeStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW,
DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW,
DEFAULT_CACHE_TTL_MS,
DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS,
DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS,
DEFAULT_MAX_SUMMARY_CHARS,
DEFAULT_MIN_TIMEOUT_MS,
DEFAULT_QMD_SEARCH_MODE,
DEFAULT_QUERY_MODE,
DEFAULT_RECENT_ASSISTANT_CHARS,
DEFAULT_RECENT_ASSISTANT_TURNS,
DEFAULT_RECENT_USER_CHARS,
DEFAULT_RECENT_USER_TURNS,
DEFAULT_SETUP_GRACE_TIMEOUT_MS,
DEFAULT_TIMEOUT_MS,
DEFAULT_TRANSCRIPT_DIR,
LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW,
MAX_ACTIVE_MEMORY_TOOLS_ALLOW,
MAX_SETUP_GRACE_TIMEOUT_MS,
MAX_TIMEOUT_MS,
type ActiveMemoryChatType,
type ActiveMemoryPromptStyle,
type ActiveMemoryQmdSearchMode,
type ActiveMemoryThinkingLevel,
type ActiveRecallPluginConfig,
type ResolvedActiveRecallPluginConfig,
} from "./types.js";
let minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
let setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
function parseOptionalPositiveInt(value: unknown, fallback: number): number {
const parsed =
typeof value === "number"
? value
: typeof value === "string"
? parseStrictPositiveInteger(value)
: Number.NaN;
return parsed !== undefined && Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.floor(value as number)));
}
function normalizeTranscriptDir(value: unknown): string {
const raw = typeof value === "string" ? value.trim() : "";
if (!raw) {
return DEFAULT_TRANSCRIPT_DIR;
}
const normalized = raw.replace(/\\/g, "/");
const parts = normalized.split("/").map((part) => part.trim());
const safeParts = parts.filter((part) => part.length > 0 && part !== "." && part !== "..");
return safeParts.length > 0 ? path.join(...safeParts) : DEFAULT_TRANSCRIPT_DIR;
}
function normalizeChatIdList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const out: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim().toLowerCase();
if (!trimmed) {
continue;
}
if (seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
out.push(trimmed);
}
return out;
}
function normalizeConfiguredToolsAllow(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const seen = new Set<string>();
const out: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const normalized = normalizeLowercaseStringOrEmpty(entry);
if (!normalized || isReservedActiveMemoryToolsAllowEntry(normalized) || seen.has(normalized)) {
continue;
}
seen.add(normalized);
out.push(normalized);
if (out.length >= MAX_ACTIVE_MEMORY_TOOLS_ALLOW) {
break;
}
}
return out.length > 0 ? out : undefined;
}
function isReservedActiveMemoryToolsAllowEntry(value: string): boolean {
const normalized = value.trim().toLowerCase();
return normalized.startsWith("group:") || ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW.has(normalized);
}
function resolveDefaultToolsAllow(cfg: OpenClawConfig | undefined): string[] {
return cfg?.plugins?.slots?.memory === "memory-lancedb"
? [...LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW]
: [...DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW];
}
function resolveToolsAllow(params: { pluginToolsAllow: unknown; cfg?: OpenClawConfig }): string[] {
return (
normalizeConfiguredToolsAllow(params.pluginToolsAllow) ?? resolveDefaultToolsAllow(params.cfg)
);
}
function normalizePromptConfigText(value: unknown): string | undefined {
const text = typeof value === "string" ? value.trim() : "";
return text ? text : undefined;
}
function resolveQmdSearchMode(value: unknown): ActiveMemoryQmdSearchMode {
if (value === "inherit" || value === "search" || value === "vsearch" || value === "query") {
return value;
}
return DEFAULT_QMD_SEARCH_MODE;
}
function hasDeprecatedModelFallbackPolicy(pluginConfig: unknown): boolean {
const raw = asRecord(pluginConfig);
return raw ? Object.hasOwn(raw, "modelFallbackPolicy") : false;
}
function resolveSafeTranscriptDir(baseSessionsDir: string, transcriptDir: string): string {
const normalized = transcriptDir.trim();
if (!normalized || normalized.includes(":") || path.isAbsolute(normalized)) {
return path.resolve(baseSessionsDir, DEFAULT_TRANSCRIPT_DIR);
}
const resolvedBase = path.resolve(baseSessionsDir);
const candidate = path.resolve(resolvedBase, normalized);
if (!isPathInside(resolvedBase, candidate)) {
return path.resolve(resolvedBase, DEFAULT_TRANSCRIPT_DIR);
}
return candidate;
}
function toSafeTranscriptAgentDirName(agentId: string): string {
const encoded = encodeURIComponent(agentId.trim());
return encoded ? encoded : "unknown-agent";
}
function resolvePersistentTranscriptBaseDir(api: OpenClawPluginApi, agentId: string): string {
return path.join(
api.runtime.state.resolveStateDir(),
"plugins",
"active-memory",
"transcripts",
"agents",
toSafeTranscriptAgentDirName(agentId),
);
}
function requireTransientWorkspaceDir(tempDir: string | undefined): string {
if (!tempDir) {
throw new Error("Active memory transient workspace was not initialized.");
}
return tempDir;
}
function formatRuntimeToolsAllowSource(toolsAllow: readonly string[]): string {
return `runtime toolsAllow: ${toolsAllow.join(", ")}`;
}
function isMissingRegisteredMemoryToolsError(
error: unknown,
toolsAllow: readonly string[] = DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW,
): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.trim();
const prefix = "No callable tools remain after resolving explicit tool allowlist (";
const suffix =
"); no registered tools matched. Fix the allowlist or enable the plugin that registers the requested tool.";
if (!message.startsWith(prefix) || !message.endsWith(suffix)) {
return false;
}
const sources = message.slice(prefix.length, -suffix.length);
const runtimeSource = formatRuntimeToolsAllowSource(toolsAllow);
const sourceParts = sources
.split(";")
.map((source) => source.trim())
.filter(Boolean);
return sourceParts.includes(runtimeSource);
}
function normalizePluginConfig(
pluginConfig: unknown,
cfg?: OpenClawConfig,
): ResolvedActiveRecallPluginConfig {
const raw = (
pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {}
) as ActiveRecallPluginConfig;
const qmd = asRecord(raw.qmd);
const allowedChatTypes = Array.isArray(raw.allowedChatTypes)
? raw.allowedChatTypes.filter(
(value): value is ActiveMemoryChatType =>
value === "direct" || value === "group" || value === "channel" || value === "explicit",
)
: [];
return {
enabled: raw.enabled !== false,
agents: Array.isArray(raw.agents) ? normalizeStringEntries(raw.agents) : [],
model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : undefined,
modelFallback:
typeof raw.modelFallback === "string" && raw.modelFallback.trim()
? raw.modelFallback.trim()
: undefined,
modelFallbackPolicy:
raw.modelFallbackPolicy === "resolved-only" ? "resolved-only" : "default-remote",
allowedChatTypes: allowedChatTypes.length > 0 ? allowedChatTypes : ["direct"],
allowedChatIds: normalizeChatIdList(raw.allowedChatIds),
deniedChatIds: normalizeChatIdList(raw.deniedChatIds),
thinking: resolveThinkingLevel(raw.thinking),
promptStyle: resolvePromptStyle(raw.promptStyle, raw.queryMode),
toolsAllow: resolveToolsAllow({ pluginToolsAllow: raw.toolsAllow, cfg }),
promptOverride: normalizePromptConfigText(raw.promptOverride),
promptAppend: normalizePromptConfigText(raw.promptAppend),
timeoutMs: clampInt(
parseOptionalPositiveInt(raw.timeoutMs, DEFAULT_TIMEOUT_MS),
DEFAULT_TIMEOUT_MS,
minimumTimeoutMs,
MAX_TIMEOUT_MS,
),
setupGraceTimeoutMs: clampInt(
raw.setupGraceTimeoutMs,
setupGraceTimeoutMs,
0,
MAX_SETUP_GRACE_TIMEOUT_MS,
),
queryMode:
raw.queryMode === "message" || raw.queryMode === "recent" || raw.queryMode === "full"
? raw.queryMode
: DEFAULT_QUERY_MODE,
maxSummaryChars: clampInt(raw.maxSummaryChars, DEFAULT_MAX_SUMMARY_CHARS, 40, 1000),
recentUserTurns: clampInt(raw.recentUserTurns, DEFAULT_RECENT_USER_TURNS, 0, 4),
recentAssistantTurns: clampInt(raw.recentAssistantTurns, DEFAULT_RECENT_ASSISTANT_TURNS, 0, 3),
recentUserChars: clampInt(raw.recentUserChars, DEFAULT_RECENT_USER_CHARS, 40, 1000),
recentAssistantChars: clampInt(
raw.recentAssistantChars,
DEFAULT_RECENT_ASSISTANT_CHARS,
40,
1000,
),
logging: raw.logging === true,
cacheTtlMs: clampInt(raw.cacheTtlMs, DEFAULT_CACHE_TTL_MS, 1000, 120_000),
circuitBreakerMaxTimeouts: clampInt(
raw.circuitBreakerMaxTimeouts,
DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS,
1,
20,
),
circuitBreakerCooldownMs: clampInt(
raw.circuitBreakerCooldownMs,
DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS,
5000,
600_000,
),
persistTranscripts: raw.persistTranscripts === true,
transcriptDir: normalizeTranscriptDir(raw.transcriptDir),
qmd: {
searchMode: resolveQmdSearchMode(qmd?.searchMode),
},
};
}
function applyActiveMemoryRuntimeConfigSnapshot(
cfg: OpenClawConfig,
pluginConfig: ResolvedActiveRecallPluginConfig,
): OpenClawConfig {
const existingEntry = asRecord(cfg.plugins?.entries?.["active-memory"]);
const existingPluginConfig = asRecord(existingEntry?.config);
return {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
"active-memory": {
...existingEntry,
config: {
...existingPluginConfig,
qmd: {
...asRecord(existingPluginConfig?.qmd),
searchMode: pluginConfig.qmd.searchMode,
},
},
},
},
},
};
}
function resolveActiveMemoryCleanupConfig(api: OpenClawPluginApi): OpenClawConfig | undefined {
try {
return (
(api.runtime.config?.current?.() as OpenClawConfig | undefined) ??
(api.config as OpenClawConfig | undefined)
);
} catch {
return api.config as OpenClawConfig | undefined;
}
}
function resolveThinkingLevel(thinking: unknown): ActiveMemoryThinkingLevel {
if (
thinking === "off" ||
thinking === "minimal" ||
thinking === "low" ||
thinking === "medium" ||
thinking === "high" ||
thinking === "xhigh" ||
thinking === "adaptive" ||
thinking === "max"
) {
return thinking;
}
return "off";
}
function resolvePromptStyle(
promptStyle: unknown,
queryMode: ActiveRecallPluginConfig["queryMode"],
): ActiveMemoryPromptStyle {
if (
promptStyle === "balanced" ||
promptStyle === "strict" ||
promptStyle === "contextual" ||
promptStyle === "recall-heavy" ||
promptStyle === "precision-heavy" ||
promptStyle === "preference-only"
) {
return promptStyle;
}
if (queryMode === "message") {
return "strict";
}
if (queryMode === "full") {
return "contextual";
}
return "balanced";
}
function resetActiveMemoryConfigForTests(): void {
minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
}
function setMinimumTimeoutMsForTests(value: number): void {
minimumTimeoutMs = value;
}
function setSetupGraceTimeoutMsForTests(value: number): void {
setupGraceTimeoutMs = Math.max(0, Math.floor(value));
}
export {
applyActiveMemoryRuntimeConfigSnapshot,
clampInt,
hasDeprecatedModelFallbackPolicy,
isMissingRegisteredMemoryToolsError,
normalizePluginConfig,
requireTransientWorkspaceDir,
resetActiveMemoryConfigForTests,
resolveActiveMemoryCleanupConfig,
resolvePersistentTranscriptBaseDir,
resolveSafeTranscriptDir,
setMinimumTimeoutMsForTests,
setSetupGraceTimeoutMsForTests,
};