/** * Self-Improvement Hook for OpenClaw * * OpenClaw has no per-tool-call hook event, so errors cannot be detected in * real time after each command. This hook detects them at session end * instead: * * - agent:bootstrap Injects the self-improvement reminder before * workspace files are injected, including a note * when auto-detected errors are awaiting triage. * - command:new / :reset Session-end sweep: scans the transcript of the * session that just ended for error patterns and * appends a pending entry to * /.learnings/ERRORS.md. * * The sweep is opt-in: it only runs when /.learnings/ exists. * Excerpts are truncated and redacted before being written. */ const fs = require('node:fs/promises'); const path = require('node:path'); const REMINDER_NAME = 'SELF_IMPROVEMENT_REMINDER.md'; const REMINDER_PATH = REMINDER_NAME; const REMINDER_HEADER = '## Self-Improvement Reminder'; const REMINDER_CONTENT = ` ${REMINDER_HEADER} After completing tasks, evaluate whether any learnings should be captured. Only log if this repo or workspace is using the self-improvement skill. Before logging: - Create only missing \`.learnings/\` files; never overwrite existing content - Do not log secrets, tokens, private keys, environment variables, or raw transcripts - Prefer short summaries or redacted excerpts over full command output **Log when:** - User corrects you → \`.learnings/LEARNINGS.md\` - Command/operation fails → \`.learnings/ERRORS.md\` - User wants missing capability → \`.learnings/FEATURE_REQUESTS.md\` - You discover your knowledge was wrong → \`.learnings/LEARNINGS.md\` - You find a better approach → \`.learnings/LEARNINGS.md\` **Promote when pattern is proven:** - Behavioral patterns → \`SOUL.md\` - Workflow improvements → \`AGENTS.md\` - Tool gotchas → \`TOOLS.md\` Keep entries simple: date, title, what happened, and what to do differently. `.trim(); // Error-detection patterns. Ordered specific → generic: the first matching pattern // supplies the Pattern-Key stamped on swept entries, which is what makes // auto-detected errors dedup-able and recurrence-countable (see the // "Pattern-Key Taxonomy" section in SKILL.md). const ERROR_PATTERN_KEYS = [ ['command not found', 'shell.command-not-found'], ['No such file', 'fs.no-such-file'], ['Permission denied', 'fs.permission-denied'], ['ModuleNotFoundError', 'deps.module-not-found'], ['npm ERR!', 'deps.npm-error'], ['Traceback', 'runtime.python-exception'], ['SyntaxError', 'runtime.syntax-error'], ['TypeError', 'runtime.type-error'], ['Exception', 'runtime.exception'], ['fatal:', 'vcs.fatal-error'], ['exit code', 'shell.nonzero-exit'], ['non-zero', 'shell.nonzero-exit'], ['error:', 'runtime.error'], ['Error:', 'runtime.error'], ['ERROR:', 'runtime.error'], ['failed', 'runtime.failure'], ['FAILED', 'runtime.failure'], ]; const SWEEP_SOURCE = 'openclaw-error-sweep'; const MAX_EXCERPTS = 5; const MAX_EXCERPT_LENGTH = 200; const ERRORS_FILE_HEADER = '# Errors\n\nCommand failures and integration errors.\n\n---\n'; // Best-effort redaction of common secret shapes before anything is written. const REDACTION_RULES = [ [/\b(api[_-]?key|token|secret|password|passwd|authorization|credential)s?\b(\s*[=:]\s*)\S+/gi, '$1$2[REDACTED]'], [/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'], [/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, '[REDACTED]'], [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '[REDACTED]'], [/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED]'], [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g, '[REDACTED-JWT]'], [/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED-BLOB]'], ]; function isObject(value) { return !!value && typeof value === 'object'; } function isInjectedReminderFile(value) { if (!isObject(value) || value.path !== REMINDER_PATH) { return false; } return ( value.virtual === true || (typeof value.content === 'string' && value.content.includes(REMINDER_HEADER)) ); } function redactSensitiveText(text) { let result = text; for (const [pattern, replacement] of REDACTION_RULES) { result = result.replace(pattern, replacement); } return result; } function sanitizeExcerptLine(line) { let excerpt = redactSensitiveText(line.trim()).split('```').join("'''"); if (excerpt.length > MAX_EXCERPT_LENGTH) { excerpt = `${excerpt.slice(0, MAX_EXCERPT_LENGTH)}…`; } return excerpt; } function collectTextFragments(value, out, depth = 0) { if (depth > 4 || out.length > 200) { return; } if (typeof value === 'string') { out.push(value); return; } if (Array.isArray(value)) { for (const item of value) { collectTextFragments(item, out, depth + 1); } return; } if (isObject(value)) { if (typeof value.text === 'string') { out.push(value.text); } if ('content' in value) { collectTextFragments(value.content, out, depth + 1); } } } function matchErrorPatternKey(line) { for (const [pattern, patternKey] of ERROR_PATTERN_KEYS) { if (line.includes(pattern)) { return patternKey; } } return null; } async function scanTranscriptForErrors(sessionFilePath) { let raw; try { raw = await fs.readFile(sessionFilePath, 'utf-8'); } catch { return []; } const excerpts = []; const seen = new Set(); for (const jsonLine of raw.split('\n')) { if (excerpts.length >= MAX_EXCERPTS) { break; } const trimmed = jsonLine.trim(); if (!trimmed) { continue; } let entry; try { entry = JSON.parse(trimmed); } catch { continue; } if (!isObject(entry) || !isObject(entry.message)) { continue; } const fragments = []; collectTextFragments(entry.message.content, fragments); for (const fragment of fragments) { for (const line of fragment.split('\n')) { const patternKey = matchErrorPatternKey(line); if (!patternKey) { continue; } const excerpt = sanitizeExcerptLine(line); if (!excerpt || seen.has(excerpt)) { continue; } seen.add(excerpt); excerpts.push({ excerpt, patternKey }); if (excerpts.length >= MAX_EXCERPTS) { return excerpts; } } } } return excerpts; } function resolveSessionFilePath(context, workspaceDir) { const sessionEntry = isObject(context.previousSessionEntry) ? context.previousSessionEntry : isObject(context.sessionEntry) ? context.sessionEntry : {}; if (typeof sessionEntry.sessionFile === 'string' && sessionEntry.sessionFile.trim()) { return sessionEntry.sessionFile; } const sessionId = typeof sessionEntry.sessionId === 'string' ? sessionEntry.sessionId.trim() : ''; if (sessionId && workspaceDir) { return path.join(workspaceDir, 'sessions', `${sessionId}.jsonl`); } return undefined; } function generateEntryId(timestamp) { const yyyymmdd = timestamp.toISOString().slice(0, 10).replace(/-/g, ''); const suffix = Math.random().toString(36).slice(2, 5).toUpperCase().padEnd(3, '0'); return `ERR-${yyyymmdd}-${suffix}`; } function formatErrorEntry(params) { const { excerpts, sessionKey, sessionFilePath, action, timestamp } = params; const plural = excerpts.length === 1 ? '' : 's'; const patternKeys = [...new Set(excerpts.map((item) => item.patternKey))]; return [ `## [${generateEntryId(timestamp)}] openclaw_session_sweep`, '', `**Logged**: ${timestamp.toISOString()}`, '**Priority**: medium', '**Status**: pending', '**Area**: config', '', '### Summary', `Session-end sweep detected ${excerpts.length} possible error${plural} in the previous OpenClaw session.`, '', '### Error', '```', ...excerpts.map((item) => item.excerpt), '```', '', '### Context', `- Detected by the self-improvement hook on \`/${action}\` (OpenClaw has no per-tool-call hook, so errors are swept from the session transcript at session end)`, `- Session key: ${sessionKey || 'unknown'}`, `- Session transcript: ${sessionFilePath}`, '- Excerpts are truncated and redacted; check the transcript for full context', '', '### Suggested Fix', 'Triage this entry: if the error was real and non-obvious, keep it and fill in the fix; otherwise mark it resolved or delete it. Before keeping it, grep for its Pattern-Key(s) and fold recurrences into the existing entry (bump Recurrence-Count) instead of duplicating.', '', '### Metadata', `- Source: ${SWEEP_SOURCE}`, '- Reproducible: unknown', ...patternKeys.map((patternKey) => `- Pattern-Key: ${patternKey}`), '', '---', ].join('\n'); } async function handleSessionEndSweep(event) { const context = event.context; const workspaceDir = typeof context.workspaceDir === 'string' && context.workspaceDir.trim() ? context.workspaceDir : undefined; if (!workspaceDir) { return; } // Opt-in gate: only sweep when the workspace uses the self-improvement skill. const learningsDir = path.join(workspaceDir, '.learnings'); try { const stats = await fs.stat(learningsDir); if (!stats.isDirectory()) { return; } } catch { return; } const sessionFilePath = resolveSessionFilePath(context, workspaceDir); if (!sessionFilePath) { return; } const excerpts = await scanTranscriptForErrors(sessionFilePath); if (excerpts.length === 0) { return; } const errorsFilePath = path.join(learningsDir, 'ERRORS.md'); let existing = ''; try { existing = await fs.readFile(errorsFilePath, 'utf-8'); } catch { // Missing file is fine; it is created below. } const freshExcerpts = excerpts.filter((item) => !existing.includes(item.excerpt)); if (freshExcerpts.length === 0) { return; } const entry = formatErrorEntry({ excerpts: freshExcerpts, sessionKey: typeof event.sessionKey === 'string' ? event.sessionKey : '', sessionFilePath, action: event.action, timestamp: event.timestamp instanceof Date ? event.timestamp : new Date(), }); if (!existing) { try { await fs.writeFile(errorsFilePath, `${ERRORS_FILE_HEADER}\n${entry}\n`, { flag: 'wx' }); return; } catch (err) { if (!isObject(err) || err.code !== 'EEXIST') { throw err; } } } await fs.appendFile(errorsFilePath, `\n${entry}\n`); } async function countPendingSweepEntries(workspaceDir) { if (!workspaceDir) { return 0; } let content; try { content = await fs.readFile(path.join(workspaceDir, '.learnings', 'ERRORS.md'), 'utf-8'); } catch { return 0; } return content .split(/^## /m) .slice(1) .filter( (section) => section.includes(`Source: ${SWEEP_SOURCE}`) && section.includes('**Status**: pending'), ).length; } async function handleBootstrap(event) { // Skip sub-agent sessions to avoid bootstrap issues // Sub-agents have sessionKey patterns like "agent:main:subagent:..." const sessionKey = event.sessionKey || ''; if (sessionKey.includes(':subagent:')) { return; } // Inject the reminder as a virtual bootstrap file // Check that bootstrapFiles is an array before pushing if (!Array.isArray(event.context.bootstrapFiles)) { return; } const occupiedByOtherFile = event.context.bootstrapFiles.some( (file) => isObject(file) && file.path === REMINDER_PATH && !isInjectedReminderFile(file), ); if (occupiedByOtherFile) { return; } let reminderContent = REMINDER_CONTENT; const workspaceDir = typeof event.context.workspaceDir === 'string' && event.context.workspaceDir.trim() ? event.context.workspaceDir : undefined; const pendingSweepCount = await countPendingSweepEntries(workspaceDir); if (pendingSweepCount > 0) { const plural = pendingSweepCount === 1 ? 'y' : 'ies'; reminderContent += `\n\n**Pending triage:** ${pendingSweepCount} auto-detected error entr${plural} ` + `(Source: ${SWEEP_SOURCE}) in \`.learnings/ERRORS.md\` await review. ` + 'Confirm, resolve, or delete them when convenient.'; } const cleanedBootstrapFiles = event.context.bootstrapFiles.filter( (file, index, files) => !isInjectedReminderFile(file) || files.findIndex((candidate) => isInjectedReminderFile(candidate)) === index, ); const reminderFile = { name: REMINDER_NAME, path: REMINDER_PATH, content: reminderContent, missing: false, virtual: true, }; const existingIndex = cleanedBootstrapFiles.findIndex((file) => isInjectedReminderFile(file)); if (existingIndex === -1) { cleanedBootstrapFiles.push(reminderFile); } else { cleanedBootstrapFiles[existingIndex] = reminderFile; } event.context.bootstrapFiles = cleanedBootstrapFiles; } const handler = async (event) => { // Safety checks for event structure if (!event || typeof event !== 'object') { return; } if (!event.context || typeof event.context !== 'object') { return; } try { if (event.type === 'agent' && event.action === 'bootstrap') { await handleBootstrap(event); return; } if (event.type === 'command' && (event.action === 'new' || event.action === 'reset')) { await handleSessionEndSweep(event); } } catch (err) { // Never break the gateway on hook failure. if (process.env.SELF_IMPROVEMENT_HOOK_DEBUG) { console.error('[self-improvement] hook failed:', err); } } }; module.exports = handler; module.exports.default = handler;