diff --git a/index.ts b/index.ts index 6cb1e2d5..8449bde8 100644 --- a/index.ts +++ b/index.ts @@ -108,9 +108,9 @@ interface PluginConfig { autoRecallPerItemMaxChars?: number; /** Hard per-turn injection cap (safety valve). Overrides autoRecallMaxItems if lower. Default: 10. */ maxRecallPerTurn?: number; - recallMode?: "full" | "summary" | "adaptive" | "off"; - /** Agent IDs excluded from auto-recall injection. Useful for background agents (e.g. memory-distiller, cron workers) whose output should not be contaminated by injected memory context. */ + /** Agent/session exclusion list for auto-recall. Supports exact match, wildcard prefix (e.g. "pi-"), and "temp:*" for internal reflection sessions. */ autoRecallExcludeAgents?: string[]; + recallMode?: "full" | "summary" | "adaptive" | "off"; captureAssistant?: boolean; retrieval?: { mode?: "hybrid" | "vector"; @@ -203,6 +203,10 @@ interface PluginConfig { thinkLevel?: ReflectionThinkLevel; errorReminderMaxEntries?: number; dedupeErrorSignals?: boolean; + /** Cooldown in milliseconds between reflection triggers for the same session. + * Default: 120000 (2 minutes). Set to 0 to disable the serial guard. + * @example 60000 (1 min), 180000 (3 min) */ + serialCooldownMs?: number; }; mdMirror?: { enabled?: boolean; dir?: string }; workspaceBoundary?: WorkspaceBoundaryConfig; @@ -314,6 +318,45 @@ function resolveSourceFromSessionKey(sessionKey: string | undefined): string { const source = match?.[1]?.trim(); return source || "unknown"; } +/** + * Check if an agent ID or sessionKey matches any exclusion pattern. + * Supports: + * - Exact string match (e.g. "memory-distiller") + * - Wildcard suffix match (e.g. "pi-" matches "pi-agent", "pi-coder") + * - Special "temp:*" pattern matching internal reflection sessions + */ +function isAgentOrSessionExcluded( + agentId: string, + sessionKey: string | undefined, + patterns: string[], +): boolean { + if (!Array.isArray(patterns) || patterns.length === 0) return false; + + const cleanAgentId = agentId.trim(); + const isInternal = typeof sessionKey === "string" && + sessionKey.trim().startsWith("temp:memory-reflection"); + + for (const pattern of patterns) { + const p = typeof pattern === "string" ? pattern.trim() : ""; + if (!p) continue; + + if (p === "temp:*") { + if (isInternal) return true; + continue; + } + + if (p.endsWith("-")) { + // Wildcard prefix match: "pi-" matches "pi-agent" + const prefix = p.slice(0, -1); + if (cleanAgentId.startsWith(prefix)) return true; + } else if (p === cleanAgentId) { + return true; + } + } + + return false; +} + function summarizeAgentEndMessages(messages: unknown[]): string { const roleCounts = new Map(); @@ -2308,15 +2351,15 @@ const memoryLanceDBProPlugin = { const AUTO_RECALL_TIMEOUT_MS = parsePositiveInt(config.autoRecallTimeoutMs) ?? 5_000; // configurable; default raised from 3s to 5s for remote embedding APIs behind proxies api.on("before_prompt_build", async (event: any, ctx: any) => { // Per-agent exclusion: skip auto-recall for agents in the exclusion list. - const agentId = resolveHookAgentId(ctx?.agentId, (event as any).sessionKey); + const sessionKey = (event as any).sessionKey as string | undefined; + const agentId = resolveHookAgentId(ctx?.agentId, sessionKey); if ( Array.isArray(config.autoRecallExcludeAgents) && config.autoRecallExcludeAgents.length > 0 && - agentId !== undefined && - config.autoRecallExcludeAgents.includes(agentId) + isAgentOrSessionExcluded(agentId, sessionKey, config.autoRecallExcludeAgents) ) { api.logger.debug?.( - `memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}'`, + `memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}' (sessionKey=${sessionKey ?? "(none)"})', ); return; } @@ -3030,6 +3073,13 @@ const memoryLanceDBProPlugin = { ? (event.context as Record) : {}; const commandSource = typeof contextForLog.commandSource === "string" ? contextForLog.commandSource : ""; + // Skip self-improvement on internal reflection sessions (consistent with agent:bootstrap guard) + if (isInternalReflectionSessionKey(sessionKeyForLog)) { + api.logger.debug?.( + `self-improvement: command:${action} skipped (internal reflection session sessionKey=${sessionKeyForLog})`, + ); + return; + } const contextKeys = Object.keys(contextForLog).slice(0, 8).join(","); api.logger.info( `self-improvement: command:${action} hook start; sessionKey=${sessionKeyForLog || "(none)"}; source=${commandSource || "(unknown)"}; hasMessages=${Array.isArray(event?.messages)}; contextKeys=${contextKeys || "(none)"}` @@ -3166,6 +3216,21 @@ const memoryLanceDBProPlugin = { api.on("before_prompt_build", async (_event: any, ctx: any) => { const sessionKey = typeof ctx.sessionKey === "string" ? ctx.sessionKey : ""; if (isInternalReflectionSessionKey(sessionKey)) return; + if ( + Array.isArray(config.autoRecallExcludeAgents) && + config.autoRecallExcludeAgents.length > 0 + ) { + const agentIdForExclude = resolveHookAgentId( + typeof ctx.agentId === "string" ? ctx.agentId : undefined, + sessionKey, + ); + if (isAgentOrSessionExcluded(agentIdForExclude, sessionKey, config.autoRecallExcludeAgents)) { + api.logger.debug?.( + `memory-lancedb-pro: reflection inheritance skipped for excluded agent '${agentIdForExclude}'`, + ); + return; + } + } if (reflectionInjectMode !== "inheritance-only" && reflectionInjectMode !== "inheritance+derived") return; try { pruneReflectionSessionState(); @@ -3193,6 +3258,21 @@ const memoryLanceDBProPlugin = { api.on("before_prompt_build", async (_event: any, ctx: any) => { const sessionKey = typeof ctx.sessionKey === "string" ? ctx.sessionKey : ""; if (isInternalReflectionSessionKey(sessionKey)) return; + if ( + Array.isArray(config.autoRecallExcludeAgents) && + config.autoRecallExcludeAgents.length > 0 + ) { + const agentIdForExclude = resolveHookAgentId( + typeof ctx.agentId === "string" ? ctx.agentId : undefined, + sessionKey, + ); + if (isAgentOrSessionExcluded(agentIdForExclude, sessionKey, config.autoRecallExcludeAgents)) { + api.logger.debug?.( + `memory-lancedb-pro: reflection derived+error injection skipped for excluded agent '${agentIdForExclude}'`, + ); + return; + } + } const agentId = resolveHookAgentId( typeof ctx.agentId === "string" ? ctx.agentId : undefined, sessionKey, @@ -3260,33 +3340,46 @@ const memoryLanceDBProPlugin = { if (!g[GLOBAL_REFLECTION_LOCK]) g[GLOBAL_REFLECTION_LOCK] = new Map(); return g[GLOBAL_REFLECTION_LOCK] as Map; }; - - // Serial loop guard: track last reflection time per sessionKey to prevent - // gateway-level re-triggering (e.g. session_end → new session → command:new) - const REFLECTION_SERIAL_GUARD = Symbol.for("openclaw.memory-lancedb-pro.reflection-serial-guard"); - const getSerialGuardMap = () => { - const g = globalThis as any; - if (!g[REFLECTION_SERIAL_GUARD]) g[REFLECTION_SERIAL_GUARD] = new Map(); - return g[REFLECTION_SERIAL_GUARD] as Map; + const SERIAL_GUARD_COOLDOWN_MS = 120_000; // 2-minute cooldown between reflection triggers + const getSerialGuardMap = (): Map => { + const g = globalThis as Record; + if (!(g as Record)[Symbol.for("openclaw.memory-lancedb-pro.serial-guard")]) { + (g as Record)[Symbol.for("openclaw.memory-lancedb-pro.serial-guard")] = new Map(); + } + return (g as Record)[Symbol.for("openclaw.memory-lancedb-pro.serial-guard")] as Map; }; - const SERIAL_GUARD_COOLDOWN_MS = 120_000; // 2 minutes cooldown per sessionKey const runMemoryReflection = async (event: any) => { const sessionKey = typeof event.sessionKey === "string" ? event.sessionKey : ""; - // Guard against re-entrant calls for the same session (e.g. file-write triggering another command:new) - // Uses global lock shared across all plugin instances to prevent loop amplification. + // Guard against internal reflection session + if (isInternalReflectionSessionKey(sessionKey)) { + api.logger.debug?.( + `memory-reflection: command hook skipped (internal sessionKey=${sessionKey})`, + ); + return; + } + // Guard against re-entrant calls for the same session const globalLock = getGlobalReflectionLock(); if (sessionKey && globalLock.get(sessionKey)) { - api.logger.info(`memory-reflection: skipping re-entrant call for sessionKey=${sessionKey}; already running (global guard)`); + api.logger.info(`memory-reflection: command hook skipped (re-entrant, sessionKey=${sessionKey})`); return; } - // Serial loop guard: skip if a reflection for this sessionKey completed recently + // Parse context before guards so cfg is available + const context = (event.context || {}) as Record; + const cfg = context.cfg as Record | undefined; + + // Serial loop guard: prevent rapid re-trigger within cooldown window if (sessionKey) { const serialGuard = getSerialGuardMap(); const lastRun = serialGuard.get(sessionKey); - if (lastRun && (Date.now() - lastRun) < SERIAL_GUARD_COOLDOWN_MS) { - api.logger.info(`memory-reflection: skipping serial re-trigger for sessionKey=${sessionKey}; last run ${(Date.now() - lastRun) / 1000}s ago (cooldown=${SERIAL_GUARD_COOLDOWN_MS / 1000}s)`); - return; + if (lastRun) { + const cooldownMs = typeof (cfg?.memoryReflection as Record | undefined)?.serialCooldownMs === "number" + ? (cfg!.memoryReflection as Record).serialCooldownMs as number + : 120_000; + if ((Date.now() - lastRun) < cooldownMs) { + api.logger.info(`memory-reflection: command hook skipped (cooldown ${((Date.now() - lastRun) / 1000).toFixed(0)}s/${(cooldownMs / 1000).toFixed(0)}s, sessionKey=${sessionKey})`); + return; + } } } if (sessionKey) globalLock.set(sessionKey, true); @@ -3294,11 +3387,9 @@ const memoryLanceDBProPlugin = { try { pruneReflectionSessionState(); const action = String(event?.action || "unknown"); - const context = (event.context || {}) as Record; - const cfg = context.cfg; const workspaceDir = resolveWorkspaceDirFromContext(context); if (!cfg) { - api.logger.warn(`memory-reflection: command:${action} missing cfg in hook context; skip reflection`); + api.logger.warn(`memory-reflection: command:${action} sessionKey=${sessionKey} missing cfg; skip reflection`); return; } @@ -3343,7 +3434,7 @@ const memoryLanceDBProPlugin = { sourceAgentId, }); api.logger.warn( - `memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}` + `memory-reflection: command:${action} sessionKey=${sessionKey} sessionId=${currentSessionId} missing session file after recovery; dirs=${searchDirs.join(" | ") || "(none)"}` ); return; } @@ -3351,7 +3442,7 @@ const memoryLanceDBProPlugin = { const conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); if (!conversation) { api.logger.warn( - `memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile}` + `memory-reflection: command:${action} sessionKey=${sessionKey} sessionId=${currentSessionId} conversation empty/unusable; file=${currentSessionFile}` ); return; } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 976b8a19..09901295 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -146,7 +146,12 @@ }, "recallMode": { "type": "string", - "enum": ["full", "summary", "adaptive", "off"], + "enum": [ + "full", + "summary", + "adaptive", + "off" + ], "default": "full", "description": "Auto-recall depth mode. 'full': inject with configured per-item budget. 'summary': L0 abstracts only (compact). 'adaptive': analyze query intent to auto-select category and depth. 'off': disable auto-recall injection." }, @@ -245,23 +250,78 @@ "type": "object", "additionalProperties": false, "properties": { - "utility": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 }, - "confidence": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 }, - "novelty": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 }, - "recency": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 }, - "typePrior": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.6 } + "utility": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.1 + }, + "novelty": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.1 + }, + "recency": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.1 + }, + "typePrior": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.6 + } } }, "typePriors": { "type": "object", "additionalProperties": false, "properties": { - "profile": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.95 }, - "preferences": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.9 }, - "entities": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.75 }, - "events": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.45 }, - "cases": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.8 }, - "patterns": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.85 } + "profile": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.95 + }, + "preferences": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.9 + }, + "entities": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.75 + }, + "events": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.45 + }, + "cases": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.8 + }, + "patterns": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.85 + } } } } @@ -635,6 +695,18 @@ "dedupeErrorSignals": { "type": "boolean", "default": true + }, + "serialCooldownMs": { + "type": "integer", + "minimum": 0, + "description": "Cooldown in ms between reflection triggers for the same session. Default: 120000 (2 min). Set to 0 to disable." + }, + "excludeAgents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Agent/session patterns excluded from reflection injection. Supports exact match, wildcard prefix (e.g. pi-), and temp:*." } } }, @@ -838,6 +910,13 @@ "description": "Maximum number of auto-capture extractions allowed per hour" } } + }, + "autoRecallExcludeAgents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Agent/session patterns excluded from auto-recall and reflection injection. Supports exact match, wildcard prefix (e.g. pi-), and temp:*." } }, "required": [ @@ -1361,4 +1440,4 @@ "advanced": true } } -} +} \ No newline at end of file