auto: sync OpenClaw config 2026-09-16 16:13

This commit is contained in:
2026-09-16 16:13:36 +08:00
parent c51006a57d
commit 5ac4289f66
35 changed files with 4827 additions and 65 deletions
@@ -0,0 +1,68 @@
---
name: self-improvement
description: "Injects self-improvement reminder at bootstrap and sweeps ended sessions for errors"
metadata: {"openclaw":{"emoji":"🧠","events":["agent:bootstrap","command:new","command:reset"]}}
---
# Self-Improvement Hook
Injects a reminder to evaluate learnings during agent bootstrap, and detects
errors from ended sessions.
OpenClaw has no per-tool-call hook event, so errors cannot be detected in
real time after each command. This hook detects them with a session-end
error sweep instead.
## What It Does
**On `agent:bootstrap`** (before workspace files are injected):
- Adds a reminder block to check `.learnings/` for relevant entries
- Prompts the agent to log corrections, errors, and discoveries
- If auto-detected errors are awaiting triage, includes a pending-triage note
**On `command:new` / `command:reset`** (session end):
- Locates the transcript of the session that just ended
(`context.previousSessionEntry.sessionFile`, falling back to
`<workspace>/sessions/<sessionId>.jsonl`)
- Scans it against a fixed error-pattern list
(`Error:`, `command not found`, `Traceback`, `npm ERR!`, …)
- Appends a `pending` entry to `<workspace>/.learnings/ERRORS.md` with short,
truncated, redacted excerpts (max 5 per sweep) for the next session to triage
- Stamps each entry with deterministic `Pattern-Key` values derived from the
matched pattern (e.g. `deps.module-not-found`, `shell.command-not-found`),
so auto-detected errors can be deduplicated and recurrence-counted by key
(see the Pattern-Key Taxonomy in `SKILL.md`)
## Opt-In and Safety
- The sweep only runs when `<workspace>/.learnings/` exists — create that
directory to enable it, delete it to disable it
- `ERRORS.md` is created only if missing and is otherwise appended to, never
overwritten
- Excerpts are truncated to 200 characters and common secret shapes (bearer
tokens, API keys, GitHub/Slack/AWS tokens, JWTs, long opaque blobs) are
redacted before writing; excerpts already present in `ERRORS.md` are skipped
- Hook failures are swallowed so the gateway is never affected; set
`SELF_IMPROVEMENT_HOOK_DEBUG=1` to log failures
## Configuration
No configuration needed. Enable with:
```bash
openclaw hooks enable self-improvement
```
Enable the error sweep by creating the learnings directory:
```bash
mkdir -p ~/.openclaw/workspace/.learnings
```
## Testing
```bash
node --test hooks/openclaw/handler.test.js
```
@@ -0,0 +1,448 @@
/**
* 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;
@@ -0,0 +1,229 @@
/**
* Tests for the OpenClaw self-improvement hook.
*
* Run with: node --test hooks/openclaw/
* (no dependencies; uses the built-in node:test runner)
*/
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { test, beforeEach, afterEach } = require('node:test');
const handler = require('./handler.js');
let workspaceDir;
beforeEach(async () => {
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'self-improvement-hook-'));
});
afterEach(async () => {
await fs.rm(workspaceDir, { recursive: true, force: true });
});
function makeBootstrapEvent(overrides = {}) {
return {
type: 'agent',
action: 'bootstrap',
sessionKey: 'agent:main:whatsapp',
timestamp: new Date('2026-07-04T12:00:00Z'),
messages: [],
context: { workspaceDir, bootstrapFiles: [] },
...overrides,
};
}
function makeCommandEvent(action, sessionFile) {
return {
type: 'command',
action,
sessionKey: 'agent:main:whatsapp',
timestamp: new Date('2026-07-04T12:00:00Z'),
messages: [],
context: {
workspaceDir,
commandSource: 'whatsapp',
previousSessionEntry: { sessionId: 'abc123', sessionFile },
},
};
}
async function writeTranscript(lines) {
const sessionsDir = path.join(workspaceDir, 'sessions');
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, 'abc123.jsonl');
await fs.writeFile(sessionFile, lines.map((line) => JSON.stringify(line)).join('\n'));
return sessionFile;
}
function toolResultLine(text) {
return {
type: 'message',
message: { role: 'toolResult', content: [{ type: 'text', text }] },
};
}
const errorsFile = () => path.join(workspaceDir, '.learnings', 'ERRORS.md');
test('bootstrap injects the reminder as a virtual file', async () => {
const event = makeBootstrapEvent();
await handler(event);
assert.equal(event.context.bootstrapFiles.length, 1);
const injected = event.context.bootstrapFiles[0];
assert.equal(injected.path, 'SELF_IMPROVEMENT_REMINDER.md');
assert.equal(injected.virtual, true);
assert.match(injected.content, /## Self-Improvement Reminder/);
assert.doesNotMatch(injected.content, /Pending triage/);
});
test('bootstrap skips sub-agent sessions', async () => {
const event = makeBootstrapEvent({ sessionKey: 'agent:main:subagent:xyz' });
await handler(event);
assert.equal(event.context.bootstrapFiles.length, 0);
});
test('bootstrap deduplicates a previously injected reminder', async () => {
const event = makeBootstrapEvent();
event.context.bootstrapFiles.push({
name: 'SELF_IMPROVEMENT_REMINDER.md',
path: 'SELF_IMPROVEMENT_REMINDER.md',
content: '## Self-Improvement Reminder\n\nstale copy',
virtual: true,
});
await handler(event);
assert.equal(event.context.bootstrapFiles.length, 1);
});
test('session-end sweep appends detected errors to ERRORS.md', async () => {
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
const sessionFile = await writeTranscript([
{ type: 'message', message: { role: 'user', content: 'run the build' } },
toolResultLine('npm ERR! missing script: build\nbash: tsc: command not found'),
{ type: 'message', message: { role: 'assistant', content: 'Build is broken.' } },
]);
await handler(makeCommandEvent('new', sessionFile));
const content = await fs.readFile(errorsFile(), 'utf-8');
assert.match(content, /^# Errors/);
assert.match(content, /## \[ERR-20260704-[A-Z0-9]{3}\] openclaw_session_sweep/);
assert.match(content, /npm ERR! missing script: build/);
assert.match(content, /command not found/);
assert.match(content, /Source: openclaw-error-sweep/);
assert.match(content, /\*\*Status\*\*: pending/);
assert.match(content, /- Pattern-Key: deps\.npm-error/);
assert.match(content, /- Pattern-Key: shell\.command-not-found/);
});
test('sweep stamps the most specific Pattern-Key and dedupes keys', async () => {
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
const sessionFile = await writeTranscript([
// 'ModuleNotFoundError' must win over the generic 'Error:'/'Traceback' buckets
toolResultLine("ModuleNotFoundError: No module named 'requests'"),
toolResultLine('TypeError: cannot read properties of undefined (first)'),
toolResultLine('TypeError: cannot read properties of undefined (second)'),
]);
await handler(makeCommandEvent('new', sessionFile));
const content = await fs.readFile(errorsFile(), 'utf-8');
assert.match(content, /- Pattern-Key: deps\.module-not-found/);
const typeErrorKeys = content.split('- Pattern-Key: runtime.type-error').length - 1;
assert.equal(typeErrorKeys, 1);
assert.doesNotMatch(content, /- Pattern-Key: runtime\.error/);
});
test('sweep does nothing when .learnings/ does not exist (opt-in gate)', async () => {
const sessionFile = await writeTranscript([toolResultLine('fatal: not a git repository')]);
await handler(makeCommandEvent('new', sessionFile));
await assert.rejects(fs.access(errorsFile()));
});
test('sweep does nothing when the transcript has no errors', async () => {
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
const sessionFile = await writeTranscript([
{ type: 'message', message: { role: 'assistant', content: 'All good!' } },
]);
await handler(makeCommandEvent('new', sessionFile));
await assert.rejects(fs.access(errorsFile()));
});
test('sweep never overwrites an existing ERRORS.md', async () => {
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
await fs.writeFile(errorsFile(), '# Errors\n\nExisting notes.\n\n---\n');
const sessionFile = await writeTranscript([toolResultLine('Error: connection refused')]);
await handler(makeCommandEvent('reset', sessionFile));
const content = await fs.readFile(errorsFile(), 'utf-8');
assert.match(content, /Existing notes\./);
assert.match(content, /Error: connection refused/);
});
test('sweep is idempotent for already-logged excerpts', async () => {
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
const sessionFile = await writeTranscript([toolResultLine('Error: connection refused')]);
await handler(makeCommandEvent('new', sessionFile));
await handler(makeCommandEvent('new', sessionFile));
const content = await fs.readFile(errorsFile(), 'utf-8');
const occurrences = content.split('Error: connection refused').length - 1;
assert.equal(occurrences, 1);
});
test('sweep redacts secrets and truncates long lines', async () => {
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
const longTail = 'x'.repeat(300);
// Fixture secret is assembled at runtime so the literal never appears in
// this file and secret scanners don't flag it as an exposed credential.
const fakeKey = ['sk', 'live', '1234567890'].join('-');
const sessionFile = await writeTranscript([
toolResultLine(
`Error: request failed with api_key=${fakeKey} Bearer abc.def.ghi token: hunter2 ${longTail}`,
),
]);
await handler(makeCommandEvent('new', sessionFile));
const content = await fs.readFile(errorsFile(), 'utf-8');
assert.doesNotMatch(content, new RegExp(fakeKey));
assert.doesNotMatch(content, /hunter2/);
assert.match(content, /\[REDACTED\]/);
assert.doesNotMatch(content, /x{250}/);
});
test('sweep falls back to <workspace>/sessions/<sessionId>.jsonl', async () => {
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
await writeTranscript([toolResultLine('Traceback (most recent call last):')]);
await handler(makeCommandEvent('new', undefined));
const content = await fs.readFile(errorsFile(), 'utf-8');
assert.match(content, /Traceback/);
});
test('bootstrap surfaces pending sweep entries for triage', async () => {
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
const sessionFile = await writeTranscript([toolResultLine('Permission denied (publickey)')]);
await handler(makeCommandEvent('new', sessionFile));
const event = makeBootstrapEvent();
await handler(event);
const injected = event.context.bootstrapFiles[0];
assert.match(injected.content, /\*\*Pending triage:\*\* 1 auto-detected error entry/);
});
test('handler ignores unrelated events and malformed input', async () => {
await handler(null);
await handler({ type: 'gateway', action: 'startup', context: {} });
await handler({ type: 'command', action: 'stop', context: { workspaceDir } });
// A sweep with a missing transcript must not throw.
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
await handler(makeCommandEvent('new', path.join(workspaceDir, 'sessions', 'missing.jsonl')));
await assert.rejects(fs.access(errorsFile()));
});
@@ -0,0 +1,465 @@
/**
* 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.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import type { HookHandler } from 'openclaw/hooks';
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.`;
// 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: Array<[string, string]> = [
['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'],
];
interface SweepExcerpt {
excerpt: string;
patternKey: string;
}
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: Array<[RegExp, string]> = [
[/\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]'],
];
type HookEvent = Parameters<HookHandler>[0];
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isInjectedReminderFile(value: unknown): boolean {
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: string): string {
let result = text;
for (const [pattern, replacement] of REDACTION_RULES) {
result = result.replace(pattern, replacement);
}
return result;
}
function sanitizeExcerptLine(line: string): string {
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: unknown, out: string[], depth = 0): void {
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: string): string | null {
for (const [pattern, patternKey] of ERROR_PATTERN_KEYS) {
if (line.includes(pattern)) {
return patternKey;
}
}
return null;
}
async function scanTranscriptForErrors(sessionFilePath: string): Promise<SweepExcerpt[]> {
let raw: string;
try {
raw = await fs.readFile(sessionFilePath, 'utf-8');
} catch {
return [];
}
const excerpts: SweepExcerpt[] = [];
const seen = new Set<string>();
for (const jsonLine of raw.split('\n')) {
if (excerpts.length >= MAX_EXCERPTS) {
break;
}
const trimmed = jsonLine.trim();
if (!trimmed) {
continue;
}
let entry: unknown;
try {
entry = JSON.parse(trimmed);
} catch {
continue;
}
if (!isObject(entry) || !isObject(entry.message)) {
continue;
}
const fragments: string[] = [];
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: Record<string, unknown>,
workspaceDir: string | undefined,
): string | undefined {
const sessionEntry = isObject(context.previousSessionEntry)
? context.previousSessionEntry
: isObject(context.sessionEntry)
? context.sessionEntry
: {};
const sessionFile = (sessionEntry as Record<string, unknown>).sessionFile;
if (typeof sessionFile === 'string' && sessionFile.trim()) {
return sessionFile;
}
const sessionIdValue = (sessionEntry as Record<string, unknown>).sessionId;
const sessionId = typeof sessionIdValue === 'string' ? sessionIdValue.trim() : '';
if (sessionId && workspaceDir) {
return path.join(workspaceDir, 'sessions', `${sessionId}.jsonl`);
}
return undefined;
}
function generateEntryId(timestamp: Date): string {
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: {
excerpts: SweepExcerpt[];
sessionKey: string;
sessionFilePath: string;
action: string;
timestamp: Date;
}): string {
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: HookEvent): Promise<void> {
const context = event.context as Record<string, unknown>;
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 as { code?: string }).code !== 'EEXIST') {
throw err;
}
}
}
await fs.appendFile(errorsFilePath, `\n${entry}\n`);
}
async function countPendingSweepEntries(workspaceDir: string | undefined): Promise<number> {
if (!workspaceDir) {
return 0;
}
let content: string;
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: HookEvent): Promise<void> {
// 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;
}
const context = event.context as Record<string, unknown>;
// Inject the reminder as a virtual bootstrap file
// Check that bootstrapFiles is an array before pushing
if (!Array.isArray(context.bootstrapFiles)) {
return;
}
const occupiedByOtherFile = context.bootstrapFiles.some(
(file) => isObject(file) && file.path === REMINDER_PATH && !isInjectedReminderFile(file),
);
if (occupiedByOtherFile) {
return;
}
let reminderContent = REMINDER_CONTENT;
const workspaceDir =
typeof context.workspaceDir === 'string' && context.workspaceDir.trim()
? 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 = 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;
}
context.bootstrapFiles = cleanedBootstrapFiles;
}
const handler: HookHandler = 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);
}
}
};
export default handler;