From ab71fdf821b2e10ed22f1ab554254b832b097f13 Mon Sep 17 00:00:00 2001 From: solstead <168413654+solstead@users.noreply.github.com> Date: Sat, 14 Feb 2026 06:45:45 +0700 Subject: [PATCH] Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * refactor: move bootstrap extras into bundled hook --------- Co-authored-by: Solomon Steadman Co-authored-by: Claude Opus 4.6 Co-authored-by: Clawdbot Co-authored-by: Peter Steinberger --- docs/automation/hooks.md | 45 +++++++- docs/cli/hooks.md | 15 ++- src/agents/bootstrap-files.ts | 1 + src/agents/pi-embedded-runner/compact.ts | 50 +++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 2 + ...rkspace.load-extra-bootstrap-files.test.ts | 53 +++++++++ src/agents/workspace.ts | 81 +++++++++++++ src/auto-reply/reply/commands-core.ts | 44 ++++++++ src/commands/status.command.ts | 4 + src/hooks/bundled/README.md | 14 +++ .../bundled/bootstrap-extra-files/HOOK.md | 53 +++++++++ .../bootstrap-extra-files/handler.test.ts | 106 ++++++++++++++++++ .../bundled/bootstrap-extra-files/handler.ts | 59 ++++++++++ src/plugins/hooks.ts | 15 +++ src/plugins/types.ts | 25 +++++ 15 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 src/agents/workspace.load-extra-bootstrap-files.test.ts create mode 100644 src/hooks/bundled/bootstrap-extra-files/HOOK.md create mode 100644 src/hooks/bundled/bootstrap-extra-files/handler.test.ts create mode 100644 src/hooks/bundled/bootstrap-extra-files/handler.ts diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 2030e9aea..68c583a7a 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -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 ``` diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 6b4f42143..fdf72f834 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -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. diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 30e825171..0954cd40e 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -30,6 +30,7 @@ export async function resolveBootstrapFilesForRun(params: { await loadWorkspaceBootstrapFiles(params.workspaceDir), sessionKey, ); + return applyBootstrapHookOverrides({ files: bootstrapFiles, workspaceDir: params.workspaceDir, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 0eec28249..f50dfd7bc 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 425a30a50..dbb69e73e 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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, }, diff --git a/src/agents/workspace.load-extra-bootstrap-files.test.ts b/src/agents/workspace.load-extra-bootstrap-files.test.ts new file mode 100644 index 000000000..32586029c --- /dev/null +++ b/src/agents/workspace.load-extra-bootstrap-files.test.ts @@ -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"); + }); +}); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 486dff87c..c13fe29f7 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -93,6 +93,19 @@ export type WorkspaceBootstrapFile = { missing: boolean; }; +/** Set of recognized bootstrap filenames for runtime validation */ +const VALID_BOOTSTRAP_NAMES: ReadonlySet = 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 { + 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(); + 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; +} diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index c139fd6f6..e35867084 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -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 { + 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({ diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index cbe5d6d78..04d1c505c 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -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[] = []; diff --git a/src/hooks/bundled/README.md b/src/hooks/bundled/README.md index 4587d20a2..b3fb4e131 100644 --- a/src/hooks/bundled/README.md +++ b/src/hooks/bundled/README.md @@ -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. diff --git a/src/hooks/bundled/bootstrap-extra-files/HOOK.md b/src/hooks/bundled/bootstrap-extra-files/HOOK.md new file mode 100644 index 000000000..a46a07efd --- /dev/null +++ b/src/hooks/bundled/bootstrap-extra-files/HOOK.md @@ -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`). diff --git a/src/hooks/bundled/bootstrap-extra-files/handler.test.ts b/src/hooks/bundled/bootstrap-extra-files/handler.test.ts new file mode 100644 index 000000000..2b945ad07 --- /dev/null +++ b/src/hooks/bundled/bootstrap-extra-files/handler.test.ts @@ -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"]); + }); +}); diff --git a/src/hooks/bundled/bootstrap-extra-files/handler.ts b/src/hooks/bundled/bootstrap-extra-files/handler.ts new file mode 100644 index 000000000..ada728690 --- /dev/null +++ b/src/hooks/bundled/bootstrap-extra-files/handler.ts @@ -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[] { + 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); + 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; diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index d74c23c5b..040ce1d35 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -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 { + 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, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 27c6fff24..32a961df6 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; + before_reset: ( + event: PluginHookBeforeResetEvent, + ctx: PluginHookAgentContext, + ) => Promise | void; message_received: ( event: PluginHookMessageReceivedEvent, ctx: PluginHookMessageContext,