mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
fa03d9b913
* 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
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import crypto from "node:crypto";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { resolveRememberAcrossConversations } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
|
import {
|
|
normalizePluginsConfig,
|
|
resolvePluginConfigObject,
|
|
} from "openclaw/plugin-sdk/plugin-config-runtime";
|
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing";
|
|
import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { resolveCanonicalSessionKeyFromSessionId } from "./session.js";
|
|
import {
|
|
DEFAULT_AGENT_ID,
|
|
type ActiveMemoryChatType,
|
|
type ActiveMemoryToggleEntry,
|
|
type ResolvedActiveRecallPluginConfig,
|
|
} from "./types.js";
|
|
|
|
function activeMemoryToggleKey(sessionKey: string): string {
|
|
return crypto.createHash("sha256").update(sessionKey, "utf8").digest("hex");
|
|
}
|
|
|
|
function openActiveMemoryToggleStore(api: OpenClawPluginApi) {
|
|
return api.runtime.state.openKeyedStore<ActiveMemoryToggleEntry>({
|
|
namespace: "session-toggles",
|
|
maxEntries: 10_000,
|
|
});
|
|
}
|
|
|
|
async function isSessionActiveMemoryDisabled(params: {
|
|
api: OpenClawPluginApi;
|
|
sessionKey?: string;
|
|
}): Promise<boolean> {
|
|
const sessionKey = params.sessionKey?.trim();
|
|
if (!sessionKey) {
|
|
return false;
|
|
}
|
|
try {
|
|
const store = openActiveMemoryToggleStore(params.api);
|
|
const key = activeMemoryToggleKey(sessionKey);
|
|
const stored = await store.lookup(key);
|
|
if (stored?.disabled === true) {
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
params.api.logger.debug?.(
|
|
`active-memory: failed to read session toggle (${error instanceof Error ? error.message : String(error)})`,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function setSessionActiveMemoryDisabled(params: {
|
|
api: OpenClawPluginApi;
|
|
sessionKey: string;
|
|
disabled: boolean;
|
|
}): Promise<void> {
|
|
const store = openActiveMemoryToggleStore(params.api);
|
|
if (params.disabled) {
|
|
await store.register(activeMemoryToggleKey(params.sessionKey), {
|
|
sessionKey: params.sessionKey,
|
|
disabled: true,
|
|
updatedAt: Date.now(),
|
|
});
|
|
} else {
|
|
await store.delete(activeMemoryToggleKey(params.sessionKey));
|
|
}
|
|
}
|
|
|
|
function resolveCommandSessionKey(params: {
|
|
api: OpenClawPluginApi;
|
|
config: ResolvedActiveRecallPluginConfig;
|
|
sessionKey?: string;
|
|
sessionId?: string;
|
|
}): string | undefined {
|
|
const explicit = params.sessionKey?.trim();
|
|
if (explicit) {
|
|
return explicit;
|
|
}
|
|
const configuredAgents =
|
|
params.config.agents.length > 0 ? params.config.agents : [DEFAULT_AGENT_ID];
|
|
for (const agentId of configuredAgents) {
|
|
const sessionKey = resolveCanonicalSessionKeyFromSessionId({
|
|
api: params.api,
|
|
agentId,
|
|
sessionId: params.sessionId,
|
|
});
|
|
if (sessionKey) {
|
|
return sessionKey;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function formatActiveMemoryCommandHelp(): string {
|
|
return [
|
|
"Active Memory session toggle:",
|
|
"/active-memory status",
|
|
"/active-memory on",
|
|
"/active-memory off",
|
|
"",
|
|
"Global config toggle:",
|
|
"/active-memory status --global",
|
|
"/active-memory on --global",
|
|
"/active-memory off --global",
|
|
].join("\n");
|
|
}
|
|
|
|
function isActiveMemoryGloballyEnabled(cfg: OpenClawConfig): boolean {
|
|
const entry = asOptionalRecord(cfg.plugins?.entries?.["active-memory"]);
|
|
if (entry?.enabled === false) {
|
|
return false;
|
|
}
|
|
const pluginConfig = resolvePluginConfigObject(cfg, "active-memory");
|
|
return pluginConfig?.enabled !== false;
|
|
}
|
|
|
|
function isActiveMemoryPluginEnabled(cfg: OpenClawConfig): boolean {
|
|
const plugins = normalizePluginsConfig(cfg.plugins);
|
|
if (!plugins.enabled || plugins.deny.includes("active-memory")) {
|
|
return false;
|
|
}
|
|
if (plugins.allow.length > 0 && !plugins.allow.includes("active-memory")) {
|
|
return false;
|
|
}
|
|
return plugins.entries["active-memory"]?.enabled !== false;
|
|
}
|
|
|
|
function hasRememberAcrossConversationsAgent(cfg: OpenClawConfig): boolean {
|
|
const configuredAgentIds = cfg.agents?.list?.map((agent) => agent.id) ?? [];
|
|
const agentIds = configuredAgentIds.length > 0 ? configuredAgentIds : ["main"];
|
|
return agentIds.some((agentId) => resolveRememberAcrossConversations(cfg, agentId));
|
|
}
|
|
|
|
function shouldRememberAcrossConversations(cfg: OpenClawConfig, agentId: string): boolean {
|
|
return resolveRememberAcrossConversations(cfg, agentId);
|
|
}
|
|
|
|
function updateActiveMemoryGlobalEnabledInConfig(
|
|
cfg: OpenClawConfig,
|
|
enabled: boolean,
|
|
): OpenClawConfig {
|
|
const entries = { ...cfg.plugins?.entries };
|
|
const existingEntry = asOptionalRecord(entries["active-memory"]) ?? {};
|
|
const existingConfig = asOptionalRecord(existingEntry.config) ?? {};
|
|
entries["active-memory"] = {
|
|
...existingEntry,
|
|
enabled: true,
|
|
config: {
|
|
...existingConfig,
|
|
enabled,
|
|
},
|
|
};
|
|
|
|
return {
|
|
...cfg,
|
|
plugins: {
|
|
...cfg.plugins,
|
|
entries,
|
|
},
|
|
};
|
|
}
|
|
|
|
function lacksAdminToMutateActiveMemoryGlobal(params: {
|
|
senderIsOwner?: boolean;
|
|
gatewayClientScopes?: readonly string[];
|
|
}): boolean {
|
|
if (Array.isArray(params.gatewayClientScopes)) {
|
|
return !params.gatewayClientScopes.includes("operator.admin");
|
|
}
|
|
return params.senderIsOwner !== true;
|
|
}
|
|
|
|
const ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT =
|
|
"⚠️ /active-memory global enable/disable changes require owner or operator.admin.";
|
|
|
|
function isEnabledForAgent(
|
|
config: ResolvedActiveRecallPluginConfig,
|
|
agentId: string | undefined,
|
|
): boolean {
|
|
if (!config.enabled) {
|
|
return false;
|
|
}
|
|
if (!agentId) {
|
|
return false;
|
|
}
|
|
return config.agents.includes(agentId);
|
|
}
|
|
|
|
function isAgentHarnessSessionKey(sessionKey: string): boolean {
|
|
const normalized = sessionKey.trim().toLowerCase();
|
|
const rest = parseAgentSessionKey(normalized)?.rest ?? normalized;
|
|
return rest.startsWith("harness:");
|
|
}
|
|
|
|
function shouldSkipActiveMemoryForHarnessSession(params: {
|
|
api: OpenClawPluginApi;
|
|
agentId?: string;
|
|
sessionKey?: string;
|
|
}): boolean {
|
|
const sessionKey = params.sessionKey?.trim();
|
|
if (!sessionKey) {
|
|
return false;
|
|
}
|
|
try {
|
|
const entry = params.api.runtime.agent.session.getSessionEntry({
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
sessionKey,
|
|
readConsistency: "latest",
|
|
});
|
|
// A missing reserved key must not synthesize work, while unlocked rows are
|
|
// grandfathered user sessions from before the namespace was introduced.
|
|
return (
|
|
entry?.modelSelectionLocked === true ||
|
|
(entry === undefined && isAgentHarnessSessionKey(sessionKey))
|
|
);
|
|
} catch {
|
|
// Recall is optional. If durable ownership cannot be checked, do not risk
|
|
// crossing a harness/model boundary with an independently selected model.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function isEligibleInteractiveSession(ctx: {
|
|
trigger?: string;
|
|
sessionKey?: string;
|
|
sessionId?: string;
|
|
messageProvider?: string;
|
|
channelId?: string;
|
|
}): boolean {
|
|
if (ctx.trigger !== "user") {
|
|
return false;
|
|
}
|
|
// Exclude only canonical dreaming-narrative session keys (bare or agent-prefixed).
|
|
// Canonical forms: "dreaming-narrative-<phase>-<hash>" or
|
|
// "agent:<agentId>:dreaming-narrative-<phase>-<hash>".
|
|
// A colon-delimited match would also exclude real chat session ids whose peer id
|
|
// begins with a phased dreaming-narrative phrase (e.g.
|
|
// "agent:main:feishu:group:dreaming-narrative-light-room").
|
|
const sessionKey = ctx.sessionKey ?? "";
|
|
if (
|
|
/^dreaming-narrative-(light|rem|deep)-/i.test(sessionKey) ||
|
|
/^agent:[^:]+:dreaming-narrative-(light|rem|deep)-/i.test(sessionKey)
|
|
) {
|
|
return false;
|
|
}
|
|
if (!ctx.sessionKey && !ctx.sessionId) {
|
|
return false;
|
|
}
|
|
const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
|
|
if (provider === "webchat") {
|
|
return true;
|
|
}
|
|
return Boolean(ctx.channelId && ctx.channelId.trim());
|
|
}
|
|
|
|
function resolveChatType(ctx: {
|
|
sessionKey?: string;
|
|
messageProvider?: string;
|
|
channelId?: string;
|
|
mainKey?: string;
|
|
}): ActiveMemoryChatType | undefined {
|
|
const rawSessionKey = ctx.sessionKey?.trim();
|
|
const { baseSessionKey } = parseThreadSessionSuffix(rawSessionKey);
|
|
const sessionKey = (baseSessionKey ?? rawSessionKey)?.trim().toLowerCase();
|
|
if (sessionKey) {
|
|
if (sessionKey.startsWith("agent:") && sessionKey.split(":")[2] === "explicit") {
|
|
return "explicit";
|
|
}
|
|
if (sessionKey.includes(":group:")) {
|
|
return "group";
|
|
}
|
|
if (sessionKey.includes(":channel:")) {
|
|
return "channel";
|
|
}
|
|
if (sessionKey.includes(":direct:") || sessionKey.includes(":dm:")) {
|
|
return "direct";
|
|
}
|
|
const mainKey = ctx.mainKey?.trim().toLowerCase() || "main";
|
|
const agentSessionParts = sessionKey.split(":");
|
|
if (
|
|
agentSessionParts.length === 3 &&
|
|
agentSessionParts[0] === "agent" &&
|
|
(agentSessionParts[2] === mainKey || agentSessionParts[2] === "main")
|
|
) {
|
|
const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
|
|
const channelId = (ctx.channelId ?? "").trim();
|
|
if (provider && provider !== "webchat" && channelId) {
|
|
return "direct";
|
|
}
|
|
}
|
|
}
|
|
const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
|
|
if (provider === "webchat") {
|
|
return "direct";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function isAllowedChatType(
|
|
config: ResolvedActiveRecallPluginConfig,
|
|
ctx: {
|
|
sessionKey?: string;
|
|
messageProvider?: string;
|
|
channelId?: string;
|
|
mainKey?: string;
|
|
},
|
|
): boolean {
|
|
const chatType = resolveChatType(ctx);
|
|
if (!chatType) {
|
|
return false;
|
|
}
|
|
return config.allowedChatTypes.includes(chatType);
|
|
}
|
|
|
|
function isPrivateRecallDestination(ctx: {
|
|
sessionKey?: string;
|
|
messageProvider?: string;
|
|
channelId?: string;
|
|
mainKey?: string;
|
|
}): boolean {
|
|
const chatType = resolveChatType(ctx);
|
|
return chatType === "direct" || chatType === "explicit";
|
|
}
|
|
|
|
/**
|
|
* Best-effort extraction of the conversation id (peer id) embedded in an
|
|
* agent-scoped session key, using shared session-key utilities so we
|
|
* stay aligned with the canonical key shapes produced by
|
|
* `buildAgentPeerSessionKey` / `resolveThreadSessionKeys`.
|
|
*
|
|
* Supported shapes (after stripping the optional `:thread:<id>` suffix):
|
|
* - agent:<agentId>:direct:<peerId> (dmScope=per-peer)
|
|
* - agent:<agentId>:<channel>:direct:<peerId> (dmScope=per-channel-peer)
|
|
* - agent:<agentId>:<channel>:<accountId>:direct:<peerId> (dmScope=per-account-channel-peer)
|
|
* - agent:<agentId>:<channel>:group:<peerId> (group)
|
|
* - agent:<agentId>:<channel>:channel:<peerId> (channel)
|
|
*
|
|
* The legacy `dm` token is also accepted for backwards compatibility.
|
|
*
|
|
* Returns undefined for sessions that do not embed a peer id (for
|
|
* example dmScope=main `agent:<agentId>:<mainKey>` sessions, or any
|
|
* non-canonical session key shape).
|
|
*/
|
|
function resolveConversationId(ctx: {
|
|
sessionKey?: string;
|
|
messageProvider?: string;
|
|
}): string | undefined {
|
|
const rawSessionKey = ctx.sessionKey?.trim();
|
|
if (!rawSessionKey) {
|
|
return undefined;
|
|
}
|
|
// Strip generic `:thread:<id>` suffix first so threaded sessions match
|
|
// the same conversation id as their non-threaded parent. Provider-
|
|
// specific topic ids (e.g. Telegram/Feishu) that are baked into the
|
|
// peer id by the channel adapter are preserved.
|
|
const { baseSessionKey } = parseThreadSessionSuffix(rawSessionKey);
|
|
const baseKey = (baseSessionKey ?? rawSessionKey).trim();
|
|
if (!baseKey) {
|
|
return undefined;
|
|
}
|
|
const parsed = parseAgentSessionKey(baseKey);
|
|
if (!parsed) {
|
|
return undefined;
|
|
}
|
|
const restParts = parsed.rest.split(":").filter(Boolean);
|
|
if (restParts.length < 2) {
|
|
// `agent:<agentId>:<mainKey>` (dmScope=main) lands here — there is
|
|
// no embedded peer id to filter against.
|
|
return undefined;
|
|
}
|
|
// Walk left-to-right until we hit the first chat-type marker. Every
|
|
// canonical peer key terminates with `<chatType>:<peerId...>`, so the
|
|
// tail after the first marker is the conversation id we want.
|
|
for (let index = 0; index < restParts.length - 1; index += 1) {
|
|
const token = restParts[index];
|
|
if (token === "direct" || token === "dm" || token === "group" || token === "channel") {
|
|
const tail = restParts
|
|
.slice(index + 1)
|
|
.join(":")
|
|
.trim();
|
|
return tail || undefined;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Apply allowedChatIds / deniedChatIds filters after the chat type check
|
|
* has already passed. Empty allowedChatIds means "no allowlist" and this
|
|
* function returns true for any conversation. Empty deniedChatIds is also
|
|
* a no-op.
|
|
*
|
|
* When allowedChatIds is non-empty but the session key does not expose a
|
|
* conversation id (e.g. webchat default session), the session is skipped
|
|
* to avoid accidentally running against an unknown conversation.
|
|
*/
|
|
function isAllowedChatId(
|
|
config: ResolvedActiveRecallPluginConfig,
|
|
ctx: {
|
|
sessionKey?: string;
|
|
messageProvider?: string;
|
|
channelId?: string;
|
|
},
|
|
): boolean {
|
|
const hasAllowlist = config.allowedChatIds.length > 0;
|
|
const hasDenylist = config.deniedChatIds.length > 0;
|
|
if (!hasAllowlist && !hasDenylist) {
|
|
return true;
|
|
}
|
|
// dmScope=main direct sessions omit the peer id from the key. Fall back to
|
|
// the trusted hook chat id so allow/deny lists still apply.
|
|
const conversationId =
|
|
(resolveConversationId(ctx) ?? ctx.channelId?.trim())?.toLowerCase() || undefined;
|
|
if (hasAllowlist) {
|
|
if (!conversationId) {
|
|
return false;
|
|
}
|
|
if (!config.allowedChatIds.includes(conversationId)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (hasDenylist && conversationId && config.deniedChatIds.includes(conversationId)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export {
|
|
ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT,
|
|
formatActiveMemoryCommandHelp,
|
|
isActiveMemoryGloballyEnabled,
|
|
isActiveMemoryPluginEnabled,
|
|
isAllowedChatId,
|
|
isAllowedChatType,
|
|
isEligibleInteractiveSession,
|
|
isEnabledForAgent,
|
|
isPrivateRecallDestination,
|
|
isSessionActiveMemoryDisabled,
|
|
hasRememberAcrossConversationsAgent,
|
|
lacksAdminToMutateActiveMemoryGlobal,
|
|
resolveCommandSessionKey,
|
|
setSessionActiveMemoryDisabled,
|
|
shouldSkipActiveMemoryForHarnessSession,
|
|
shouldRememberAcrossConversations,
|
|
updateActiveMemoryGlobalEnabledInConfig,
|
|
};
|