449 lines
13 KiB
JavaScript
449 lines
13 KiB
JavaScript
/**
|
|
* 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
|
|
* <workspace>/.learnings/ERRORS.md.
|
|
*
|
|
* The sweep is opt-in: it only runs when <workspace>/.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;
|