Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context - Pass session messages to before_compaction hook - Add before_reset plugin hook for /new and /reset commands - Add sessionId to plugin hook agent context * feat: extraBootstrapFiles config with glob pattern support Add extraBootstrapFiles to agent defaults config, allowing glob patterns (e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files into agent context every turn. Missing files silently skipped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(status): show custom memory plugins as enabled, not unavailable The status command probes memory availability using the built-in memory-core manager. Custom memory plugins (e.g. via plugin slot) can't be probed this way, so they incorrectly showed "unavailable". Now they show "enabled (plugin X)" without the misleading label. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use async fs.glob and capture pre-compaction messages - Replace globSync (node:fs) with fs.glob (node:fs/promises) to match codebase conventions for async file operations - Capture session.messages BEFORE replaceMessages(limited) so before_compaction hook receives the full conversation history, not the already-truncated list * fix: resolve lint errors from CI (oxlint strict mode) - Add void to fire-and-forget IIFE (no-floating-promises) - Use String() for unknown catch params in template literals - Add curly braces to single-statement if (curly rule) * fix: resolve remaining CI lint errors in workspace.ts - Remove `| string` from WorkspaceBootstrapFileName union (made all typeof members redundant per no-redundant-type-constituents) - Use type assertion for extra bootstrap file names - Drop redundant await on fs.glob() AsyncIterable (await-thenable) * fix: address Greptile review — path traversal guard + fs/promises import - workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles() - commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve symlinks before workspace boundary check Greptile correctly identified that symlinks inside the workspace could point to files outside it, bypassing the path prefix check. Now uses fs.realpath() to resolve symlinks before verifying the real path stays within the workspace boundary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address Greptile review — hook reliability and type safety 1. before_compaction: add compactingCount field so plugins know both the full pre-compaction message count and the truncated count being fed to the compaction LLM. Clarify semantics in comment. 2. loadExtraBootstrapFiles: use path.basename() for the name field so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type instead of an invalid WorkspaceBootstrapFileName cast. 3. before_reset: fire the hook even when no session file exists. Previously, short sessions without a persisted file would silently skip the hook. Now fires with empty messages array so plugins always know a reset occurred. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: validate bootstrap filenames and add compaction hook timeout - Only load extra bootstrap files whose basename matches a recognized workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary files from being injected into agent context. - Wrap before_compaction hook in a 30-second Promise.race timeout so misbehaving plugins cannot stall the compaction pipeline. - Clarify hook comments: before_compaction is intentionally awaited (plugins need messages before they're discarded) but bounded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make before_compaction non-blocking, add sessionFile to after_compaction - before_compaction is now true fire-and-forget — no await, no timeout. Plugins that need full conversation data should persist it themselves and return quickly, or use after_compaction for async processing. - after_compaction now includes sessionFile path so plugins can read the full JSONL transcript asynchronously. All pre-compaction messages are preserved on disk, eliminating the need to block compaction. - Removes Promise.race timeout pattern that didn't actually cancel slow hooks (just raced past them while they continued running). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add sessionFile to before_compaction for parallel processing The session JSONL already has all messages on disk before compaction starts. By providing sessionFile in before_compaction, plugins can read and extract data in parallel with the compaction LLM call rather than waiting for after_compaction. This is the optimal path for memory plugins that need the full conversation history. sessionFile is also kept on after_compaction for plugins that only need to act after compaction completes (analytics, cleanup, etc.). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move bootstrap extras into bundled hook --------- Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Clawdbot <clawdbot@alfie.local> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -41,9 +41,10 @@ The hooks system allows you to:
|
||||
|
||||
### Bundled Hooks
|
||||
|
||||
OpenClaw ships with three bundled hooks that are automatically discovered:
|
||||
OpenClaw ships with four bundled hooks that are automatically discovered:
|
||||
|
||||
- **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new`
|
||||
- **📎 bootstrap-extra-files**: Injects additional workspace bootstrap files from configured glob/path patterns during `agent:bootstrap`
|
||||
- **📝 command-logger**: Logs all command events to `~/.openclaw/logs/commands.log`
|
||||
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
|
||||
|
||||
@@ -484,6 +485,47 @@ Saves session context to memory when you issue `/new`.
|
||||
openclaw hooks enable session-memory
|
||||
```
|
||||
|
||||
### bootstrap-extra-files
|
||||
|
||||
Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`.
|
||||
|
||||
**Events**: `agent:bootstrap`
|
||||
|
||||
**Requirements**: `workspace.dir` must be configured
|
||||
|
||||
**Output**: No files written; bootstrap context is modified in-memory only.
|
||||
|
||||
**Config**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"entries": {
|
||||
"bootstrap-extra-files": {
|
||||
"enabled": true,
|
||||
"paths": ["packages/*/AGENTS.md", "packages/*/TOOLS.md"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- Paths are resolved relative to workspace.
|
||||
- Files must stay inside workspace (realpath-checked).
|
||||
- Only recognized bootstrap basenames are loaded.
|
||||
- Subagent allowlist is preserved (`AGENTS.md` and `TOOLS.md` only).
|
||||
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
openclaw hooks enable bootstrap-extra-files
|
||||
```
|
||||
|
||||
### command-logger
|
||||
|
||||
Logs all command events to a centralized audit file.
|
||||
@@ -618,6 +660,7 @@ The gateway logs hook loading at startup:
|
||||
|
||||
```
|
||||
Registered hook: session-memory -> command:new
|
||||
Registered hook: bootstrap-extra-files -> agent:bootstrap
|
||||
Registered hook: command-logger -> command
|
||||
Registered hook: boot-md -> gateway:startup
|
||||
```
|
||||
|
||||
@@ -32,10 +32,11 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Hooks (3/3 ready)
|
||||
Hooks (4/4 ready)
|
||||
|
||||
Ready:
|
||||
🚀 boot-md ✓ - Run BOOT.md on gateway startup
|
||||
📎 bootstrap-extra-files ✓ - Inject extra workspace bootstrap files during agent bootstrap
|
||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||
```
|
||||
@@ -249,6 +250,18 @@ openclaw hooks enable session-memory
|
||||
|
||||
**See:** [session-memory documentation](/automation/hooks#session-memory)
|
||||
|
||||
### bootstrap-extra-files
|
||||
|
||||
Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`.
|
||||
|
||||
**Enable:**
|
||||
|
||||
```bash
|
||||
openclaw hooks enable bootstrap-extra-files
|
||||
```
|
||||
|
||||
**See:** [bootstrap-extra-files documentation](/automation/hooks#bootstrap-extra-files)
|
||||
|
||||
### command-logger
|
||||
|
||||
Logs all command events to a centralized audit file.
|
||||
|
||||
@@ -30,6 +30,7 @@ export async function resolveBootstrapFilesForRun(params: {
|
||||
await loadWorkspaceBootstrapFiles(params.workspaceDir),
|
||||
sessionKey,
|
||||
);
|
||||
|
||||
return applyBootstrapHookOverrides({
|
||||
files: bootstrapFiles,
|
||||
workspaceDir: params.workspaceDir,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { EmbeddedPiCompactResult } from "./types.js";
|
||||
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
|
||||
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
|
||||
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
|
||||
@@ -431,6 +432,8 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
const validated = transcriptPolicy.validateAnthropicTurns
|
||||
? validateAnthropicTurns(validatedGemini)
|
||||
: validatedGemini;
|
||||
// Capture full message history BEFORE limiting — plugins need the complete conversation
|
||||
const preCompactionMessages = [...session.messages];
|
||||
const truncated = limitHistoryTurns(
|
||||
validated,
|
||||
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
||||
@@ -444,6 +447,34 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
if (limited.length > 0) {
|
||||
session.agent.replaceMessages(limited);
|
||||
}
|
||||
// Run before_compaction hooks (fire-and-forget).
|
||||
// The session JSONL already contains all messages on disk, so plugins
|
||||
// can read sessionFile asynchronously and process in parallel with
|
||||
// the compaction LLM call — no need to block or wait for after_compaction.
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const hookCtx = {
|
||||
agentId: params.sessionKey?.split(":")[0] ?? "main",
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||
};
|
||||
if (hookRunner?.hasHooks("before_compaction")) {
|
||||
hookRunner
|
||||
.runBeforeCompaction(
|
||||
{
|
||||
messageCount: preCompactionMessages.length,
|
||||
compactingCount: limited.length,
|
||||
messages: preCompactionMessages,
|
||||
sessionFile: params.sessionFile,
|
||||
},
|
||||
hookCtx,
|
||||
)
|
||||
.catch((hookErr: unknown) => {
|
||||
log.warn(`before_compaction hook failed: ${String(hookErr)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const result = await session.compact(params.customInstructions);
|
||||
// Estimate tokens after compaction by summing token estimates for remaining messages
|
||||
let tokensAfter: number | undefined;
|
||||
@@ -460,6 +491,25 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
// If estimation fails, leave tokensAfter undefined
|
||||
tokensAfter = undefined;
|
||||
}
|
||||
// Run after_compaction hooks (fire-and-forget).
|
||||
// Also includes sessionFile for plugins that only need to act after
|
||||
// compaction completes (e.g. analytics, cleanup).
|
||||
if (hookRunner?.hasHooks("after_compaction")) {
|
||||
hookRunner
|
||||
.runAfterCompaction(
|
||||
{
|
||||
messageCount: session.messages.length,
|
||||
tokenCount: tokensAfter,
|
||||
compactedCount: limited.length - session.messages.length,
|
||||
sessionFile: params.sessionFile,
|
||||
},
|
||||
hookCtx,
|
||||
)
|
||||
.catch((hookErr) => {
|
||||
log.warn(`after_compaction hook failed: ${hookErr}`);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
|
||||
@@ -749,6 +749,7 @@ export async function runEmbeddedAttempt(
|
||||
{
|
||||
agentId: hookAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
},
|
||||
@@ -890,6 +891,7 @@ export async function runEmbeddedAttempt(
|
||||
{
|
||||
agentId: hookAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
},
|
||||
|
||||
53
src/agents/workspace.load-extra-bootstrap-files.test.ts
Normal file
53
src/agents/workspace.load-extra-bootstrap-files.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeTempWorkspace } from "../test-helpers/workspace.js";
|
||||
import { loadExtraBootstrapFiles } from "./workspace.js";
|
||||
|
||||
describe("loadExtraBootstrapFiles", () => {
|
||||
it("loads recognized bootstrap files from glob patterns", async () => {
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-extra-bootstrap-glob-");
|
||||
const packageDir = path.join(workspaceDir, "packages", "core");
|
||||
await fs.mkdir(packageDir, { recursive: true });
|
||||
await fs.writeFile(path.join(packageDir, "TOOLS.md"), "tools", "utf-8");
|
||||
await fs.writeFile(path.join(packageDir, "README.md"), "not bootstrap", "utf-8");
|
||||
|
||||
const files = await loadExtraBootstrapFiles(workspaceDir, ["packages/*/*"]);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]?.name).toBe("TOOLS.md");
|
||||
expect(files[0]?.content).toBe("tools");
|
||||
});
|
||||
|
||||
it("keeps path-traversal attempts outside workspace excluded", async () => {
|
||||
const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-root-");
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const outsideDir = path.join(rootDir, "outside");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(path.join(outsideDir, "AGENTS.md"), "outside", "utf-8");
|
||||
|
||||
const files = await loadExtraBootstrapFiles(workspaceDir, ["../outside/AGENTS.md"]);
|
||||
|
||||
expect(files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("supports symlinked workspace roots with realpath checks", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-symlink-");
|
||||
const realWorkspace = path.join(rootDir, "real-workspace");
|
||||
const linkedWorkspace = path.join(rootDir, "linked-workspace");
|
||||
await fs.mkdir(realWorkspace, { recursive: true });
|
||||
await fs.writeFile(path.join(realWorkspace, "AGENTS.md"), "linked agents", "utf-8");
|
||||
await fs.symlink(realWorkspace, linkedWorkspace, "dir");
|
||||
|
||||
const files = await loadExtraBootstrapFiles(linkedWorkspace, ["AGENTS.md"]);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]?.name).toBe("AGENTS.md");
|
||||
expect(files[0]?.content).toBe("linked agents");
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,19 @@ export type WorkspaceBootstrapFile = {
|
||||
missing: boolean;
|
||||
};
|
||||
|
||||
/** Set of recognized bootstrap filenames for runtime validation */
|
||||
const VALID_BOOTSTRAP_NAMES: ReadonlySet<string> = new Set([
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_SOUL_FILENAME,
|
||||
DEFAULT_TOOLS_FILENAME,
|
||||
DEFAULT_IDENTITY_FILENAME,
|
||||
DEFAULT_USER_FILENAME,
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
DEFAULT_MEMORY_FILENAME,
|
||||
DEFAULT_MEMORY_ALT_FILENAME,
|
||||
]);
|
||||
|
||||
async function writeFileIfMissing(filePath: string, content: string) {
|
||||
try {
|
||||
await fs.writeFile(filePath, content, {
|
||||
@@ -329,3 +342,71 @@ export function filterBootstrapFilesForSession(
|
||||
}
|
||||
return files.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name));
|
||||
}
|
||||
|
||||
export async function loadExtraBootstrapFiles(
|
||||
dir: string,
|
||||
extraPatterns: string[],
|
||||
): Promise<WorkspaceBootstrapFile[]> {
|
||||
if (!extraPatterns.length) {
|
||||
return [];
|
||||
}
|
||||
const resolvedDir = resolveUserPath(dir);
|
||||
let realResolvedDir = resolvedDir;
|
||||
try {
|
||||
realResolvedDir = await fs.realpath(resolvedDir);
|
||||
} catch {
|
||||
// Keep lexical root if realpath fails.
|
||||
}
|
||||
|
||||
// Resolve glob patterns into concrete file paths
|
||||
const resolvedPaths = new Set<string>();
|
||||
for (const pattern of extraPatterns) {
|
||||
if (pattern.includes("*") || pattern.includes("?") || pattern.includes("{")) {
|
||||
try {
|
||||
const matches = fs.glob(pattern, { cwd: resolvedDir });
|
||||
for await (const m of matches) {
|
||||
resolvedPaths.add(m);
|
||||
}
|
||||
} catch {
|
||||
// glob not available or pattern error — fall back to literal
|
||||
resolvedPaths.add(pattern);
|
||||
}
|
||||
} else {
|
||||
resolvedPaths.add(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
const result: WorkspaceBootstrapFile[] = [];
|
||||
for (const relPath of resolvedPaths) {
|
||||
const filePath = path.resolve(resolvedDir, relPath);
|
||||
// Guard against path traversal — resolved path must stay within workspace
|
||||
if (!filePath.startsWith(resolvedDir + path.sep) && filePath !== resolvedDir) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Resolve symlinks and verify the real path is still within workspace
|
||||
const realFilePath = await fs.realpath(filePath);
|
||||
if (
|
||||
!realFilePath.startsWith(realResolvedDir + path.sep) &&
|
||||
realFilePath !== realResolvedDir
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Only load files whose basename is a recognized bootstrap filename
|
||||
const baseName = path.basename(relPath);
|
||||
if (!VALID_BOOTSTRAP_NAMES.has(baseName)) {
|
||||
continue;
|
||||
}
|
||||
const content = await fs.readFile(realFilePath, "utf-8");
|
||||
result.push({
|
||||
name: baseName as WorkspaceBootstrapFileName,
|
||||
path: filePath,
|
||||
content,
|
||||
missing: false,
|
||||
});
|
||||
} catch {
|
||||
// Silently skip missing extra files
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type {
|
||||
CommandHandler,
|
||||
CommandHandlerResult,
|
||||
@@ -5,6 +6,7 @@ import type {
|
||||
} from "./commands-types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||
@@ -104,6 +106,48 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fire before_reset plugin hook — extract memories before session history is lost
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (hookRunner?.hasHooks("before_reset")) {
|
||||
const prevEntry = params.previousSessionEntry;
|
||||
const sessionFile = prevEntry?.sessionFile;
|
||||
// Fire-and-forget: read old session messages and run hook
|
||||
void (async () => {
|
||||
try {
|
||||
const messages: unknown[] = [];
|
||||
if (sessionFile) {
|
||||
const content = await fs.readFile(sessionFile, "utf-8");
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "message" && entry.message) {
|
||||
messages.push(entry.message);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose("before_reset: no session file available, firing hook with empty messages");
|
||||
}
|
||||
await hookRunner.runBeforeReset(
|
||||
{ sessionFile, messages, reason: commandAction },
|
||||
{
|
||||
agentId: params.sessionKey?.split(":")[0] ?? "main",
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: prevEntry?.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
},
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
logVerbose(`before_reset hook failed: ${String(err)}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
|
||||
@@ -312,6 +312,10 @@ export async function statusCommand(
|
||||
}
|
||||
if (!memory) {
|
||||
const slot = memoryPlugin.slot ? `plugin ${memoryPlugin.slot}` : "plugin";
|
||||
// Custom (non-built-in) memory plugins can't be probed — show enabled, not unavailable
|
||||
if (memoryPlugin.slot && memoryPlugin.slot !== "memory-core") {
|
||||
return `enabled (${slot})`;
|
||||
}
|
||||
return muted(`enabled (${slot}) · unavailable`);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -18,6 +18,20 @@ Automatically saves session context to memory when you issue `/new`.
|
||||
openclaw hooks enable session-memory
|
||||
```
|
||||
|
||||
### 📎 bootstrap-extra-files
|
||||
|
||||
Injects extra bootstrap files (for example monorepo `AGENTS.md`/`TOOLS.md`) during prompt assembly.
|
||||
|
||||
**Events**: `agent:bootstrap`
|
||||
**What it does**: Expands configured workspace glob/path patterns and appends matching bootstrap files to injected context.
|
||||
**Output**: No files written; context is modified in-memory only.
|
||||
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
openclaw hooks enable bootstrap-extra-files
|
||||
```
|
||||
|
||||
### 📝 command-logger
|
||||
|
||||
Logs all command events to a centralized audit file.
|
||||
|
||||
53
src/hooks/bundled/bootstrap-extra-files/HOOK.md
Normal file
53
src/hooks/bundled/bootstrap-extra-files/HOOK.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: bootstrap-extra-files
|
||||
description: "Inject additional workspace bootstrap files via glob/path patterns"
|
||||
homepage: https://docs.openclaw.ai/automation/hooks#bootstrap-extra-files
|
||||
metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "📎",
|
||||
"events": ["agent:bootstrap"],
|
||||
"requires": { "config": ["workspace.dir"] },
|
||||
"install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with OpenClaw" }],
|
||||
},
|
||||
}
|
||||
---
|
||||
|
||||
# Bootstrap Extra Files Hook
|
||||
|
||||
Loads additional bootstrap files into `Project Context` during `agent:bootstrap`.
|
||||
|
||||
## Why
|
||||
|
||||
Use this when your workspace has multiple context roots (for example monorepos) and
|
||||
you want to include extra `AGENTS.md`/`TOOLS.md`-class files without changing the
|
||||
workspace root.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"entries": {
|
||||
"bootstrap-extra-files": {
|
||||
"enabled": true,
|
||||
"paths": ["packages/*/AGENTS.md", "packages/*/TOOLS.md"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `paths` (string[]): preferred list of glob/path patterns.
|
||||
- `patterns` (string[]): alias of `paths`.
|
||||
- `files` (string[]): alias of `paths`.
|
||||
|
||||
All paths are resolved from the workspace and must stay inside it (including realpath checks).
|
||||
Only recognized bootstrap basenames are loaded (`AGENTS.md`, `SOUL.md`, `TOOLS.md`,
|
||||
`IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`, `MEMORY.md`, `memory.md`).
|
||||
106
src/hooks/bundled/bootstrap-extra-files/handler.test.ts
Normal file
106
src/hooks/bundled/bootstrap-extra-files/handler.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { AgentBootstrapHookContext } from "../../hooks.js";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
|
||||
import { createHookEvent } from "../../hooks.js";
|
||||
import handler from "./handler.js";
|
||||
|
||||
describe("bootstrap-extra-files hook", () => {
|
||||
it("appends extra bootstrap files from configured patterns", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-bootstrap-extra-");
|
||||
const extraDir = path.join(tempDir, "packages", "core");
|
||||
await fs.mkdir(extraDir, { recursive: true });
|
||||
await fs.writeFile(path.join(extraDir, "AGENTS.md"), "extra agents", "utf-8");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
hooks: {
|
||||
internal: {
|
||||
entries: {
|
||||
"bootstrap-extra-files": {
|
||||
enabled: true,
|
||||
paths: ["packages/*/AGENTS.md"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const context: AgentBootstrapHookContext = {
|
||||
workspaceDir: tempDir,
|
||||
bootstrapFiles: [
|
||||
{
|
||||
name: "AGENTS.md",
|
||||
path: await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: "AGENTS.md",
|
||||
content: "root agents",
|
||||
}),
|
||||
content: "root agents",
|
||||
missing: false,
|
||||
},
|
||||
],
|
||||
cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
};
|
||||
|
||||
const event = createHookEvent("agent", "bootstrap", "agent:main:main", context);
|
||||
await handler(event);
|
||||
|
||||
const injected = context.bootstrapFiles.filter((f) => f.name === "AGENTS.md");
|
||||
expect(injected).toHaveLength(2);
|
||||
expect(injected.some((f) => f.path.endsWith(path.join("packages", "core", "AGENTS.md")))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("re-applies subagent bootstrap allowlist after extras are added", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-bootstrap-extra-subagent-");
|
||||
const extraDir = path.join(tempDir, "packages", "persona");
|
||||
await fs.mkdir(extraDir, { recursive: true });
|
||||
await fs.writeFile(path.join(extraDir, "SOUL.md"), "evil", "utf-8");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
hooks: {
|
||||
internal: {
|
||||
entries: {
|
||||
"bootstrap-extra-files": {
|
||||
enabled: true,
|
||||
paths: ["packages/*/SOUL.md"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const context: AgentBootstrapHookContext = {
|
||||
workspaceDir: tempDir,
|
||||
bootstrapFiles: [
|
||||
{
|
||||
name: "AGENTS.md",
|
||||
path: await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: "AGENTS.md",
|
||||
content: "root agents",
|
||||
}),
|
||||
content: "root agents",
|
||||
missing: false,
|
||||
},
|
||||
{
|
||||
name: "TOOLS.md",
|
||||
path: await writeWorkspaceFile({ dir: tempDir, name: "TOOLS.md", content: "root tools" }),
|
||||
content: "root tools",
|
||||
missing: false,
|
||||
},
|
||||
],
|
||||
cfg,
|
||||
sessionKey: "agent:main:subagent:abc",
|
||||
};
|
||||
|
||||
const event = createHookEvent("agent", "bootstrap", "agent:main:subagent:abc", context);
|
||||
await handler(event);
|
||||
|
||||
expect(context.bootstrapFiles.map((f) => f.name).toSorted()).toEqual(["AGENTS.md", "TOOLS.md"]);
|
||||
});
|
||||
});
|
||||
59
src/hooks/bundled/bootstrap-extra-files/handler.ts
Normal file
59
src/hooks/bundled/bootstrap-extra-files/handler.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
filterBootstrapFilesForSession,
|
||||
loadExtraBootstrapFiles,
|
||||
} from "../../../agents/workspace.js";
|
||||
import { resolveHookConfig } from "../../config.js";
|
||||
import { isAgentBootstrapEvent, type HookHandler } from "../../hooks.js";
|
||||
|
||||
const HOOK_KEY = "bootstrap-extra-files";
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((v) => (typeof v === "string" ? v.trim() : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveExtraBootstrapPatterns(hookConfig: Record<string, unknown>): string[] {
|
||||
const fromPaths = normalizeStringArray(hookConfig.paths);
|
||||
if (fromPaths.length > 0) {
|
||||
return fromPaths;
|
||||
}
|
||||
const fromPatterns = normalizeStringArray(hookConfig.patterns);
|
||||
if (fromPatterns.length > 0) {
|
||||
return fromPatterns;
|
||||
}
|
||||
return normalizeStringArray(hookConfig.files);
|
||||
}
|
||||
|
||||
const bootstrapExtraFilesHook: HookHandler = async (event) => {
|
||||
if (!isAgentBootstrapEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = event.context;
|
||||
const hookConfig = resolveHookConfig(context.cfg, HOOK_KEY);
|
||||
if (!hookConfig || hookConfig.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const patterns = resolveExtraBootstrapPatterns(hookConfig as Record<string, unknown>);
|
||||
if (patterns.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const extras = await loadExtraBootstrapFiles(context.workspaceDir, patterns);
|
||||
if (extras.length === 0) {
|
||||
return;
|
||||
}
|
||||
context.bootstrapFiles = filterBootstrapFilesForSession(
|
||||
[...context.bootstrapFiles, ...extras],
|
||||
context.sessionKey,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`[bootstrap-extra-files] failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default bootstrapExtraFilesHook;
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
PluginHookBeforeAgentStartEvent,
|
||||
PluginHookBeforeAgentStartResult,
|
||||
PluginHookBeforeCompactionEvent,
|
||||
PluginHookBeforeResetEvent,
|
||||
PluginHookBeforeToolCallEvent,
|
||||
PluginHookBeforeToolCallResult,
|
||||
PluginHookGatewayContext,
|
||||
@@ -42,6 +43,7 @@ export type {
|
||||
PluginHookBeforeAgentStartResult,
|
||||
PluginHookAgentEndEvent,
|
||||
PluginHookBeforeCompactionEvent,
|
||||
PluginHookBeforeResetEvent,
|
||||
PluginHookAfterCompactionEvent,
|
||||
PluginHookMessageContext,
|
||||
PluginHookMessageReceivedEvent,
|
||||
@@ -230,6 +232,18 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
||||
return runVoidHook("after_compaction", event, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run before_reset hook.
|
||||
* Fired when /new or /reset clears a session, before messages are lost.
|
||||
* Runs in parallel (fire-and-forget).
|
||||
*/
|
||||
async function runBeforeReset(
|
||||
event: PluginHookBeforeResetEvent,
|
||||
ctx: PluginHookAgentContext,
|
||||
): Promise<void> {
|
||||
return runVoidHook("before_reset", event, ctx);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Message Hooks
|
||||
// =========================================================================
|
||||
@@ -447,6 +461,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
||||
runAgentEnd,
|
||||
runBeforeCompaction,
|
||||
runAfterCompaction,
|
||||
runBeforeReset,
|
||||
// Message hooks
|
||||
runMessageReceived,
|
||||
runMessageSending,
|
||||
|
||||
@@ -300,6 +300,7 @@ export type PluginHookName =
|
||||
| "agent_end"
|
||||
| "before_compaction"
|
||||
| "after_compaction"
|
||||
| "before_reset"
|
||||
| "message_received"
|
||||
| "message_sending"
|
||||
| "message_sent"
|
||||
@@ -315,6 +316,7 @@ export type PluginHookName =
|
||||
export type PluginHookAgentContext = {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
workspaceDir?: string;
|
||||
messageProvider?: string;
|
||||
};
|
||||
@@ -340,14 +342,33 @@ export type PluginHookAgentEndEvent = {
|
||||
|
||||
// Compaction hooks
|
||||
export type PluginHookBeforeCompactionEvent = {
|
||||
/** Total messages in the session before any truncation or compaction */
|
||||
messageCount: number;
|
||||
/** Messages being fed to the compaction LLM (after history-limit truncation) */
|
||||
compactingCount?: number;
|
||||
tokenCount?: number;
|
||||
messages?: unknown[];
|
||||
/** Path to the session JSONL transcript. All messages are already on disk
|
||||
* before compaction starts, so plugins can read this file asynchronously
|
||||
* and process in parallel with the compaction LLM call. */
|
||||
sessionFile?: string;
|
||||
};
|
||||
|
||||
// before_reset hook — fired when /new or /reset clears a session
|
||||
export type PluginHookBeforeResetEvent = {
|
||||
sessionFile?: string;
|
||||
messages?: unknown[];
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type PluginHookAfterCompactionEvent = {
|
||||
messageCount: number;
|
||||
tokenCount?: number;
|
||||
compactedCount: number;
|
||||
/** Path to the session JSONL transcript. All pre-compaction messages are
|
||||
* preserved on disk, so plugins can read and process them asynchronously
|
||||
* without blocking the compaction pipeline. */
|
||||
sessionFile?: string;
|
||||
};
|
||||
|
||||
// Message context
|
||||
@@ -486,6 +507,10 @@ export type PluginHookHandlerMap = {
|
||||
event: PluginHookAfterCompactionEvent,
|
||||
ctx: PluginHookAgentContext,
|
||||
) => Promise<void> | void;
|
||||
before_reset: (
|
||||
event: PluginHookBeforeResetEvent,
|
||||
ctx: PluginHookAgentContext,
|
||||
) => Promise<void> | void;
|
||||
message_received: (
|
||||
event: PluginHookMessageReceivedEvent,
|
||||
ctx: PluginHookMessageContext,
|
||||
|
||||
Reference in New Issue
Block a user