โ† Back to PRs

#17346: feat(hooks): add message_persist hook for all transcript messages

by clawee-vanguard open 2026-02-15 17:26 View on GitHub โ†’
agents stale size: M
## Summary Adds a new synchronous plugin hook `message_persist` that is called for **all messages** (user, assistant, system, toolResult) before they are written to the session transcript on disk. ## Why This Hook Matters OpenClaw's plugin system lets agents extend the platform without forking core. But there's a gap: **there's no way for a plugin to intercept user, assistant, or system messages before they're written to disk.** The existing `tool_result_persist` hook only covers tool results. If you want to build: - ๐Ÿ”’ **At-rest encryption** โ€” encrypt ALL transcript content, not just tool results - ๐Ÿ”‡ **PII redaction** โ€” strip sensitive data from every message type - ๐Ÿ“ **Audit logging** โ€” complete conversation capture for compliance - ๐Ÿท๏ธ **Metadata injection** โ€” tag messages with provenance, timestamps, or labels ...you're stuck. You'd have to fork OpenClaw core. This hook closes that gap. A plugin registers `message_persist`, and every message flows through it before hitting disk โ€” same pattern plugin authors already know from `tool_result_persist`. ## How We Built This (Development Process) This PR was developed by an AI agent (Clawee ๐Ÿพ) working through OpenClaw's own plugin system. Here's the process โ€” useful as a template for agent-contributed PRs: 1. **Identified the need**: Building an `@openclaw/vault` encryption plugin. Discovered `tool_result_persist` existed but couldn't intercept user/assistant/system messages. 2. **Read the source**: Traced the persistence path โ€” `SessionManager._persist()` โ†’ `appendFileSync()` โ†’ found `transformMessageForPersistence` callback in `session-tool-result-guard.ts` already intercepted ALL messages but wasn't exposed as a hook. 3. **Mirrored the existing pattern**: Copied the `tool_result_persist` implementation exactly โ€” same synchronous execution, same priority ordering, same async rejection, same zero-overhead guard when no hooks registered. 4. **Wired it in**: Connected the new hook to the existing `transformMessageForPersistence` callback, running after input provenance tagging. 5. **Validated**: TypeScript compiles, linting clean, formatting clean. ## Design The hook follows the exact same synchronous, sequential pattern as `tool_result_persist`: - Handlers run in priority order (higher first) - Each handler may return `{ message }` to replace the message for the next handler - Async handlers are rejected with a warning (hot path, same as `tool_result_persist`) - When no hooks are registered, zero overhead (guard check skipped entirely) ### โš ๏ธ Double-hook note for `toolResult` messages `toolResult` messages pass through **both** `message_persist` (via `transformMessageForPersistence`) and `tool_result_persist` (via `transformToolResultForPersistence`). This is by design โ€” most plugins only need `message_persist` to cover everything. But if you register both hooks, guard against double-processing: ```ts // Example: skip if already encrypted api.registerHook('message_persist', (event) => { if (isAlreadyEncrypted(event.message)) return; // guard return { message: encrypt(event.message) }; }); ``` ## Changes | File | Change | |------|--------| | `src/plugins/types.ts` | Add `message_persist` to `PluginHookName`, event/result/context types, handler map entry | | `src/plugins/hooks.ts` | Add `runMessagePersist()` โ€” mirrors `runToolResultPersist()` exactly | | `src/agents/session-tool-result-guard-wrapper.ts` | Wire hook into `transformMessageForPersistence` callback with inline docs | ## Usage Example ```ts // Plugin: encrypt all messages before they hit disk export default function (api) { api.registerHook('message_persist', (event, ctx) => { // event.message โ€” the raw message object (any role) // event.role โ€” 'user' | 'assistant' | 'system' | 'toolResult' // ctx.agentId โ€” which agent's session this belongs to // ctx.sessionKey โ€” the session identifier const encrypted = encrypt(event.message); return { message: encrypted }; // returned message replaces original on disk }, { name: 'vault-encrypt', priority: 100 }); } ``` ## Backward Compatibility No breaking changes. Existing behavior is fully preserved when no `message_persist` hooks are registered โ€” the `transformMessageForPersistence` path still applies input provenance as before, with the hook transform applied afterward. ## Testing - TypeScript compiles cleanly - Linting passes (oxlint: 0 warnings, 0 errors) - Formatting passes (oxfmt) - E2e test to follow (mirroring existing `tool_result_persist` test) <!-- greptile_comment --> <h3>Greptile Summary</h3> Adds `message_persist` hook that intercepts all messages (user, assistant, system, toolResult) before persistence to disk, enabling plugins to implement at-rest encryption, PII redaction, audit logging, and metadata injection without forking core. - **Design**: Mirrors `tool_result_persist` exactly โ€” synchronous, priority-ordered execution, async handler rejection, zero overhead when no hooks registered - **Integration**: Wired into existing `transformMessageForPersistence` callback in session-tool-result-guard-wrapper.ts, running after input provenance tagging - **Testing**: Comprehensive e2e test coverage validates all message roles, transformation chaining, priority ordering, async rejection, and double-hook interaction for toolResult messages - **Documentation**: Inline comment at src/agents/session-tool-result-guard-wrapper.ts:70 warns plugin authors that toolResult messages pass through both `message_persist` and `tool_result_persist` hooks <h3>Confidence Score: 5/5</h3> - Safe to merge with no risk โ€” implementation follows established patterns exactly - Clean implementation that mirrors the existing `tool_result_persist` hook pattern exactly. Comprehensive e2e tests validate all edge cases including async rejection, priority ordering, and the double-hook interaction for toolResult messages. The inline documentation at the integration point is clear and warns plugin authors about the potential for double-processing. No breaking changes, zero overhead when hooks aren't registered, and fully backward compatible. - No files require special attention <sub>Last reviewed commit: cd7047d</sub> <!-- greptile_other_comments_section --> <!-- /greptile_comment -->

Most Similar PRs