/** * Hippo Memory - OpenClaw Plugin * * Auto-injects relevant memory context at session start, * captures errors during sessions, and runs consolidation. * * Config lives under plugins.entries.hippo-memory.config */ import { execFileSync, spawn } from 'child_process'; import type { ChildProcess, ExecFileSyncOptionsWithStringEncoding, SpawnOptions } from 'child_process'; import { existsSync, readdirSync, rmSync } from 'fs'; import { join } from 'path'; import { basename as posixBasename, dirname as posixDirname } from 'path/posix'; interface HippoConfig { budget?: number; autoContext?: boolean; autoLearn?: boolean; autoSleep?: boolean; framing?: 'observe' | 'suggest' | 'assert'; root?: string; } type HippoRuntimeContext = { workspaceDir?: string; agentId?: string; sessionId?: string; sessionKey?: string; }; /** * Dependency-injection seam for tests. Production code always uses the real * child_process/fs functions imported above; tests override this via * __setHippoPluginDeps to substitute fakes instead of `vi.mock`ing the * built-in modules. Never called outside tests -- register(api)'s public * behavior is unaffected when it's never invoked. * * Option params use Node's own overload-selecting types so `deps = { * execFileSync, spawn, existsSync }` type-checks under strict without a cast. */ export interface HippoPluginDeps { execFileSync: ( file: string, args: readonly string[], options: ExecFileSyncOptionsWithStringEncoding, ) => string; spawn: ( command: string, args: readonly string[], options: SpawnOptions, ) => Pick; existsSync: (path: string) => boolean; } let deps: HippoPluginDeps = { execFileSync, spawn, existsSync }; /** Test-only override. Not part of the plugin's public API surface. */ export function __setHippoPluginDeps(overrides: Partial): void { deps = { ...deps, ...overrides }; } const AUTO_SLEEP_SESSION_THRESHOLD = 10; const MAX_ERRORS_PER_SESSION = 5; const sessionMemoryCounts = new Map(); const sessionErrorCounts = new Map(); const sessionErrorHashes = new Map>(); const injectedSessions = new Set(); /** * Infrastructure errors that repeat constantly and never produce useful lessons. * These are transient operational failures, not domain-specific gotchas. */ const NOISE_ERROR_PATTERNS: RegExp[] = [ /Local media path is not under an allowed directory/i, /timed out\.?\s*Restart the OpenClaw gateway/i, /EISDIR:\s*illegal operation on a directory/i, /Missing required parameter:\s*path/i, /ENOENT:\s*no such file or directory/i, /EACCES:\s*permission denied/i, /EPERM:\s*operation not permitted/i, /socket hang up/i, /ECONNREFUSED/i, /ECONNRESET/i, /ERR_SOCKET_CONNECTION_TIMEOUT/i, /net::ERR_/i, /Navigation timeout/i, ]; function isNoiseError(error: string): boolean { return NOISE_ERROR_PATTERNS.some((p) => p.test(error)); } /** True for a non-empty string, without assuming the input's shape — used * on values read off the untyped OpenClaw plugin `api` surface. */ function isNonEmptyString(value: T): value is T & string { return typeof value === 'string' && value.length > 0; } function hashError(toolName: string, error: string): string { // Normalize the error to a stable key: tool + first 80 chars of error const normalized = error.replace(/\s+/g, ' ').trim().slice(0, 80).toLowerCase(); return `${toolName}::${normalized}`; } function getConfig(api: any): HippoConfig { try { const entries = api.config?.plugins?.entries?.['hippo-memory']; return entries?.config ?? {}; } catch { return {}; } } function findHippoRoot(workspace?: string, configRoot?: string): string | null { if (configRoot && deps.existsSync(configRoot)) return configRoot; const home = process.env.USERPROFILE || process.env.HOME || ''; const candidates = [ workspace ? join(workspace, '.hippo') : null, process.env.HIPPO_HOME, process.env.HIPPO_ROOT, process.env.XDG_DATA_HOME ? join(process.env.XDG_DATA_HOME, 'hippo') : null, home ? join(home, '.hippo') : null, ].filter((candidate): candidate is string => Boolean(candidate)); for (const candidate of candidates) { if (deps.existsSync(candidate)) return candidate; } return null; } function getAgentWorkspace(api: any, agentId?: string): string | undefined { try { const agents = api.config?.agents; const list = Array.isArray(agents?.list) ? agents.list : []; if (agentId) { const match = list.find((agent: any) => agent?.id === agentId); if (isNonEmptyString(match?.workspace)) return match.workspace; } const defaultAgent = list.find((agent: any) => agent?.default); if (isNonEmptyString(defaultAgent?.workspace)) { return defaultAgent.workspace; } const fallback = agents?.defaults?.workspace; return isNonEmptyString(fallback) ? fallback : undefined; } catch { return undefined; } } function resolveHippoCwd(workspace?: string, configRoot?: string): string { const hippoRoot = findHippoRoot(workspace, configRoot); if (!hippoRoot) return workspace || process.cwd(); const normalized = hippoRoot.replace(/\\/g, '/'); return posixBasename(normalized).toLowerCase() === '.hippo' ? posixDirname(normalized) : hippoRoot; } function resolveHippoCwdFromContext(api: any, ctx: HippoRuntimeContext, configRoot?: string): string { const workspace = ctx.workspaceDir ?? getAgentWorkspace(api, ctx.agentId); return resolveHippoCwd(workspace, configRoot); } function getSessionIdentity(ctx: Pick): string { return ctx.sessionId ?? ctx.sessionKey ?? ctx.agentId ?? `fallback-${Date.now()}-${process.pid}`; } function recordSessionMemory(ctx: Pick): void { const key = getSessionIdentity(ctx); sessionMemoryCounts.set(key, (sessionMemoryCounts.get(key) ?? 0) + 1); } function consumeSessionMemoryCount( ctx: Pick, ): number { const key = getSessionIdentity(ctx); const count = sessionMemoryCounts.get(key) ?? 0; sessionMemoryCounts.delete(key); return count; } function sanitizeTag(tag?: string): string | undefined { if (!tag) return undefined; const normalized = tag .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 30); return normalized || undefined; } function formatToolErrorMemory(toolName: string, error: string): string { const normalized = error.replace(/\s+/g, ' ').trim(); const truncated = normalized.slice(0, 500); const suffix = normalized.length > truncated.length ? ' [truncated]' : ''; return `Tool '${toolName}' failed: ${truncated}${suffix}`; } /** * Remove stale hippo-memory.bak-* directories from the OpenClaw extensions folder. * These are left behind by plugin updates and cause duplicate plugin ID errors on boot. */ function cleanupBackupPlugins(logger?: { info?: (...args: unknown[]) => void }): void { try { const extensionsDir = join( process.env.USERPROFILE || process.env.HOME || '', '.openclaw', 'extensions' ); if (!deps.existsSync(extensionsDir)) return; for (const entry of readdirSync(extensionsDir)) { if (entry.startsWith('hippo-memory.bak')) { const fullPath = join(extensionsDir, entry); rmSync(fullPath, { recursive: true, force: true }); logger?.info?.(`[hippo] Removed stale backup: ${entry}`); } } } catch { // Best-effort cleanup — don't break boot } } function hippoRememberSucceeded(result: string): boolean { return result.includes('Remembered ['); } function runHippo(args: readonly string[], cwd?: string): string { try { const result = deps.execFileSync('hippo', args, { cwd: cwd || process.cwd(), encoding: 'utf8', timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'], }); // `encoding: 'utf8'` above selects the ExecFileSyncOptionsWithStringEncoding // overload, so `result` is always a `string` here — no runtime check needed. return result.trim(); } catch (err: any) { return err.stdout?.trim() || err.message || 'hippo command failed'; } } function spawnHippoDetached(args: readonly string[], cwd?: string): boolean { try { const child = deps.spawn('hippo', [...args], { cwd: cwd || process.cwd(), detached: true, stdio: 'ignore', windowsHide: true, }); child.unref(); return true; } catch { return false; } } let _registered = false; export default function register(api: any) { if (_registered) return; _registered = true; const logger = api.logger ?? console; // Clean up stale backup plugins from previous updates cleanupBackupPlugins(logger); // --- Tool: hippo_recall --- api.registerTool((ctx: HippoRuntimeContext) => ({ name: 'hippo_recall', description: 'Retrieve relevant memories from the project memory store. Returns memories ranked by relevance, strength, and recency within the token budget. Use at session start or when you need context about a topic.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'What to search for in memory (natural language)', }, budget: { type: 'number', description: 'Max tokens to return (default: 1500)', }, }, required: ['query'], }, async execute(_id: string, params: { query: string; budget?: number }) { const cfg = getConfig(api); const budget = params.budget ?? cfg.budget ?? 1500; const framing = cfg.framing ?? 'observe'; const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const result = runHippo( ['recall', params.query, '--budget', String(budget), '--framing', framing], hippoCwd, ); return { content: [{ type: 'text', text: result || 'No relevant memories found.' }] }; }, })); // --- Tool: hippo_remember --- api.registerTool((ctx: HippoRuntimeContext) => ({ name: 'hippo_remember', description: 'Store a new memory. Use when you learn something non-obvious, hit an error, or discover a useful pattern. Memories decay over time unless retrieved. Errors get 2x half-life.', parameters: { type: 'object', properties: { text: { type: 'string', description: 'The memory to store (1-2 sentences, specific and concrete)', }, error: { type: 'boolean', description: 'Mark as error memory (doubles half-life)', }, pin: { type: 'boolean', description: 'Pin memory (never decays)', }, tag: { type: 'string', description: 'Optional tag for categorization', }, }, required: ['text'], }, async execute( _id: string, params: { text: string; error?: boolean; pin?: boolean; tag?: string }, ) { const cfg = getConfig(api); const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const args: string[] = ['remember', params.text]; if (params.error) args.push('--error'); if (params.pin) args.push('--pin'); if (params.tag) { const safe = sanitizeTag(params.tag); if (safe) args.push('--tag', safe); } const result = runHippo(args, hippoCwd); if (hippoRememberSucceeded(result)) { recordSessionMemory(ctx); } return { content: [{ type: 'text', text: result || 'Memory stored.' }] }; }, })); // --- Tool: hippo_outcome --- api.registerTool((ctx: HippoRuntimeContext) => ({ name: 'hippo_outcome', description: 'Report whether recalled memories were useful. Strengthens good memories (+5 days half-life) and weakens bad ones (-3 days). Call after completing work.', parameters: { type: 'object', properties: { good: { type: 'boolean', description: 'true = memories helped, false = memories were irrelevant', }, }, required: ['good'], }, async execute(_id: string, params: { good: boolean }) { const cfg = getConfig(api); const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const flag = params.good ? '--good' : '--bad'; const result = runHippo(['outcome', flag], hippoCwd); return { content: [{ type: 'text', text: result || 'Outcome recorded.' }] }; }, })); // --- Tool: hippo_status --- api.registerTool( (ctx: HippoRuntimeContext) => ({ name: 'hippo_status', description: 'Check memory health: counts, strengths, at-risk memories, last consolidation time.', parameters: { type: 'object', properties: {}, }, async execute() { const cfg = getConfig(api); const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const result = runHippo(['status'], hippoCwd); return { content: [{ type: 'text', text: result || 'No hippo store found.' }] }; }, }), { optional: true }, ); // --- Tool: hippo_context --- api.registerTool( (ctx: HippoRuntimeContext) => ({ name: 'hippo_context', description: 'Smart context injection: auto-detects current task from git state and returns relevant memories. Use at the start of any session.', parameters: { type: 'object', properties: { budget: { type: 'number', description: 'Max tokens (default: 1500)', }, }, }, async execute(_id: string, params: { budget?: number }) { const cfg = getConfig(api); const budget = params.budget ?? cfg.budget ?? 1500; const framing = cfg.framing ?? 'observe'; const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const result = runHippo( ['context', '--auto', '--budget', String(budget), '--framing', framing], hippoCwd, ); return { content: [{ type: 'text', text: result || 'No context available.' }] }; }, }), { optional: true }, ); // --- Tool: hippo_conflicts --- api.registerTool( (ctx: HippoRuntimeContext) => ({ name: 'hippo_conflicts', description: 'List open memory conflicts — contradictory memories that need resolution.', parameters: { type: 'object', properties: { json: { type: 'boolean', description: 'Output as JSON (default: false)', }, }, }, async execute(_id: string, params: { json?: boolean }) { const cfg = getConfig(api); const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const args: string[] = params.json ? ['conflicts', '--json'] : ['conflicts']; const result = runHippo(args, hippoCwd); return { content: [{ type: 'text', text: result || 'No conflicts found.' }] }; }, }), { optional: true }, ); // --- Tool: hippo_resolve --- api.registerTool( (ctx: HippoRuntimeContext) => ({ name: 'hippo_resolve', description: 'Resolve a memory conflict by keeping one memory and weakening or deleting the other.', parameters: { type: 'object', properties: { conflict_id: { type: 'number', description: 'The conflict ID to resolve', }, keep: { type: 'string', description: 'ID of the memory to keep', }, forget: { type: 'boolean', description: 'Delete the losing memory instead of weakening it (default: false)', }, }, required: ['conflict_id', 'keep'], }, async execute( _id: string, params: { conflict_id: number; keep: string; forget?: boolean }, ) { const cfg = getConfig(api); const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const args: string[] = ['resolve', String(params.conflict_id), '--keep', params.keep]; if (params.forget) args.push('--forget'); const result = runHippo(args, hippoCwd); return { content: [{ type: 'text', text: result || 'Conflict resolved.' }] }; }, }), { optional: true }, ); // --- Tool: hippo_share --- api.registerTool( (ctx: HippoRuntimeContext) => ({ name: 'hippo_share', description: 'Share a memory to the global store for cross-project use. Memories with universal lessons (errors, platform gotchas) transfer well; project-specific ones are filtered.', parameters: { type: 'object', properties: { id: { type: 'string', description: 'Memory ID to share (or "auto" to auto-share all high-scoring memories)', }, force: { type: 'boolean', description: 'Share even if transfer score is low (default: false)', }, }, required: ['id'], }, async execute( _id: string, params: { id: string; force?: boolean }, ) { const cfg = getConfig(api); const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const args: string[] = ['share']; if (params.id === 'auto') { args.push('--auto'); } else { args.push(params.id); if (params.force) args.push('--force'); } const result = runHippo(args, hippoCwd); return { content: [{ type: 'text', text: result || 'Share complete.' }] }; }, }), { optional: true }, ); // --- Tool: hippo_peers --- api.registerTool( (ctx: HippoRuntimeContext) => ({ name: 'hippo_peers', description: 'List all projects that have contributed memories to the global shared store.', parameters: { type: 'object', properties: {}, }, async execute() { const cfg = getConfig(api); const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const result = runHippo(['peers'], hippoCwd); return { content: [{ type: 'text', text: result || 'No peers found.' }] }; }, }), { optional: true }, ); // --- Tool: hippo_wm_push --- api.registerTool( (ctx: HippoRuntimeContext) => ({ name: 'hippo_wm_push', description: 'Push a note into working memory — a bounded buffer for current-state context. Entries are scoped, importance-ranked, and auto-evicted when the buffer is full (max 20 per scope).', parameters: { type: 'object', properties: { content: { type: 'string', description: 'Working memory note', }, scope: { type: 'string', description: 'Scope (default: repo)', }, importance: { type: 'number', description: 'Priority 0-1 (default: 0.5)', }, }, required: ['content'], }, async execute( _id: string, params: { content: string; scope?: string; importance?: number }, ) { const cfg = getConfig(api); const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const scope = params.scope ?? 'repo'; const importance = params.importance ?? 0.5; const result = runHippo( ['wm', 'push', '--scope', scope, '--content', params.content, '--importance', String(importance)], hippoCwd, ); return { content: [{ type: 'text', text: result || 'Working memory entry pushed.' }] }; }, }), { optional: true }, ); // --- Hook: auto-inject context at session start --- api.on( 'before_prompt_build', (_event: any, ctx: HippoRuntimeContext) => { const cfg = getConfig(api); if (cfg.autoContext === false) return {}; // Dedup guard: skip if this session already got context injected const sessionKey = getSessionIdentity(ctx); if (sessionKey && injectedSessions.has(sessionKey)) { logger.debug?.(`[hippo] skipping duplicate context injection for session ${sessionKey}`); return {}; } const budget = cfg.budget ?? 1500; const framing = cfg.framing ?? 'observe'; const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); // Record session_start event try { runHippo( ['session', 'log', '--id', sessionKey, '--type', 'session_start', '--content', 'Session started', '--source', 'openclaw'], hippoCwd, ); } catch (err) { logger.debug?.('[hippo] session_start event skipped:', err); } try { const context = runHippo( ['context', '--auto', '--budget', String(budget), '--framing', framing], hippoCwd, ); if (context && context.length > 10 && !context.includes('No hippo store')) { if (sessionKey) injectedSessions.add(sessionKey); return { appendSystemContext: `\n\n## Project Memory (Hippo)\n${context}`, }; } } catch (err) { logger.debug?.('[hippo] context injection skipped:', err); } return {}; }, { priority: 5 }, ); api.on( 'after_tool_call', (event: { toolName: string; error?: string }, ctx: HippoRuntimeContext) => { const cfg = getConfig(api); if (cfg.autoLearn === false) return; if (!event.error?.trim()) return; if (event.toolName.startsWith('hippo_')) return; // --- Filter 1: Skip known infrastructure noise --- if (isNoiseError(event.error)) { logger.debug?.(`[hippo] autoLearn skipped noise error from '${event.toolName}'`); return; } const sessionKey = getSessionIdentity(ctx); // --- Filter 2: Rate-limit errors per session --- const errorCount = sessionErrorCounts.get(sessionKey) ?? 0; if (errorCount >= MAX_ERRORS_PER_SESSION) { logger.debug?.(`[hippo] autoLearn rate-limited (${errorCount} errors this session)`); return; } // --- Filter 3: Deduplicate within session --- const hash = hashError(event.toolName, event.error); const seen = sessionErrorHashes.get(sessionKey) ?? new Set(); if (seen.has(hash)) { logger.debug?.(`[hippo] autoLearn skipped duplicate error from '${event.toolName}'`); return; } const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); const toolTag = sanitizeTag(event.toolName); const args: string[] = [ 'remember', formatToolErrorMemory(event.toolName, event.error), '--error', '--observed', '--tag', 'openclaw', ]; if (toolTag) args.push('--tag', toolTag); const result = runHippo(args, hippoCwd); if (hippoRememberSucceeded(result)) { recordSessionMemory(ctx); seen.add(hash); sessionErrorHashes.set(sessionKey, seen); sessionErrorCounts.set(sessionKey, errorCount + 1); } else { logger.debug?.(`[hippo] autoLearn skipped storing tool error: ${result}`); } }, ); api.on( 'session_end', (_event: { sessionId: string; messageCount: number }, ctx: HippoRuntimeContext) => { // Clear dedup guards so a new session starts fresh const sessionKey = getSessionIdentity(ctx); injectedSessions.delete(sessionKey); sessionErrorCounts.delete(sessionKey); sessionErrorHashes.delete(sessionKey); const cfg = getConfig(api); const hippoCwd = resolveHippoCwdFromContext(api, ctx, cfg.root); // Record session_end event try { runHippo( ['session', 'log', '--id', sessionKey, '--type', 'session_end', '--content', 'Session ended', '--source', 'openclaw'], hippoCwd, ); } catch (err) { logger.debug?.('[hippo] session_end event skipped:', err); } const newMemories = consumeSessionMemoryCount(ctx); if (!cfg.autoSleep || newMemories < AUTO_SLEEP_SESSION_THRESHOLD) return; const sessionLabel = ctx.sessionId ?? ctx.sessionKey ?? 'unknown'; if (spawnHippoDetached(['sleep'], hippoCwd)) { logger.info?.( `[hippo] autoSleep scheduled for session ${sessionLabel} ` + `after ${newMemories} new memories`, ); return; } const result = runHippo(['sleep'], hippoCwd); logger.warn?.( `[hippo] autoSleep fell back to inline execution for session ${sessionLabel} ` + `after ${newMemories} new memories`, ); logger.debug?.(`[hippo] autoSleep result: ${result}`); }, ); logger.info?.('[hippo] Memory plugin registered (tools: hippo_recall, hippo_remember, hippo_outcome, hippo_status, hippo_context, hippo_conflicts, hippo_resolve, hippo_share, hippo_peers, hippo_wm_push)'); }