mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 01:01:37 +00:00
* 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
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import {
|
|
ACTIVE_MEMORY_DEBUG_PREFIX,
|
|
ACTIVE_MEMORY_STATUS_PREFIX,
|
|
type ActiveMemorySearchDebug,
|
|
type ActiveRecallResult,
|
|
type PluginDebugEntry,
|
|
type ResolvedActiveRecallPluginConfig,
|
|
} from "./types.js";
|
|
|
|
function resolveCanonicalSessionKeyFromSessionId(params: {
|
|
api: OpenClawPluginApi;
|
|
agentId: string;
|
|
sessionId?: string;
|
|
}): string | undefined {
|
|
const sessionId = params.sessionId?.trim();
|
|
if (!sessionId) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
let bestMatch:
|
|
| {
|
|
sessionKey: string;
|
|
updatedAt: number;
|
|
}
|
|
| undefined;
|
|
for (const { sessionKey, entry } of params.api.runtime.agent.session.listSessionEntries({
|
|
agentId: params.agentId,
|
|
})) {
|
|
if (!entry || typeof entry !== "object") {
|
|
continue;
|
|
}
|
|
const candidateSessionId =
|
|
typeof (entry as { sessionId?: unknown }).sessionId === "string"
|
|
? (entry as { sessionId?: string }).sessionId?.trim()
|
|
: "";
|
|
if (!candidateSessionId || candidateSessionId !== sessionId) {
|
|
continue;
|
|
}
|
|
const updatedAt =
|
|
typeof (entry as { updatedAt?: unknown }).updatedAt === "number"
|
|
? ((entry as { updatedAt?: number }).updatedAt ?? 0)
|
|
: 0;
|
|
if (!bestMatch || updatedAt > bestMatch.updatedAt) {
|
|
bestMatch = { sessionKey, updatedAt };
|
|
}
|
|
}
|
|
return bestMatch?.sessionKey?.trim() || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function resolveRecallRunChannelContext(params: {
|
|
api: OpenClawPluginApi;
|
|
agentId: string;
|
|
sessionKey?: string;
|
|
sessionId?: string;
|
|
messageProvider?: string;
|
|
channelId?: string;
|
|
}): {
|
|
messageChannel?: string;
|
|
messageProvider?: string;
|
|
} {
|
|
const isRunnableChannelName = (channel: string) =>
|
|
!channel.includes(":") && !channel.includes("/");
|
|
const explicitChannel = normalizeOptionalString(params.channelId);
|
|
const explicitProvider = normalizeOptionalString(params.messageProvider);
|
|
// A channelId that contains ":" is a scoped conversation id (e.g. Telegram
|
|
// forum-topic "-100123:topic:77") or "/" (e.g. Google Chat "spaces/...") is
|
|
// not a runnable channel name. Using it as the embedded recall run's channel
|
|
// causes bundled-plugin dirName validation to throw (#76704, #78918).
|
|
const runnableExplicitChannel =
|
|
explicitChannel && isRunnableChannelName(explicitChannel) ? explicitChannel : undefined;
|
|
// Non-webchat providers often pass a raw conversation id as channelId.
|
|
// Keep those ids for filtering, but run the recall sub-agent through the provider.
|
|
const trustedExplicitChannel =
|
|
runnableExplicitChannel &&
|
|
runnableExplicitChannel !== explicitProvider &&
|
|
(!explicitProvider || explicitProvider === "webchat")
|
|
? runnableExplicitChannel
|
|
: undefined;
|
|
const resolveReturnValue = (paramsLocal: {
|
|
resolvedChannel?: string;
|
|
resolvedChannelStrength?: "strong" | "weak";
|
|
}) => {
|
|
const trustedResolvedChannel =
|
|
paramsLocal.resolvedChannelStrength === "strong" ? paramsLocal.resolvedChannel : undefined;
|
|
return {
|
|
messageChannel:
|
|
trustedExplicitChannel ??
|
|
trustedResolvedChannel ??
|
|
explicitProvider ??
|
|
runnableExplicitChannel ??
|
|
paramsLocal.resolvedChannel,
|
|
messageProvider:
|
|
trustedExplicitChannel ??
|
|
trustedResolvedChannel ??
|
|
explicitProvider ??
|
|
runnableExplicitChannel ??
|
|
paramsLocal.resolvedChannel,
|
|
};
|
|
};
|
|
const resolvedSessionKey =
|
|
normalizeOptionalString(params.sessionKey) ??
|
|
resolveCanonicalSessionKeyFromSessionId({
|
|
api: params.api,
|
|
agentId: params.agentId,
|
|
sessionId: params.sessionId,
|
|
});
|
|
if (!resolvedSessionKey) {
|
|
return resolveReturnValue({});
|
|
}
|
|
|
|
try {
|
|
const sessionEntry = params.api.runtime.agent.session.getSessionEntry({
|
|
agentId: params.agentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
const rawStrongEntryChannel =
|
|
normalizeOptionalString(sessionEntry?.lastChannel) ??
|
|
normalizeOptionalString(sessionEntry?.channel);
|
|
// Channel IDs containing ":" or "/" are scoped conversation IDs, not
|
|
// runnable channel names. The same guard that
|
|
// applies to explicit channelId (#76704) must also apply to channels
|
|
// read from the session store (#77396).
|
|
const strongEntryChannel =
|
|
rawStrongEntryChannel && isRunnableChannelName(rawStrongEntryChannel)
|
|
? rawStrongEntryChannel
|
|
: undefined;
|
|
const weakEntryChannel = normalizeOptionalString(sessionEntry?.origin?.provider);
|
|
return resolveReturnValue({
|
|
resolvedChannel: strongEntryChannel ?? weakEntryChannel,
|
|
resolvedChannelStrength: strongEntryChannel
|
|
? "strong"
|
|
: weakEntryChannel
|
|
? "weak"
|
|
: undefined,
|
|
});
|
|
} catch {
|
|
return resolveReturnValue({});
|
|
}
|
|
}
|
|
|
|
function resolveStatusUpdateAgentId(ctx: { agentId?: string; sessionKey?: string }): string {
|
|
const explicit = ctx.agentId?.trim();
|
|
if (explicit) {
|
|
return explicit;
|
|
}
|
|
const sessionKey = ctx.sessionKey?.trim();
|
|
if (!sessionKey) {
|
|
return "";
|
|
}
|
|
const match = /^agent:([^:]+):/i.exec(sessionKey);
|
|
return match?.[1]?.trim() ?? "";
|
|
}
|
|
|
|
function formatElapsedMsCompact(elapsedMs: number): string {
|
|
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) {
|
|
return "0ms";
|
|
}
|
|
if (elapsedMs >= 1000) {
|
|
const seconds = elapsedMs / 1000;
|
|
return `${seconds % 1 === 0 ? seconds.toFixed(0) : seconds.toFixed(1)}s`;
|
|
}
|
|
return `${Math.round(elapsedMs)}ms`;
|
|
}
|
|
|
|
function buildPluginStatusLine(params: {
|
|
result: ActiveRecallResult;
|
|
config: ResolvedActiveRecallPluginConfig;
|
|
}): string {
|
|
const parts = [
|
|
ACTIVE_MEMORY_STATUS_PREFIX,
|
|
`status=${params.result.status}`,
|
|
`elapsed=${formatElapsedMsCompact(params.result.elapsedMs)}`,
|
|
`query=${params.config.queryMode}`,
|
|
];
|
|
if (params.result.summary && params.result.summary.length > 0) {
|
|
parts.push(`summary=${params.result.summary.length} chars`);
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function buildPersistedDebugSummary(result: ActiveRecallResult): string | null {
|
|
if (result.status === "timeout_partial") {
|
|
return `timeout_partial: ${String(result.summary.length)} chars recovered (not persisted)`;
|
|
}
|
|
return result.summary;
|
|
}
|
|
|
|
function buildPluginDebugLine(params: {
|
|
summary?: string | null;
|
|
searchDebug?: ActiveMemorySearchDebug;
|
|
}): string | null {
|
|
const cleaned = sanitizeDebugText(params.summary ?? "");
|
|
const warning = sanitizeDebugText(params.searchDebug?.warning ?? "");
|
|
const action = sanitizeDebugText(params.searchDebug?.action ?? "");
|
|
const error = sanitizeDebugText(params.searchDebug?.error ?? "");
|
|
const debugParts: string[] = [];
|
|
const backend = sanitizeDebugText(params.searchDebug?.backend ?? "");
|
|
if (backend) {
|
|
debugParts.push(`backend=${backend}`);
|
|
}
|
|
const configuredMode = sanitizeDebugText(params.searchDebug?.configuredMode ?? "");
|
|
if (configuredMode) {
|
|
debugParts.push(`configuredMode=${configuredMode}`);
|
|
}
|
|
const effectiveMode = sanitizeDebugText(params.searchDebug?.effectiveMode ?? "");
|
|
if (effectiveMode) {
|
|
debugParts.push(`effectiveMode=${effectiveMode}`);
|
|
}
|
|
const fallback = sanitizeDebugText(params.searchDebug?.fallback ?? "");
|
|
if (fallback) {
|
|
debugParts.push(`fallback=${fallback}`);
|
|
}
|
|
if (
|
|
typeof params.searchDebug?.searchMs === "number" &&
|
|
Number.isFinite(params.searchDebug.searchMs)
|
|
) {
|
|
debugParts.push(`searchMs=${Math.max(0, Math.round(params.searchDebug.searchMs))}`);
|
|
}
|
|
if (typeof params.searchDebug?.hits === "number" && Number.isFinite(params.searchDebug.hits)) {
|
|
debugParts.push(`hits=${Math.max(0, Math.floor(params.searchDebug.hits))}`);
|
|
}
|
|
const prefix = debugParts.join(" ");
|
|
const warningAction =
|
|
warning && action && !cleaned
|
|
? `${warning} ${action}`
|
|
: [warning, action && !cleaned ? action : ""]
|
|
.filter((value): value is string => Boolean(value))
|
|
.join(" | ");
|
|
const messages = uniqueStrings(
|
|
[warningAction, cleaned].filter((value): value is string => Boolean(value)),
|
|
).join(" | ");
|
|
const trailing = messages;
|
|
if (prefix && trailing) {
|
|
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${prefix} | ${trailing}`;
|
|
}
|
|
if (prefix) {
|
|
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${prefix}`;
|
|
}
|
|
if (messages) {
|
|
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${messages}`;
|
|
}
|
|
if (warning) {
|
|
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${warning}`;
|
|
}
|
|
if (cleaned) {
|
|
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${cleaned}`;
|
|
}
|
|
if (error) {
|
|
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${error}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sanitizeDebugText(text: string): string {
|
|
let sanitized = "";
|
|
for (const ch of text) {
|
|
const code = ch.charCodeAt(0);
|
|
const isControl = (code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f);
|
|
if (!isControl) {
|
|
sanitized += ch;
|
|
}
|
|
}
|
|
return sanitized.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
async function persistPluginStatusLines(params: {
|
|
api: OpenClawPluginApi;
|
|
agentId: string;
|
|
sessionKey?: string;
|
|
statusLine?: string;
|
|
debugSummary?: string | null;
|
|
searchDebug?: ActiveMemorySearchDebug;
|
|
}): Promise<void> {
|
|
const sessionKey = params.sessionKey?.trim();
|
|
if (!sessionKey) {
|
|
return;
|
|
}
|
|
const debugLine = buildPluginDebugLine({
|
|
summary: params.debugSummary,
|
|
searchDebug: params.searchDebug,
|
|
});
|
|
const agentId = params.agentId.trim();
|
|
if (!agentId && (params.statusLine || debugLine)) {
|
|
return;
|
|
}
|
|
try {
|
|
if (!params.statusLine && !debugLine) {
|
|
const existingEntry = params.api.runtime.agent.session.getSessionEntry({
|
|
agentId,
|
|
sessionKey,
|
|
});
|
|
const hasActiveMemoryEntry = Array.isArray(existingEntry?.pluginDebugEntries)
|
|
? existingEntry.pluginDebugEntries.some((entry) => entry?.pluginId === "active-memory")
|
|
: false;
|
|
if (!hasActiveMemoryEntry) {
|
|
return;
|
|
}
|
|
}
|
|
await params.api.runtime.agent.session.patchSessionEntry({
|
|
agentId,
|
|
sessionKey,
|
|
preserveActivity: true,
|
|
update: (existing) => {
|
|
const previousEntries = Array.isArray(existing.pluginDebugEntries)
|
|
? existing.pluginDebugEntries
|
|
: [];
|
|
const nextEntries = previousEntries.filter(
|
|
(entry): entry is PluginDebugEntry =>
|
|
Boolean(entry) &&
|
|
typeof entry === "object" &&
|
|
typeof entry.pluginId === "string" &&
|
|
entry.pluginId !== "active-memory",
|
|
);
|
|
const nextLines: string[] = [];
|
|
if (params.statusLine) {
|
|
nextLines.push(params.statusLine);
|
|
}
|
|
if (debugLine) {
|
|
nextLines.push(debugLine);
|
|
}
|
|
if (nextLines.length > 0) {
|
|
nextEntries.push({
|
|
pluginId: "active-memory",
|
|
lines: nextLines,
|
|
});
|
|
}
|
|
return {
|
|
pluginDebugEntries: nextEntries.length > 0 ? nextEntries : undefined,
|
|
};
|
|
},
|
|
});
|
|
} catch (error) {
|
|
params.api.logger.debug?.(
|
|
`active-memory: failed to persist session status note (${error instanceof Error ? error.message : String(error)})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export {
|
|
buildPersistedDebugSummary,
|
|
buildPluginStatusLine,
|
|
persistPluginStatusLines,
|
|
resolveCanonicalSessionKeyFromSessionId,
|
|
resolveRecallRunChannelContext,
|
|
resolveStatusUpdateAgentId,
|
|
};
|