auto: sync OpenClaw config 2026-09-16 16:13
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user