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,