From faba508fe0ae4dd4de453f4eccb62e452950880d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 01:31:39 +0000 Subject: [PATCH] feat: add internal hooks system --- .gitignore | 1 + CHANGELOG.md | 1 + docs/cli/hooks.md | 207 ++++- docs/internal-hooks.md | 776 ++++++++++++++++++ package.json | 2 +- scripts/copy-hook-metadata.ts | 53 ++ src/auto-reply/reply/commands-core.ts | 46 +- src/auto-reply/reply/commands-session.ts | 39 +- src/auto-reply/reply/commands-types.ts | 1 + src/auto-reply/reply/commands.test.ts | 21 +- .../reply/get-reply-inline-actions.ts | 8 +- src/auto-reply/reply/get-reply.ts | 2 + src/auto-reply/reply/session.ts | 6 + src/auto-reply/templating.ts | 5 + src/cli/hooks-cli.ts | 4 + src/cli/hooks-internal-cli.ts | 439 ++++++++++ src/commands/onboard-hooks.test.ts | 185 +++++ src/commands/onboard-hooks.ts | 86 ++ src/config/types.hooks.ts | 31 + src/config/zod-schema.hooks.ts | 26 + src/config/zod-schema.ts | 3 +- src/gateway/server-startup.ts | 14 + src/hooks/bundled-dir.ts | 40 + src/hooks/bundled/README.md | 186 +++++ src/hooks/bundled/command-logger/HOOK.md | 109 +++ src/hooks/bundled/command-logger/handler.ts | 64 ++ src/hooks/bundled/session-memory/HOOK.md | 76 ++ src/hooks/bundled/session-memory/handler.ts | 174 ++++ src/hooks/config.ts | 134 +++ src/hooks/frontmatter.ts | 152 ++++ src/hooks/hooks-status.ts | 225 +++++ src/hooks/internal-hooks.test.ts | 229 ++++++ src/hooks/internal-hooks.ts | 155 ++++ src/hooks/llm-slug-generator.ts | 80 ++ src/hooks/loader.test.ts | 271 ++++++ src/hooks/loader.ts | 152 ++++ src/hooks/types.ts | 64 ++ src/hooks/workspace.ts | 197 +++++ src/wizard/onboarding.ts | 5 + 39 files changed, 4241 insertions(+), 28 deletions(-) create mode 100644 docs/internal-hooks.md create mode 100644 scripts/copy-hook-metadata.ts create mode 100644 src/cli/hooks-internal-cli.ts create mode 100644 src/commands/onboard-hooks.test.ts create mode 100644 src/commands/onboard-hooks.ts create mode 100644 src/hooks/bundled-dir.ts create mode 100644 src/hooks/bundled/README.md create mode 100644 src/hooks/bundled/command-logger/HOOK.md create mode 100644 src/hooks/bundled/command-logger/handler.ts create mode 100644 src/hooks/bundled/session-memory/HOOK.md create mode 100644 src/hooks/bundled/session-memory/handler.ts create mode 100644 src/hooks/config.ts create mode 100644 src/hooks/frontmatter.ts create mode 100644 src/hooks/hooks-status.ts create mode 100644 src/hooks/internal-hooks.test.ts create mode 100644 src/hooks/internal-hooks.ts create mode 100644 src/hooks/llm-slug-generator.ts create mode 100644 src/hooks/loader.test.ts create mode 100644 src/hooks/loader.ts create mode 100644 src/hooks/types.ts create mode 100644 src/hooks/workspace.ts diff --git a/.gitignore b/.gitignore index 68f100069..88e45373f 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ apps/ios/*.mobileprovision .vscode/ IDENTITY.md USER.md +.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md index ade17991f..d86c3d9ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. - Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. - Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow. +- Hooks: add internal hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. ### Breaking - **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index e843ed25f..6824d1880 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -1,6 +1,7 @@ --- -summary: "CLI reference for `clawdbot hooks` (Gmail Pub/Sub + webhook helpers)" +summary: "CLI reference for `clawdbot hooks` (internal hooks + Gmail Pub/Sub + webhook helpers)" read_when: + - You want to manage internal agent hooks - You want to wire Gmail Pub/Sub events into Clawdbot hooks - You want to run the gog watch service and renew loop --- @@ -10,9 +11,212 @@ read_when: Webhook helpers and hook-based integrations. Related: +- Internal Hooks: [Internal Agent Hooks](/internal-hooks) - Webhooks: [Webhook](/automation/webhook) - Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub) +## Internal Hooks + +Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.). + +### List All Hooks + +```bash +clawdbot hooks internal list +``` + +List all discovered internal hooks from workspace, managed, and bundled directories. + +**Options:** +- `--eligible`: Show only eligible hooks (requirements met) +- `--json`: Output as JSON +- `-v, --verbose`: Show detailed information including missing requirements + +**Example output:** + +``` +Internal Hooks (2/2 ready) + +Ready: + 📝 command-logger ✓ - Log all command events to a centralized audit file + 💾 session-memory ✓ - Save session context to memory when /new command is issued +``` + +**Example (verbose):** + +```bash +clawdbot hooks internal list --verbose +``` + +Shows missing requirements for ineligible hooks. + +**Example (JSON):** + +```bash +clawdbot hooks internal list --json +``` + +Returns structured JSON for programmatic use. + +### Get Hook Information + +```bash +clawdbot hooks internal info +``` + +Show detailed information about a specific hook. + +**Arguments:** +- ``: Hook name (e.g., `session-memory`) + +**Options:** +- `--json`: Output as JSON + +**Example:** + +```bash +clawdbot hooks internal info session-memory +``` + +**Output:** + +``` +💾 session-memory ✓ Ready + +Save session context to memory when /new command is issued + +Details: + Source: clawdbot-bundled + Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md + Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts + Homepage: https://docs.clawd.bot/internal-hooks#session-memory + Events: command:new + +Requirements: + Config: ✓ workspace.dir +``` + +### Check Hooks Eligibility + +```bash +clawdbot hooks internal check +``` + +Show summary of hook eligibility status (how many are ready vs. not ready). + +**Options:** +- `--json`: Output as JSON + +**Example output:** + +``` +Internal Hooks Status + +Total hooks: 2 +Ready: 2 +Not ready: 0 +``` + +### Enable a Hook + +```bash +clawdbot hooks internal enable +``` + +Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`). + +**Arguments:** +- ``: Hook name (e.g., `session-memory`) + +**Example:** + +```bash +clawdbot hooks internal enable session-memory +``` + +**Output:** + +``` +✓ Enabled hook: 💾 session-memory +``` + +**What it does:** +- Checks if hook exists and is eligible +- Updates `hooks.internal.entries..enabled = true` in your config +- Saves config to disk + +**After enabling:** +- Restart the gateway so hooks reload (menu bar app restart on macOS, or restart your gateway process in dev). + +### Disable a Hook + +```bash +clawdbot hooks internal disable +``` + +Disable a specific hook by updating your config. + +**Arguments:** +- ``: Hook name (e.g., `command-logger`) + +**Example:** + +```bash +clawdbot hooks internal disable command-logger +``` + +**Output:** + +``` +⏸ Disabled hook: 📝 command-logger +``` + +**After disabling:** +- Restart the gateway so hooks reload + +## Bundled Hooks + +### session-memory + +Saves session context to memory when you issue `/new`. + +**Enable:** + +```bash +clawdbot hooks internal enable session-memory +``` + +**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md` + +**See:** [session-memory documentation](/internal-hooks#session-memory) + +### command-logger + +Logs all command events to a centralized audit file. + +**Enable:** + +```bash +clawdbot hooks internal enable command-logger +``` + +**Output:** `~/.clawdbot/logs/commands.log` + +**View logs:** + +```bash +# Recent commands +tail -n 20 ~/.clawdbot/logs/commands.log + +# Pretty-print +cat ~/.clawdbot/logs/commands.log | jq . + +# Filter by action +grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq . +``` + +**See:** [command-logger documentation](/internal-hooks#command-logger) + ## Gmail ```bash @@ -20,3 +224,4 @@ clawdbot hooks gmail setup --account you@example.com clawdbot hooks gmail run ``` +See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details. diff --git a/docs/internal-hooks.md b/docs/internal-hooks.md new file mode 100644 index 000000000..3d5279305 --- /dev/null +++ b/docs/internal-hooks.md @@ -0,0 +1,776 @@ +--- +summary: "Internal agent hooks: event-driven automation for commands and lifecycle events" +read_when: + - You want event-driven automation for /new, /reset, /stop, and agent lifecycle events + - You want to build, install, or debug internal hooks +--- +# Internal Agent Hooks + +Internal hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot. + +## Overview + +The internal hooks system allows you to: +- Save session context to memory when `/new` is issued +- Log all commands for auditing +- Trigger custom automations on agent lifecycle events +- Extend Clawdbot's behavior without modifying core code + +## Getting Started + +### Bundled Hooks + +Clawdbot ships with two bundled hooks that are automatically discovered: + +- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new` +- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log` + +List available hooks: + +```bash +clawdbot hooks internal list +``` + +Enable a hook: + +```bash +clawdbot hooks internal enable session-memory +``` + +Check hook status: + +```bash +clawdbot hooks internal check +``` + +Get detailed information: + +```bash +clawdbot hooks internal info session-memory +``` + +### Onboarding + +During onboarding (`clawdbot onboard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection. + +## Hook Discovery + +Hooks are automatically discovered from three directories (in order of precedence): + +1. **Workspace hooks**: `/hooks/` (per-agent, highest precedence) +2. **Managed hooks**: `~/.clawdbot/hooks/` (user-installed, shared across workspaces) +3. **Bundled hooks**: `/dist/hooks/bundled/` (shipped with Clawdbot) + +Each hook is a directory containing: + +``` +my-hook/ +├── HOOK.md # Metadata + documentation +└── handler.ts # Handler implementation +``` + +## Hook Structure + +### HOOK.md Format + +The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documentation: + +```markdown +--- +name: my-hook +description: "Short description of what this hook does" +homepage: https://docs.clawd.bot/internal-hooks#my-hook +metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}} +--- + +# My Hook + +Detailed documentation goes here... + +## What It Does + +- Listens for `/new` commands +- Performs some action +- Logs the result + +## Requirements + +- Node.js must be installed + +## Configuration + +No configuration needed. +``` + +### Metadata Fields + +The `metadata.clawdbot` object supports: + +- **`emoji`**: Display emoji for CLI (e.g., `"💾"`) +- **`events`**: Array of events to listen for (e.g., `["command:new", "command:reset"]`) +- **`export`**: Named export to use (defaults to `"default"`) +- **`homepage`**: Documentation URL +- **`requires`**: Optional requirements + - **`bins`**: Required binaries on PATH (e.g., `["git", "node"]`) + - **`anyBins`**: At least one of these binaries must be present + - **`env`**: Required environment variables + - **`config`**: Required config paths (e.g., `["workspace.dir"]`) + - **`os`**: Required platforms (e.g., `["darwin", "linux"]`) +- **`always`**: Bypass eligibility checks (boolean) +- **`install`**: Installation methods (for bundled hooks: `[{"id":"bundled","kind":"bundled"}]`) + +### Handler Implementation + +The `handler.ts` file exports an `InternalHookHandler` function: + +```typescript +import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js'; + +const myHandler: InternalHookHandler = async (event) => { + // Only trigger on 'new' command + if (event.type !== 'command' || event.action !== 'new') { + return; + } + + console.log(`[my-hook] New command triggered`); + console.log(` Session: ${event.sessionKey}`); + console.log(` Timestamp: ${event.timestamp.toISOString()}`); + + // Your custom logic here + + // Optionally send message to user + event.messages.push('✨ My hook executed!'); +}; + +export default myHandler; +``` + +#### Event Context + +Each event includes: + +```typescript +{ + type: 'command' | 'session' | 'agent', + action: string, // e.g., 'new', 'reset', 'stop' + sessionKey: string, // Session identifier + timestamp: Date, // When the event occurred + messages: string[], // Push messages here to send to user + context: { + sessionEntry?: SessionEntry, + sessionId?: string, + sessionFile?: string, + commandSource?: string, // e.g., 'whatsapp', 'telegram' + senderId?: string, + cfg?: ClawdbotConfig + } +} +``` + +## Event Types + +### Command Events + +Triggered when agent commands are issued: + +- **`command`**: All command events (general listener) +- **`command:new`**: When `/new` command is issued +- **`command:reset`**: When `/reset` command is issued +- **`command:stop`**: When `/stop` command is issued + +### Future Events + +Planned event types: + +- **`session:start`**: When a new session begins +- **`session:end`**: When a session ends +- **`agent:error`**: When an agent encounters an error +- **`message:sent`**: When a message is sent +- **`message:received`**: When a message is received + +## Creating Custom Hooks + +### 1. Choose Location + +- **Workspace hooks** (`/hooks/`): Per-agent, highest precedence +- **Managed hooks** (`~/.clawdbot/hooks/`): Shared across workspaces + +### 2. Create Directory Structure + +```bash +mkdir -p ~/.clawdbot/hooks/my-hook +cd ~/.clawdbot/hooks/my-hook +``` + +### 3. Create HOOK.md + +```markdown +--- +name: my-hook +description: "Does something useful" +metadata: {"clawdbot":{"emoji":"🎯","events":["command:new"]}} +--- + +# My Custom Hook + +This hook does something useful when you issue `/new`. +``` + +### 4. Create handler.ts + +```typescript +import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js'; + +const handler: InternalHookHandler = async (event) => { + if (event.type !== 'command' || event.action !== 'new') { + return; + } + + console.log('[my-hook] Running!'); + // Your logic here +}; + +export default handler; +``` + +### 5. Enable and Test + +```bash +# Verify hook is discovered +clawdbot hooks internal list + +# Enable it +clawdbot hooks internal enable my-hook + +# Restart your gateway process (menu bar app restart on macOS, or restart your dev process) + +# Trigger the event +# Send /new via your messaging channel +``` + +## Configuration + +### New Config Format (Recommended) + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "entries": { + "session-memory": { "enabled": true }, + "command-logger": { "enabled": false } + } + } + } +} +``` + +### Per-Hook Configuration + +Hooks can have custom configuration: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "entries": { + "my-hook": { + "enabled": true, + "env": { + "MY_CUSTOM_VAR": "value" + } + } + } + } + } +} +``` + +### Extra Directories + +Load hooks from additional directories: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "load": { + "extraDirs": ["/path/to/more/hooks"] + } + } + } +} +``` + +### Legacy Config Format (Still Supported) + +The old config format still works for backwards compatibility: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "handlers": [ + { + "event": "command:new", + "module": "./hooks/handlers/my-handler.ts", + "export": "default" + } + ] + } + } +} +``` + +**Migration**: Use the new discovery-based system for new hooks. Legacy handlers are loaded after directory-based hooks. + +## CLI Commands + +### List Hooks + +```bash +# List all hooks +clawdbot hooks internal list + +# Show only eligible hooks +clawdbot hooks internal list --eligible + +# Verbose output (show missing requirements) +clawdbot hooks internal list --verbose + +# JSON output +clawdbot hooks internal list --json +``` + +### Hook Information + +```bash +# Show detailed info about a hook +clawdbot hooks internal info session-memory + +# JSON output +clawdbot hooks internal info session-memory --json +``` + +### Check Eligibility + +```bash +# Show eligibility summary +clawdbot hooks internal check + +# JSON output +clawdbot hooks internal check --json +``` + +### Enable/Disable + +```bash +# Enable a hook +clawdbot hooks internal enable session-memory + +# Disable a hook +clawdbot hooks internal disable command-logger +``` + +## Bundled Hooks + +### session-memory + +Saves session context to memory when you issue `/new`. + +**Events**: `command:new` + +**Requirements**: `workspace.dir` must be configured + +**Output**: `/memory/YYYY-MM-DD-slug.md` (defaults to `~/clawd`) + +**What it does**: +1. Uses the pre-reset session entry to locate the correct transcript +2. Extracts the last 15 lines of conversation +3. Uses LLM to generate a descriptive filename slug +4. Saves session metadata to a dated memory file + +**Example output**: + +```markdown +# Session: 2026-01-16 14:30:00 UTC + +- **Session Key**: agent:main:main +- **Session ID**: abc123def456 +- **Source**: telegram +``` + +**Filename examples**: +- `2026-01-16-vendor-pitch.md` +- `2026-01-16-api-design.md` +- `2026-01-16-1430.md` (fallback timestamp if slug generation fails) + +**Enable**: + +```bash +clawdbot hooks internal enable session-memory +``` + +### command-logger + +Logs all command events to a centralized audit file. + +**Events**: `command` + +**Requirements**: None + +**Output**: `~/.clawdbot/logs/commands.log` + +**What it does**: +1. Captures event details (command action, timestamp, session key, sender ID, source) +2. Appends to log file in JSONL format +3. Runs silently in the background + +**Example log entries**: + +```jsonl +{"timestamp":"2026-01-16T14:30:00.000Z","action":"new","sessionKey":"agent:main:main","senderId":"+1234567890","source":"telegram"} +{"timestamp":"2026-01-16T15:45:22.000Z","action":"stop","sessionKey":"agent:main:main","senderId":"user@example.com","source":"whatsapp"} +``` + +**View logs**: + +```bash +# View recent commands +tail -n 20 ~/.clawdbot/logs/commands.log + +# Pretty-print with jq +cat ~/.clawdbot/logs/commands.log | jq . + +# Filter by action +grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq . +``` + +**Enable**: + +```bash +clawdbot hooks internal enable command-logger +``` + +## Best Practices + +### Keep Handlers Fast + +Hooks run during command processing. Keep them lightweight: + +```typescript +// ✓ Good - async work, returns immediately +const handler: InternalHookHandler = async (event) => { + void processInBackground(event); // Fire and forget +}; + +// ✗ Bad - blocks command processing +const handler: InternalHookHandler = async (event) => { + await slowDatabaseQuery(event); + await evenSlowerAPICall(event); +}; +``` + +### Handle Errors Gracefully + +Always wrap risky operations: + +```typescript +const handler: InternalHookHandler = async (event) => { + try { + await riskyOperation(event); + } catch (err) { + console.error('[my-handler] Failed:', err instanceof Error ? err.message : String(err)); + // Don't throw - let other handlers run + } +}; +``` + +### Filter Events Early + +Return early if the event isn't relevant: + +```typescript +const handler: InternalHookHandler = async (event) => { + // Only handle 'new' commands + if (event.type !== 'command' || event.action !== 'new') { + return; + } + + // Your logic here +}; +``` + +### Use Specific Event Keys + +Specify exact events in metadata when possible: + +```yaml +metadata: {"clawdbot":{"events":["command:new"]}} # Specific +``` + +Rather than: + +```yaml +metadata: {"clawdbot":{"events":["command"]}} # General - more overhead +``` + +## Debugging + +### Enable Hook Logging + +The gateway logs hook loading at startup: + +``` +Registered hook: session-memory -> command:new +Registered hook: command-logger -> command +``` + +### Check Discovery + +List all discovered hooks: + +```bash +clawdbot hooks internal list --verbose +``` + +### Check Registration + +In your handler, log when it's called: + +```typescript +const handler: InternalHookHandler = async (event) => { + console.log('[my-handler] Triggered:', event.type, event.action); + // Your logic +}; +``` + +### Verify Eligibility + +Check why a hook isn't eligible: + +```bash +clawdbot hooks internal info my-hook +``` + +Look for missing requirements in the output. + +## Testing + +### Gateway Logs + +Monitor gateway logs to see hook execution: + +```bash +# macOS +./scripts/clawlog.sh -f + +# Other platforms +tail -f ~/.clawdbot/gateway.log +``` + +### Test Hooks Directly + +Test your handlers in isolation: + +```typescript +import { test } from 'vitest'; +import { createInternalHookEvent } from './src/hooks/internal-hooks.js'; +import myHandler from './hooks/my-hook/handler.js'; + +test('my handler works', async () => { + const event = createInternalHookEvent('command', 'new', 'test-session', { + foo: 'bar' + }); + + await myHandler(event); + + // Assert side effects +}); +``` + +## Architecture + +### Core Components + +- **`src/hooks/types.ts`**: Type definitions +- **`src/hooks/workspace.ts`**: Directory scanning and loading +- **`src/hooks/frontmatter.ts`**: HOOK.md metadata parsing +- **`src/hooks/config.ts`**: Eligibility checking +- **`src/hooks/hooks-status.ts`**: Status reporting +- **`src/hooks/loader.ts`**: Dynamic module loader +- **`src/cli/hooks-internal-cli.ts`**: CLI commands +- **`src/gateway/server-startup.ts`**: Loads hooks at gateway start +- **`src/auto-reply/reply/commands-core.ts`**: Triggers command events + +### Discovery Flow + +``` +Gateway startup + ↓ +Scan directories (workspace → managed → bundled) + ↓ +Parse HOOK.md files + ↓ +Check eligibility (bins, env, config, os) + ↓ +Load handlers from eligible hooks + ↓ +Register handlers for events +``` + +### Event Flow + +``` +User sends /new + ↓ +Command validation + ↓ +Create hook event + ↓ +Trigger hook (all registered handlers) + ↓ +Command processing continues + ↓ +Session reset +``` + +## Troubleshooting + +### Hook Not Discovered + +1. Check directory structure: + ```bash + ls -la ~/.clawdbot/hooks/my-hook/ + # Should show: HOOK.md, handler.ts + ``` + +2. Verify HOOK.md format: + ```bash + cat ~/.clawdbot/hooks/my-hook/HOOK.md + # Should have YAML frontmatter with name and metadata + ``` + +3. List all discovered hooks: + ```bash + clawdbot hooks internal list + ``` + +### Hook Not Eligible + +Check requirements: + +```bash +clawdbot hooks internal info my-hook +``` + +Look for missing: +- Binaries (check PATH) +- Environment variables +- Config values +- OS compatibility + +### Hook Not Executing + +1. Verify hook is enabled: + ```bash + clawdbot hooks internal list + # Should show ✓ next to enabled hooks + ``` + +2. Restart your gateway process so hooks reload. + +3. Check gateway logs for errors: + ```bash + ./scripts/clawlog.sh | grep hook + ``` + +### Handler Errors + +Check for TypeScript/import errors: + +```bash +# Test import directly +node -e "import('./path/to/handler.ts').then(console.log)" +``` + +## Migration Guide + +### From Legacy Config to Discovery + +**Before**: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "handlers": [ + { + "event": "command:new", + "module": "./hooks/handlers/my-handler.ts" + } + ] + } + } +} +``` + +**After**: + +1. Create hook directory: + ```bash + mkdir -p ~/.clawdbot/hooks/my-hook + mv ./hooks/handlers/my-handler.ts ~/.clawdbot/hooks/my-hook/handler.ts + ``` + +2. Create HOOK.md: + ```markdown + --- + name: my-hook + description: "My custom hook" + metadata: {"clawdbot":{"emoji":"🎯","events":["command:new"]}} + --- + + # My Hook + + Does something useful. + ``` + +3. Update config: + ```json + { + "hooks": { + "internal": { + "enabled": true, + "entries": { + "my-hook": { "enabled": true } + } + } + } + } + ``` + +4. Verify and restart your gateway process: + ```bash + clawdbot hooks internal list + # Should show: 🎯 my-hook ✓ + ``` + +**Benefits of migration**: +- Automatic discovery +- CLI management +- Eligibility checking +- Better documentation +- Consistent structure + +## See Also + +- [CLI Reference: hooks internal](/cli/hooks) +- [Bundled Hooks README](https://github.com/clawdbot/clawdbot/tree/main/src/hooks/bundled) +- [Webhook Hooks](/automation/webhook) +- [Configuration](/gateway/configuration#hooks) diff --git a/package.json b/package.json index 331438b30..c3f7cf7bc 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list", "docs:dev": "cd docs && mint dev", "docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links", - "build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts", + "build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts", "plugins:sync": "tsx scripts/sync-plugin-versions.ts", "release:check": "tsx scripts/release-check.ts", "ui:install": "node scripts/ui.js install", diff --git a/scripts/copy-hook-metadata.ts b/scripts/copy-hook-metadata.ts new file mode 100644 index 000000000..66d696228 --- /dev/null +++ b/scripts/copy-hook-metadata.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env tsx +/** + * Copy HOOK.md files from src/hooks/bundled to dist/hooks/bundled + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); + +const srcBundled = path.join(projectRoot, 'src', 'hooks', 'bundled'); +const distBundled = path.join(projectRoot, 'dist', 'hooks', 'bundled'); + +function copyHookMetadata() { + if (!fs.existsSync(srcBundled)) { + console.warn('[copy-hook-metadata] Source directory not found:', srcBundled); + return; + } + + if (!fs.existsSync(distBundled)) { + fs.mkdirSync(distBundled, { recursive: true }); + } + + const entries = fs.readdirSync(srcBundled, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const hookName = entry.name; + const srcHookDir = path.join(srcBundled, hookName); + const distHookDir = path.join(distBundled, hookName); + const srcHookMd = path.join(srcHookDir, 'HOOK.md'); + const distHookMd = path.join(distHookDir, 'HOOK.md'); + + if (!fs.existsSync(srcHookMd)) { + console.warn(`[copy-hook-metadata] No HOOK.md found for ${hookName}`); + continue; + } + + if (!fs.existsSync(distHookDir)) { + fs.mkdirSync(distHookDir, { recursive: true }); + } + + fs.copyFileSync(srcHookMd, distHookMd); + console.log(`[copy-hook-metadata] Copied ${hookName}/HOOK.md`); + } + + console.log('[copy-hook-metadata] Done'); +} + +copyHookMetadata(); diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index abaf948b3..29715b2bb 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -1,6 +1,8 @@ import { logVerbose } from "../../globals.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { shouldHandleTextCommands } from "../commands-registry.js"; +import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; +import { routeReply } from "./route-reply.js"; import { handleBashCommand } from "./commands-bash.js"; import { handleCompactCommand } from "./commands-compact.js"; import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; @@ -42,9 +44,8 @@ const HANDLERS: CommandHandler[] = [ ]; export async function handleCommands(params: HandleCommandsParams): Promise { - const resetRequested = - params.command.commandBodyNormalized === "/reset" || - params.command.commandBodyNormalized === "/new"; + const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/); + const resetRequested = Boolean(resetMatch); if (resetRequested && !params.command.isAuthorizedSender) { logVerbose( `Ignoring /reset from unauthorized sender: ${params.command.senderId || ""}`, @@ -52,6 +53,45 @@ export async function handleCommands(params: HandleCommandsParams): Promise 0) { + // Use OriginatingChannel/To if available, otherwise fall back to command channel/from + const channel = params.ctx.OriginatingChannel || (params.command.channel as any); + // For replies, use 'from' (the sender) not 'to' (which might be the bot itself) + const to = params.ctx.OriginatingTo || params.command.from || params.command.to; + + if (channel && to) { + const hookReply = { text: hookEvent.messages.join('\n\n') }; + await routeReply({ + payload: hookReply, + channel: channel, + to: to, + sessionKey: params.sessionKey, + accountId: params.ctx.AccountId, + threadId: params.ctx.MessageThreadId, + cfg: params.cfg, + }); + } + } + } + const allowTextCommands = shouldHandleTextCommands({ cfg: params.cfg, surface: params.command.surface, diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 911c6a6a1..3a4e8e3f5 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -2,6 +2,7 @@ import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; import type { SessionEntry } from "../../config/sessions.js"; import { updateSessionStore } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; +import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { parseActivationCommand } from "../group-activation.js"; @@ -12,8 +13,8 @@ import { setAbortMemory, stopSubagentsForRequester, } from "./abort.js"; -import { clearSessionQueues } from "./queue.js"; import type { CommandHandler } from "./commands-types.js"; +import { clearSessionQueues } from "./queue.js"; function resolveSessionEntryForKey( store: Record | undefined, @@ -213,14 +214,27 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand } else if (params.command.abortKey) { setAbortMemory(params.command.abortKey, true); } + + // Trigger internal hook for stop command + const hookEvent = createInternalHookEvent( + 'command', + 'stop', + abortTarget.key ?? params.sessionKey ?? '', + { + sessionEntry: abortTarget.entry ?? params.sessionEntry, + sessionId: abortTarget.sessionId, + commandSource: params.command.surface, + senderId: params.command.senderId, + } + ); + await triggerInternalHook(hookEvent); + const { stopped } = stopSubagentsForRequester({ cfg: params.cfg, requesterSessionKey: abortTarget.key ?? params.sessionKey, }); - return { - shouldContinue: false, - reply: { text: formatAbortReplyText(stopped) }, - }; + + return { shouldContinue: false, reply: { text: formatAbortReplyText(stopped) } }; }; export const handleAbortTrigger: CommandHandler = async (params, allowTextCommands) => { @@ -235,12 +249,6 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman if (abortTarget.sessionId) { abortEmbeddedPiRun(abortTarget.sessionId); } - const cleared = clearSessionQueues([abortTarget.key, abortTarget.sessionId]); - if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { - logVerbose( - `stop-trigger: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, - ); - } if (abortTarget.entry && params.sessionStore && abortTarget.key) { abortTarget.entry.abortedLastRun = true; abortTarget.entry.updatedAt = Date.now(); @@ -253,12 +261,5 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman } else if (params.command.abortKey) { setAbortMemory(params.command.abortKey, true); } - const { stopped } = stopSubagentsForRequester({ - cfg: params.cfg, - requesterSessionKey: abortTarget.key ?? params.sessionKey, - }); - return { - shouldContinue: false, - reply: { text: formatAbortReplyText(stopped) }, - }; + return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } }; }; diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts index 8fb0b0133..a8e2c4c10 100644 --- a/src/auto-reply/reply/commands-types.ts +++ b/src/auto-reply/reply/commands-types.ts @@ -33,6 +33,7 @@ export type HandleCommandsParams = { failures: Array<{ gate: string; key: string }>; }; sessionEntry?: SessionEntry; + previousSessionEntry?: SessionEntry; sessionStore?: Record; sessionKey: string; storePath?: string; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index eb09b61cd..3be65afee 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../../config/config.js"; +import * as internalHooks from "../../hooks/internal-hooks.js"; import type { MsgContext } from "../templating.js"; import { resetBashChatCommandForTests } from "./bash-command.js"; import { buildCommandContext, handleCommands } from "./commands.js"; @@ -143,6 +144,24 @@ describe("handleCommands identity", () => { }); }); +describe("handleCommands internal hooks", () => { + it("triggers hooks for /new with arguments", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as ClawdbotConfig; + const params = buildParams("/new take notes", cfg); + const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); + + await handleCommands(params); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ type: "command", action: "new" }), + ); + spy.mockRestore(); + }); +}); + describe("handleCommands context", () => { it("returns context help for /context", async () => { const cfg = { diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index c1e648198..31bead546 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -29,6 +29,7 @@ export async function handleInlineActions(params: { cfg: ClawdbotConfig; agentId: string; sessionEntry?: SessionEntry; + previousSessionEntry?: SessionEntry; sessionStore?: Record; sessionKey: string; storePath?: string; @@ -66,8 +67,9 @@ export async function handleInlineActions(params: { sessionCtx, cfg, agentId, - sessionEntry, - sessionStore, + sessionEntry, + previousSessionEntry, + sessionStore, sessionKey, storePath, sessionScope, @@ -203,6 +205,7 @@ export async function handleInlineActions(params: { failures: elevatedFailures, }, sessionEntry, + previousSessionEntry, sessionStore, sessionKey, storePath, @@ -265,6 +268,7 @@ export async function handleInlineActions(params: { failures: elevatedFailures, }, sessionEntry, + previousSessionEntry, sessionStore, sessionKey, storePath, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 26d945675..a5201a74b 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -100,6 +100,7 @@ export async function getReplyFromConfig( let { sessionCtx, sessionEntry, + previousSessionEntry, sessionStore, sessionKey, sessionId, @@ -122,6 +123,7 @@ export async function getReplyFromConfig( agentCfg, sessionCtx, sessionEntry, + previousSessionEntry, sessionStore, sessionKey, storePath, diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 49e9d2208..0a4b636b3 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -30,6 +30,7 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; export type SessionInitResult = { sessionCtx: TemplateContext; sessionEntry: SessionEntry; + previousSessionEntry?: SessionEntry; sessionStore: Record; sessionKey: string; sessionId: string; @@ -115,6 +116,7 @@ export async function initSessionState(params: { let bodyStripped: string | undefined; let systemSent = false; let abortedLastRun = false; + let resetTriggered = false; let persistedThinking: string | undefined; let persistedVerbose: string | undefined; @@ -149,12 +151,14 @@ export async function initSessionState(params: { if (trimmedBody === trigger || strippedForReset === trigger) { isNewSession = true; bodyStripped = ""; + resetTriggered = true; break; } const triggerPrefix = `${trigger} `; if (trimmedBody.startsWith(triggerPrefix) || strippedForReset.startsWith(triggerPrefix)) { isNewSession = true; bodyStripped = strippedForReset.slice(trigger.length).trimStart(); + resetTriggered = true; break; } } @@ -168,6 +172,7 @@ export async function initSessionState(params: { } } const entry = sessionStore[sessionKey]; + const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined; const idleMs = idleMinutes * 60_000; const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; @@ -308,6 +313,7 @@ export async function initSessionState(params: { return { sessionCtx, sessionEntry, + previousSessionEntry, sessionStore, sessionKey, sessionId: sessionId ?? crypto.randomUUID(), diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 721a10eb2..20c2ef6b8 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -75,6 +75,11 @@ export type MsgContext = { * The chat/channel/user ID where the reply should be sent. */ OriginatingTo?: string; + /** + * Messages from internal hooks to be included in the response. + * Used for hook confirmation messages like "Session context saved to memory". + */ + HookMessages?: string[]; }; export type TemplateContext = MsgContext & { diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 912c47359..1f55b1698 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -20,6 +20,7 @@ import { import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { registerInternalHooksSubcommands } from "./hooks-internal-cli.js"; export function registerHooksCli(program: Command) { const hooks = program @@ -31,6 +32,9 @@ export function registerHooksCli(program: Command) { `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/hooks", "docs.clawd.bot/cli/hooks")}\n`, ); + // Register internal hooks management subcommands + registerInternalHooksSubcommands(hooks); + const gmail = hooks.command("gmail").description("Gmail Pub/Sub hooks (via gogcli)"); gmail diff --git a/src/cli/hooks-internal-cli.ts b/src/cli/hooks-internal-cli.ts new file mode 100644 index 000000000..774aa6eab --- /dev/null +++ b/src/cli/hooks-internal-cli.ts @@ -0,0 +1,439 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + buildWorkspaceHookStatus, + type HookStatusEntry, + type HookStatusReport, +} from "../hooks/hooks-status.js"; +import { loadConfig, writeConfigFile } from "../config/io.js"; + +export type HooksListOptions = { + json?: boolean; + eligible?: boolean; + verbose?: boolean; +}; + +export type HookInfoOptions = { + json?: boolean; +}; + +export type HooksCheckOptions = { + json?: boolean; +}; + +/** + * Format a single hook for display in the list + */ +function formatHookLine(hook: HookStatusEntry, verbose = false): string { + const emoji = hook.emoji ?? "🔗"; + const status = hook.eligible + ? chalk.green("✓") + : hook.disabled + ? chalk.yellow("disabled") + : chalk.red("missing reqs"); + + const name = hook.eligible ? chalk.white(hook.name) : chalk.gray(hook.name); + + const desc = chalk.gray( + hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description, + ); + + if (verbose) { + const missing: string[] = []; + if (hook.missing.bins.length > 0) { + missing.push(`bins: ${hook.missing.bins.join(", ")}`); + } + if (hook.missing.anyBins.length > 0) { + missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`); + } + if (hook.missing.env.length > 0) { + missing.push(`env: ${hook.missing.env.join(", ")}`); + } + if (hook.missing.config.length > 0) { + missing.push(`config: ${hook.missing.config.join(", ")}`); + } + if (hook.missing.os.length > 0) { + missing.push(`os: ${hook.missing.os.join(", ")}`); + } + const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : ""; + return `${emoji} ${name} ${status}${missingStr}\n ${desc}`; + } + + return `${emoji} ${name} ${status} - ${desc}`; +} + +/** + * Format the hooks list output + */ +export function formatHooksList(report: HookStatusReport, opts: HooksListOptions): string { + const hooks = opts.eligible ? report.hooks.filter((h) => h.eligible) : report.hooks; + + if (opts.json) { + const jsonReport = { + workspaceDir: report.workspaceDir, + managedHooksDir: report.managedHooksDir, + hooks: hooks.map((h) => ({ + name: h.name, + description: h.description, + emoji: h.emoji, + eligible: h.eligible, + disabled: h.disabled, + source: h.source, + events: h.events, + homepage: h.homepage, + missing: h.missing, + })), + }; + return JSON.stringify(jsonReport, null, 2); + } + + if (hooks.length === 0) { + const message = opts.eligible + ? "No eligible hooks found. Run `clawdbot hooks list` to see all hooks." + : "No hooks found."; + return message; + } + + const eligible = hooks.filter((h) => h.eligible); + const notEligible = hooks.filter((h) => !h.eligible); + + const lines: string[] = []; + lines.push( + chalk.bold.cyan("Internal Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`), + ); + lines.push(""); + + if (eligible.length > 0) { + lines.push(chalk.bold.green("Ready:")); + for (const hook of eligible) { + lines.push(` ${formatHookLine(hook, opts.verbose)}`); + } + } + + if (notEligible.length > 0 && !opts.eligible) { + if (eligible.length > 0) lines.push(""); + lines.push(chalk.bold.yellow("Not ready:")); + for (const hook of notEligible) { + lines.push(` ${formatHookLine(hook, opts.verbose)}`); + } + } + + return lines.join("\n"); +} + +/** + * Format detailed info for a single hook + */ +export function formatHookInfo( + report: HookStatusReport, + hookName: string, + opts: HookInfoOptions, +): string { + const hook = report.hooks.find((h) => h.name === hookName || h.hookKey === hookName); + + if (!hook) { + if (opts.json) { + return JSON.stringify({ error: "not found", hook: hookName }, null, 2); + } + return `Hook "${hookName}" not found. Run \`clawdbot hooks list\` to see available hooks.`; + } + + if (opts.json) { + return JSON.stringify(hook, null, 2); + } + + const lines: string[] = []; + const emoji = hook.emoji ?? "🔗"; + const status = hook.eligible + ? chalk.green("✓ Ready") + : hook.disabled + ? chalk.yellow("⏸ Disabled") + : chalk.red("✗ Missing requirements"); + + lines.push(`${emoji} ${chalk.bold.cyan(hook.name)} ${status}`); + lines.push(""); + lines.push(chalk.white(hook.description)); + lines.push(""); + + // Details + lines.push(chalk.bold("Details:")); + lines.push(` Source: ${hook.source}`); + lines.push(` Path: ${chalk.gray(hook.filePath)}`); + lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`); + if (hook.homepage) { + lines.push(` Homepage: ${chalk.blue(hook.homepage)}`); + } + if (hook.events.length > 0) { + lines.push(` Events: ${hook.events.join(", ")}`); + } + + // Requirements + const hasRequirements = + hook.requirements.bins.length > 0 || + hook.requirements.anyBins.length > 0 || + hook.requirements.env.length > 0 || + hook.requirements.config.length > 0 || + hook.requirements.os.length > 0; + + if (hasRequirements) { + lines.push(""); + lines.push(chalk.bold("Requirements:")); + if (hook.requirements.bins.length > 0) { + const binsStatus = hook.requirements.bins.map((bin) => { + const missing = hook.missing.bins.includes(bin); + return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`); + }); + lines.push(` Binaries: ${binsStatus.join(", ")}`); + } + if (hook.requirements.anyBins.length > 0) { + const anyBinsStatus = + hook.missing.anyBins.length > 0 + ? chalk.red(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`) + : chalk.green(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`); + lines.push(` Any binary: ${anyBinsStatus}`); + } + if (hook.requirements.env.length > 0) { + const envStatus = hook.requirements.env.map((env) => { + const missing = hook.missing.env.includes(env); + return missing ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`); + }); + lines.push(` Environment: ${envStatus.join(", ")}`); + } + if (hook.requirements.config.length > 0) { + const configStatus = hook.configChecks.map((check) => { + return check.satisfied + ? chalk.green(`✓ ${check.path}`) + : chalk.red(`✗ ${check.path}`); + }); + lines.push(` Config: ${configStatus.join(", ")}`); + } + if (hook.requirements.os.length > 0) { + const osStatus = + hook.missing.os.length > 0 + ? chalk.red(`✗ (${hook.requirements.os.join(", ")})`) + : chalk.green(`✓ (${hook.requirements.os.join(", ")})`); + lines.push(` OS: ${osStatus}`); + } + } + + return lines.join("\n"); +} + +/** + * Format check output + */ +export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptions): string { + if (opts.json) { + const eligible = report.hooks.filter((h) => h.eligible); + const notEligible = report.hooks.filter((h) => !h.eligible); + return JSON.stringify( + { + total: report.hooks.length, + eligible: eligible.length, + notEligible: notEligible.length, + hooks: { + eligible: eligible.map((h) => h.name), + notEligible: notEligible.map((h) => ({ + name: h.name, + missing: h.missing, + })), + }, + }, + null, + 2, + ); + } + + const eligible = report.hooks.filter((h) => h.eligible); + const notEligible = report.hooks.filter((h) => !h.eligible); + + const lines: string[] = []; + lines.push(chalk.bold.cyan("Internal Hooks Status")); + lines.push(""); + lines.push(`Total hooks: ${report.hooks.length}`); + lines.push(chalk.green(`Ready: ${eligible.length}`)); + lines.push(chalk.yellow(`Not ready: ${notEligible.length}`)); + + if (notEligible.length > 0) { + lines.push(""); + lines.push(chalk.bold.yellow("Hooks not ready:")); + for (const hook of notEligible) { + const reasons = []; + if (hook.disabled) reasons.push("disabled"); + if (hook.missing.bins.length > 0) reasons.push(`bins: ${hook.missing.bins.join(", ")}`); + if (hook.missing.anyBins.length > 0) + reasons.push(`anyBins: ${hook.missing.anyBins.join(", ")}`); + if (hook.missing.env.length > 0) reasons.push(`env: ${hook.missing.env.join(", ")}`); + if (hook.missing.config.length > 0) + reasons.push(`config: ${hook.missing.config.join(", ")}`); + if (hook.missing.os.length > 0) reasons.push(`os: ${hook.missing.os.join(", ")}`); + lines.push(` ${hook.emoji ?? "🔗"} ${hook.name} - ${reasons.join("; ")}`); + } + } + + return lines.join("\n"); +} + +export async function enableHook(hookName: string): Promise { + const config = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const report = buildWorkspaceHookStatus(workspaceDir, { config }); + const hook = report.hooks.find((h) => h.name === hookName); + + if (!hook) { + throw new Error(`Hook "${hookName}" not found`); + } + + if (!hook.eligible) { + throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`); + } + + // Update config + const entries = { ...config.hooks?.internal?.entries }; + entries[hookName] = { ...entries[hookName], enabled: true }; + + const nextConfig = { + ...config, + hooks: { + ...config.hooks, + internal: { + ...config.hooks?.internal, + enabled: true, + entries, + }, + }, + }; + + await writeConfigFile(nextConfig); + console.log(`${chalk.green("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${hookName}`); +} + +export async function disableHook(hookName: string): Promise { + const config = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const report = buildWorkspaceHookStatus(workspaceDir, { config }); + const hook = report.hooks.find((h) => h.name === hookName); + + if (!hook) { + throw new Error(`Hook "${hookName}" not found`); + } + + // Update config + const entries = { ...config.hooks?.internal?.entries }; + entries[hookName] = { ...entries[hookName], enabled: false }; + + const nextConfig = { + ...config, + hooks: { + ...config.hooks, + internal: { + ...config.hooks?.internal, + entries, + }, + }, + }; + + await writeConfigFile(nextConfig); + console.log(`${chalk.yellow("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${hookName}`); +} + +export function registerInternalHooksSubcommands(hooksCommand: Command): void { + // Add "internal" subcommand to existing "hooks" command + const internal = hooksCommand + .command("internal") + .description("Manage internal agent hooks") + .alias("int"); + + // list command + internal + .command("list") + .description("List all internal hooks") + .option("--eligible", "Show only eligible hooks", false) + .option("--json", "Output as JSON", false) + .option("-v, --verbose", "Show more details including missing requirements", false) + .action(async (opts) => { + try { + const config = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const report = buildWorkspaceHookStatus(workspaceDir, { config }); + console.log(formatHooksList(report, opts)); + } catch (err) { + console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + // info command + internal + .command("info ") + .description("Show detailed information about a hook") + .option("--json", "Output as JSON", false) + .action(async (name, opts) => { + try { + const config = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const report = buildWorkspaceHookStatus(workspaceDir, { config }); + console.log(formatHookInfo(report, name, opts)); + } catch (err) { + console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + // check command + internal + .command("check") + .description("Check hooks eligibility status") + .option("--json", "Output as JSON", false) + .action(async (opts) => { + try { + const config = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const report = buildWorkspaceHookStatus(workspaceDir, { config }); + console.log(formatHooksCheck(report, opts)); + } catch (err) { + console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + // enable command + internal + .command("enable ") + .description("Enable a hook") + .action(async (name) => { + try { + await enableHook(name); + } catch (err) { + console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + // disable command + internal + .command("disable ") + .description("Disable a hook") + .action(async (name) => { + try { + await disableHook(name); + } catch (err) { + console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + // Default action (no subcommand) - show list + internal.action(async () => { + try { + const config = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const report = buildWorkspaceHookStatus(workspaceDir, { config }); + console.log(formatHooksList(report, {})); + } catch (err) { + console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); +} diff --git a/src/commands/onboard-hooks.test.ts b/src/commands/onboard-hooks.test.ts new file mode 100644 index 000000000..c8cb8cd1b --- /dev/null +++ b/src/commands/onboard-hooks.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { setupInternalHooks } from './onboard-hooks.js'; +import type { ClawdbotConfig } from '../config/config.js'; +import type { RuntimeEnv } from '../runtime.js'; +import type { WizardPrompter } from '../wizard/prompts.js'; +import type { HookStatusReport } from '../hooks/hooks-status.js'; + +// Mock hook discovery modules +vi.mock('../hooks/hooks-status.js', () => ({ + buildWorkspaceHookStatus: vi.fn(), +})); + +vi.mock('../agents/agent-scope.js', () => ({ + resolveAgentWorkspaceDir: vi.fn().mockReturnValue('/mock/workspace'), + resolveDefaultAgentId: vi.fn().mockReturnValue('main'), +})); + +describe('onboard-hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createMockPrompter = (multiselectValue: string[]): WizardPrompter => ({ + confirm: vi.fn().mockResolvedValue(true), + note: vi.fn().mockResolvedValue(undefined), + intro: vi.fn().mockResolvedValue(undefined), + outro: vi.fn().mockResolvedValue(undefined), + text: vi.fn().mockResolvedValue(''), + select: vi.fn().mockResolvedValue(''), + multiselect: vi.fn().mockResolvedValue(multiselectValue), + progress: vi.fn().mockReturnValue({ + stop: vi.fn(), + update: vi.fn(), + }), + }); + + const createMockRuntime = (): RuntimeEnv => ({ + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }); + + const createMockHookReport = (eligible = true): HookStatusReport => ({ + workspaceDir: '/mock/workspace', + managedHooksDir: '/mock/.clawdbot/hooks', + hooks: [ + { + name: 'session-memory', + description: 'Save session context to memory when /new command is issued', + source: 'clawdbot-bundled', + emoji: '💾', + events: ['command:new'], + disabled: false, + eligible, + requirements: { config: ['workspace.dir'] }, + missing: {}, + }, + ], + }); + + describe('setupInternalHooks', () => { + it('should enable internal hooks when user selects them', async () => { + const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js'); + vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport()); + + const cfg: ClawdbotConfig = {}; + const prompter = createMockPrompter(['session-memory']); + const runtime = createMockRuntime(); + + const result = await setupInternalHooks(cfg, runtime, prompter); + + expect(result.hooks?.internal?.enabled).toBe(true); + expect(result.hooks?.internal?.entries).toEqual({ + 'session-memory': { enabled: true }, + }); + expect(prompter.note).toHaveBeenCalledTimes(2); + expect(prompter.multiselect).toHaveBeenCalledWith({ + message: 'Enable internal hooks?', + options: [ + { value: '__skip__', label: 'Skip for now' }, + { + value: 'session-memory', + label: '💾 session-memory', + hint: 'Save session context to memory when /new command is issued', + }, + ], + }); + }); + + it('should not enable hooks when user skips', async () => { + const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js'); + vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport()); + + const cfg: ClawdbotConfig = {}; + const prompter = createMockPrompter(['__skip__']); + const runtime = createMockRuntime(); + + const result = await setupInternalHooks(cfg, runtime, prompter); + + expect(result.hooks?.internal).toBeUndefined(); + expect(prompter.note).toHaveBeenCalledTimes(1); + }); + + it('should handle no eligible hooks', async () => { + const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js'); + vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport(false)); + + const cfg: ClawdbotConfig = {}; + const prompter = createMockPrompter([]); + const runtime = createMockRuntime(); + + const result = await setupInternalHooks(cfg, runtime, prompter); + + expect(result).toEqual(cfg); + expect(prompter.multiselect).not.toHaveBeenCalled(); + expect(prompter.note).toHaveBeenCalledWith( + 'No eligible hooks found. You can configure hooks later in your config.', + 'No Hooks Available', + ); + }); + + it('should preserve existing hooks config when enabled', async () => { + const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js'); + vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport()); + + const cfg: ClawdbotConfig = { + hooks: { + enabled: true, + path: '/webhook', + token: 'existing-token', + }, + }; + const prompter = createMockPrompter(['session-memory']); + const runtime = createMockRuntime(); + + const result = await setupInternalHooks(cfg, runtime, prompter); + + expect(result.hooks?.enabled).toBe(true); + expect(result.hooks?.path).toBe('/webhook'); + expect(result.hooks?.token).toBe('existing-token'); + expect(result.hooks?.internal?.enabled).toBe(true); + expect(result.hooks?.internal?.entries).toEqual({ + 'session-memory': { enabled: true }, + }); + }); + + it('should preserve existing config when user skips', async () => { + const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js'); + vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport()); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: '/workspace' } }, + }; + const prompter = createMockPrompter(['__skip__']); + const runtime = createMockRuntime(); + + const result = await setupInternalHooks(cfg, runtime, prompter); + + expect(result).toEqual(cfg); + expect(result.agents?.defaults?.workspace).toBe('/workspace'); + }); + + it('should show informative notes to user', async () => { + const { buildWorkspaceHookStatus } = await import('../hooks/hooks-status.js'); + vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport()); + + const cfg: ClawdbotConfig = {}; + const prompter = createMockPrompter(['session-memory']); + const runtime = createMockRuntime(); + + await setupInternalHooks(cfg, runtime, prompter); + + const noteCalls = (prompter.note as ReturnType).mock.calls; + expect(noteCalls).toHaveLength(2); + + // First note should explain what internal hooks are + expect(noteCalls[0][0]).toContain('Internal hooks'); + expect(noteCalls[0][0]).toContain('automate actions'); + + // Second note should confirm configuration + expect(noteCalls[1][0]).toContain('Enabled 1 hook: session-memory'); + expect(noteCalls[1][0]).toContain('clawdbot hooks internal list'); + }); + }); +}); diff --git a/src/commands/onboard-hooks.ts b/src/commands/onboard-hooks.ts new file mode 100644 index 000000000..ba622d4b0 --- /dev/null +++ b/src/commands/onboard-hooks.ts @@ -0,0 +1,86 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { buildWorkspaceHookStatus } from "../hooks/hooks-status.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; + +export async function setupInternalHooks( + cfg: ClawdbotConfig, + runtime: RuntimeEnv, + prompter: WizardPrompter, +): Promise { + await prompter.note( + [ + "Internal hooks let you automate actions when agent commands are issued.", + "Example: Save session context to memory when you issue /new.", + "", + "Learn more: https://docs.clawd.bot/internal-hooks", + ].join("\n"), + "Internal Hooks", + ); + + // Discover available hooks using the hook discovery system + const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const report = buildWorkspaceHookStatus(workspaceDir, { config: cfg }); + + // Filter for eligible and recommended hooks (session-memory is recommended) + const recommendedHooks = report.hooks.filter( + (h) => h.eligible && h.name === "session-memory", + ); + + if (recommendedHooks.length === 0) { + await prompter.note( + "No eligible hooks found. You can configure hooks later in your config.", + "No Hooks Available", + ); + return cfg; + } + + const toEnable = await prompter.multiselect({ + message: "Enable internal hooks?", + options: [ + { value: "__skip__", label: "Skip for now" }, + ...recommendedHooks.map((hook) => ({ + value: hook.name, + label: `${hook.emoji ?? "🔗"} ${hook.name}`, + hint: hook.description, + })), + ], + }); + + const selected = toEnable.filter((name) => name !== "__skip__"); + if (selected.length === 0) { + return cfg; + } + + // Enable selected hooks using the new entries config format + const entries = { ...cfg.hooks?.internal?.entries }; + for (const name of selected) { + entries[name] = { enabled: true }; + } + + const next: ClawdbotConfig = { + ...cfg, + hooks: { + ...cfg.hooks, + internal: { + enabled: true, + entries, + }, + }, + }; + + await prompter.note( + [ + `Enabled ${selected.length} hook${selected.length > 1 ? "s" : ""}: ${selected.join(", ")}`, + "", + "You can manage hooks later with:", + " clawdbot hooks internal list", + " clawdbot hooks internal enable ", + " clawdbot hooks internal disable ", + ].join("\n"), + "Hooks Configured", + ); + + return next; +} diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 91436677d..daa275eee 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -64,6 +64,35 @@ export type HooksGmailConfig = { thinking?: "off" | "minimal" | "low" | "medium" | "high"; }; +export type InternalHookHandlerConfig = { + /** Event key to listen for (e.g., 'command:new', 'session:start') */ + event: string; + /** Path to handler module (absolute or relative to cwd) */ + module: string; + /** Export name from module (default: 'default') */ + export?: string; +}; + +export type HookConfig = { + enabled?: boolean; + env?: Record; + [key: string]: unknown; +}; + +export type InternalHooksConfig = { + /** Enable internal hooks system */ + enabled?: boolean; + /** Legacy: List of internal hook handlers to register (still supported) */ + handlers?: InternalHookHandlerConfig[]; + /** Per-hook configuration overrides */ + entries?: Record; + /** Load configuration */ + load?: { + /** Additional hook directories to scan */ + extraDirs?: string[]; + }; +}; + export type HooksConfig = { enabled?: boolean; path?: string; @@ -73,4 +102,6 @@ export type HooksConfig = { transformsDir?: string; mappings?: HookMappingConfig[]; gmail?: HooksGmailConfig; + /** Internal agent event hooks */ + internal?: InternalHooksConfig; }; diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index d4383d6af..57df25842 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -41,6 +41,32 @@ export const HookMappingSchema = z }) .optional(); +export const InternalHookHandlerSchema = z.object({ + event: z.string(), + module: z.string(), + export: z.string().optional(), +}); + +const HookConfigSchema = z + .object({ + enabled: z.boolean().optional(), + env: z.record(z.string(), z.string()).optional(), + }) + .passthrough(); + +export const InternalHooksSchema = z + .object({ + enabled: z.boolean().optional(), + handlers: z.array(InternalHookHandlerSchema).optional(), + entries: z.record(z.string(), HookConfigSchema).optional(), + load: z + .object({ + extraDirs: z.array(z.string()).optional(), + }) + .optional(), + }) + .optional(); + export const HooksGmailSchema = z .object({ account: z.string().optional(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index f25c707d6..8b13da1ed 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js"; import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js"; -import { HookMappingSchema, HooksGmailSchema } from "./zod-schema.hooks.js"; +import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js"; import { ChannelsSchema } from "./zod-schema.providers.js"; import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.session.js"; @@ -148,6 +148,7 @@ export const ClawdbotSchema = z transformsDir: z.string().optional(), mappings: z.array(HookMappingSchema).optional(), gmail: HooksGmailSchema, + internal: InternalHooksSchema, }) .optional(), web: z diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts index 9a41cd537..b6a482184 100644 --- a/src/gateway/server-startup.ts +++ b/src/gateway/server-startup.ts @@ -8,6 +8,8 @@ import { import type { CliDeps } from "../cli/deps.js"; import type { loadConfig } from "../config/config.js"; import { startGmailWatcher } from "../hooks/gmail-watcher.js"; +import { clearInternalHooks } from "../hooks/internal-hooks.js"; +import { loadInternalHooks } from "../hooks/loader.js"; import type { loadClawdbotPlugins } from "../plugins/loader.js"; import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js"; import { startBrowserControlServerIfEnabled } from "./server-browser.js"; @@ -90,6 +92,18 @@ export async function startGatewaySidecars(params: { } } + // Load internal hook handlers from configuration and directory discovery. + try { + // Clear any previously registered hooks to ensure fresh loading + clearInternalHooks(); + const loadedCount = await loadInternalHooks(params.cfg, params.defaultWorkspaceDir); + if (loadedCount > 0) { + params.logHooks.info(`loaded ${loadedCount} internal hook handler${loadedCount > 1 ? 's' : ''}`); + } + } catch (err) { + params.logHooks.error(`failed to load internal hooks: ${String(err)}`); + } + // Launch configured channels so gateway replies via the surface the message came from. // Tests can opt out via CLAWDBOT_SKIP_CHANNELS (or legacy CLAWDBOT_SKIP_PROVIDERS). const skipChannels = diff --git a/src/hooks/bundled-dir.ts b/src/hooks/bundled-dir.ts new file mode 100644 index 000000000..74d5ebcf2 --- /dev/null +++ b/src/hooks/bundled-dir.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export function resolveBundledHooksDir(): string | undefined { + const override = process.env.CLAWDBOT_BUNDLED_HOOKS_DIR?.trim(); + if (override) return override; + + // bun --compile: ship a sibling `hooks/bundled/` next to the executable. + try { + const execDir = path.dirname(process.execPath); + const sibling = path.join(execDir, "hooks", "bundled"); + if (fs.existsSync(sibling)) return sibling; + } catch { + // ignore + } + + // npm: resolve `/dist/hooks/bundled` relative to this module (compiled hooks). + // This path works when installed via npm: node_modules/clawdbot/dist/hooks/bundled-dir.js + try { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const distBundled = path.join(moduleDir, "bundled"); + if (fs.existsSync(distBundled)) return distBundled; + } catch { + // ignore + } + + // dev: resolve `/src/hooks/bundled` relative to dist/hooks/bundled-dir.js + // This path works in dev: dist/hooks/bundled-dir.js -> ../../src/hooks/bundled + try { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const root = path.resolve(moduleDir, "..", ".."); + const srcBundled = path.join(root, "src", "hooks", "bundled"); + if (fs.existsSync(srcBundled)) return srcBundled; + } catch { + // ignore + } + + return undefined; +} diff --git a/src/hooks/bundled/README.md b/src/hooks/bundled/README.md new file mode 100644 index 000000000..10579c293 --- /dev/null +++ b/src/hooks/bundled/README.md @@ -0,0 +1,186 @@ +# Bundled Internal Hooks + +This directory contains internal hooks that ship with Clawdbot. These hooks are automatically discovered and can be enabled/disabled via CLI or configuration. + +## Available Hooks + +### 💾 session-memory + +Automatically saves session context to memory when you issue `/new`. + +**Events**: `command:new` +**What it does**: Creates a dated memory file with LLM-generated slug based on conversation content. +**Output**: `/memory/YYYY-MM-DD-slug.md` (defaults to `~/clawd`) + +**Enable**: +```bash +clawdbot hooks internal enable session-memory +``` + +### 📝 command-logger + +Logs all command events to a centralized audit file. + +**Events**: `command` (all commands) +**What it does**: Appends JSONL entries to command log file. +**Output**: `~/.clawdbot/logs/commands.log` + +**Enable**: +```bash +clawdbot hooks internal enable command-logger +``` + +## Hook Structure + +Each hook is a directory containing: + +- **HOOK.md**: Metadata and documentation in YAML frontmatter + Markdown +- **handler.ts**: The hook handler function (default export) + +Example structure: +``` +session-memory/ +├── HOOK.md # Metadata + docs +└── handler.ts # Handler implementation +``` + +## HOOK.md Format + +```yaml +--- +name: my-hook +description: "Short description" +homepage: https://docs.clawd.bot/hooks/my-hook +metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}} +--- + +# Hook Title + +Documentation goes here... +``` + +### Metadata Fields + +- **emoji**: Display emoji for CLI +- **events**: Array of events to listen for (e.g., `["command:new", "session:start"]`) +- **requires**: Optional requirements + - **bins**: Required binaries on PATH + - **anyBins**: At least one of these binaries must be present + - **env**: Required environment variables + - **config**: Required config paths (e.g., `["workspace.dir"]`) + - **os**: Required platforms (e.g., `["darwin", "linux"]`) +- **install**: Installation methods (for bundled hooks: `[{"id":"bundled","kind":"bundled"}]`) + +## Creating Custom Hooks + +To create your own hooks, place them in: + +- **Workspace hooks**: `/hooks/` (highest precedence) +- **Managed hooks**: `~/.clawdbot/hooks/` (shared across workspaces) + +Custom hooks follow the same structure as bundled hooks. + +## Managing Hooks + +List all hooks: +```bash +clawdbot hooks internal list +``` + +Show hook details: +```bash +clawdbot hooks internal info session-memory +``` + +Check hook status: +```bash +clawdbot hooks internal check +``` + +Enable/disable: +```bash +clawdbot hooks internal enable session-memory +clawdbot hooks internal disable command-logger +``` + +## Configuration + +Hooks can be configured in `~/.clawdbot/clawdbot.json`: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "entries": { + "session-memory": { + "enabled": true + }, + "command-logger": { + "enabled": false + } + } + } + } +} +``` + +## Event Types + +Currently supported events: + +- **command**: All command events +- **command:new**: `/new` command specifically +- **command:reset**: `/reset` command +- **command:stop**: `/stop` command + +More event types coming soon (session lifecycle, agent errors, etc.). + +## Handler API + +Hook handlers receive an `InternalHookEvent` object: + +```typescript +interface InternalHookEvent { + type: 'command' | 'session' | 'agent'; + action: string; // e.g., 'new', 'reset', 'stop' + sessionKey: string; + context: Record; + timestamp: Date; + messages: string[]; // Push messages here to send to user +} +``` + +Example handler: + +```typescript +import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js'; + +const myHandler: InternalHookHandler = async (event) => { + if (event.type !== 'command' || event.action !== 'new') { + return; + } + + // Your logic here + console.log('New command triggered!'); + + // Optionally send message to user + event.messages.push('✨ Hook executed!'); +}; + +export default myHandler; +``` + +## Testing + +Test your hooks by: + +1. Place hook in workspace hooks directory +2. Restart gateway: `pkill -9 -f 'clawdbot.*gateway' && pnpm clawdbot gateway` +3. Enable the hook: `clawdbot hooks internal enable my-hook` +4. Trigger the event (e.g., send `/new` command) +5. Check gateway logs for hook execution + +## Documentation + +Full documentation: https://docs.clawd.bot/internal-hooks diff --git a/src/hooks/bundled/command-logger/HOOK.md b/src/hooks/bundled/command-logger/HOOK.md new file mode 100644 index 000000000..4f579ab7d --- /dev/null +++ b/src/hooks/bundled/command-logger/HOOK.md @@ -0,0 +1,109 @@ +--- +name: command-logger +description: "Log all command events to a centralized audit file" +homepage: https://docs.clawd.bot/internal-hooks#command-logger +metadata: {"clawdbot":{"emoji":"📝","events":["command"],"install":[{"id":"bundled","kind":"bundled","label":"Bundled with Clawdbot"}]}} +--- + +# Command Logger Hook + +Logs all command events (`/new`, `/reset`, `/stop`, etc.) to a centralized audit log file for debugging and monitoring purposes. + +## What It Does + +Every time you issue a command to the agent: + +1. **Captures event details** - Command action, timestamp, session key, sender ID, source +2. **Appends to log file** - Writes a JSON line to `~/.clawdbot/logs/commands.log` +3. **Silent operation** - Runs in the background without user notifications + +## Output Format + +Log entries are written in JSONL (JSON Lines) format: + +```json +{"timestamp":"2026-01-16T14:30:00.000Z","action":"new","sessionKey":"agent:main:main","senderId":"+1234567890","source":"telegram"} +{"timestamp":"2026-01-16T15:45:22.000Z","action":"stop","sessionKey":"agent:main:main","senderId":"user@example.com","source":"whatsapp"} +``` + +## Use Cases + +- **Debugging**: Track when commands were issued and from which source +- **Auditing**: Monitor command usage across different channels +- **Analytics**: Analyze command patterns and frequency +- **Troubleshooting**: Investigate issues by reviewing command history + +## Log File Location + +`~/.clawdbot/logs/commands.log` + +## Requirements + +No requirements - this hook works out of the box on all platforms. + +## Configuration + +No configuration needed. The hook automatically: +- Creates the log directory if it doesn't exist +- Appends to the log file (doesn't overwrite) +- Handles errors silently without disrupting command execution + +## Disabling + +To disable this hook: + +```bash +clawdbot hooks internal disable command-logger +``` + +Or via config: + +```json +{ + "hooks": { + "internal": { + "entries": { + "command-logger": { "enabled": false } + } + } + } +} +``` + +## Log Rotation + +The hook does not automatically rotate logs. To manage log size, you can: + +1. **Manual rotation**: + ```bash + mv ~/.clawdbot/logs/commands.log ~/.clawdbot/logs/commands.log.old + ``` + +2. **Use logrotate** (Linux): + Create `/etc/logrotate.d/clawdbot`: + ``` + /home/username/.clawdbot/logs/commands.log { + weekly + rotate 4 + compress + missingok + notifempty + } + ``` + +## Viewing Logs + +View recent commands: +```bash +tail -n 20 ~/.clawdbot/logs/commands.log +``` + +Pretty-print with jq: +```bash +cat ~/.clawdbot/logs/commands.log | jq . +``` + +Filter by action: +```bash +grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq . +``` diff --git a/src/hooks/bundled/command-logger/handler.ts b/src/hooks/bundled/command-logger/handler.ts new file mode 100644 index 000000000..bc0a2ae7b --- /dev/null +++ b/src/hooks/bundled/command-logger/handler.ts @@ -0,0 +1,64 @@ +/** + * Example internal hook handler: Log all commands to a file + * + * This handler demonstrates how to create a hook that logs all command events + * to a centralized log file for audit/debugging purposes. + * + * To enable this handler, add it to your config: + * + * ```json + * { + * "hooks": { + * "internal": { + * "enabled": true, + * "handlers": [ + * { + * "event": "command", + * "module": "./hooks/handlers/command-logger.ts" + * } + * ] + * } + * } + * } + * ``` + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import type { InternalHookHandler } from '../../internal-hooks.js'; + +/** + * Log all command events to a file + */ +const logCommand: InternalHookHandler = async (event) => { + // Only trigger on command events + if (event.type !== 'command') { + return; + } + + try { + // Create log directory + const logDir = path.join(os.homedir(), '.clawdbot', 'logs'); + await fs.mkdir(logDir, { recursive: true }); + + // Append to command log file + const logFile = path.join(logDir, 'commands.log'); + const logLine = JSON.stringify({ + timestamp: event.timestamp.toISOString(), + action: event.action, + sessionKey: event.sessionKey, + senderId: event.context.senderId ?? 'unknown', + source: event.context.commandSource ?? 'unknown', + }) + '\n'; + + await fs.appendFile(logFile, logLine, 'utf-8'); + } catch (err) { + console.error( + '[command-logger] Failed to log command:', + err instanceof Error ? err.message : String(err) + ); + } +}; + +export default logCommand; diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md new file mode 100644 index 000000000..04032a76a --- /dev/null +++ b/src/hooks/bundled/session-memory/HOOK.md @@ -0,0 +1,76 @@ +--- +name: session-memory +description: "Save session context to memory when /new command is issued" +homepage: https://docs.clawd.bot/internal-hooks#session-memory +metadata: {"clawdbot":{"emoji":"💾","events":["command:new"],"requires":{"config":["workspace.dir"]},"install":[{"id":"bundled","kind":"bundled","label":"Bundled with Clawdbot"}]}} +--- + +# Session Memory Hook + +Automatically saves session context to your workspace memory when you issue the `/new` command. + +## What It Does + +When you run `/new` to start a fresh session: + +1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript +2. **Extracts conversation** - Reads the last 15 lines of conversation from the session +3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content +4. **Saves to memory** - Creates a new file at `/memory/YYYY-MM-DD-slug.md` +5. **Sends confirmation** - Notifies you with the file path + +## Output Format + +Memory files are created with the following format: + +```markdown +# Session: 2026-01-16 14:30:00 UTC + +- **Session Key**: agent:main:main +- **Session ID**: abc123def456 +- **Source**: telegram +``` + +## Filename Examples + +The LLM generates descriptive slugs based on your conversation: + +- `2026-01-16-vendor-pitch.md` - Discussion about vendor evaluation +- `2026-01-16-api-design.md` - API architecture planning +- `2026-01-16-bug-fix.md` - Debugging session +- `2026-01-16-1430.md` - Fallback timestamp if slug generation fails + +## Requirements + +- **Config**: `workspace.dir` must be set (automatically configured during onboarding) + +The hook uses your configured LLM provider to generate slugs, so it works with any provider (Anthropic, OpenAI, etc.). + +## Configuration + +No additional configuration required. The hook automatically: +- Uses your workspace directory (`~/clawd` by default) +- Uses your configured LLM for slug generation +- Falls back to timestamp slugs if LLM is unavailable + +## Disabling + +To disable this hook: + +```bash +clawdbot hooks internal disable session-memory +``` + +Or remove it from your config: + +```json +{ + "hooks": { + "internal": { + "entries": { + "session-memory": { "enabled": false } + } + } + } +} +``` diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts new file mode 100644 index 000000000..c5ddde484 --- /dev/null +++ b/src/hooks/bundled/session-memory/handler.ts @@ -0,0 +1,174 @@ +/** + * Session memory hook handler + * + * Saves session context to memory when /new command is triggered + * Creates a new dated memory file with LLM-generated slug + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import type { ClawdbotConfig } from '../../../config/config.js'; +import { resolveAgentWorkspaceDir } from '../../../agents/agent-scope.js'; +import { resolveAgentIdFromSessionKey } from '../../../routing/session-key.js'; +import type { InternalHookHandler } from '../../internal-hooks.js'; + +/** + * Read recent messages from session file for slug generation + */ +async function getRecentSessionContent(sessionFilePath: string): Promise { + try { + const content = await fs.readFile(sessionFilePath, 'utf-8'); + const lines = content.trim().split('\n'); + + // Get last 15 lines (recent conversation) + const recentLines = lines.slice(-15); + + // Parse JSONL and extract messages + const messages: string[] = []; + for (const line of recentLines) { + try { + const entry = JSON.parse(line); + // Session files have entries with type="message" containing a nested message object + if (entry.type === 'message' && entry.message) { + const msg = entry.message; + const role = msg.role; + if ((role === 'user' || role === 'assistant') && msg.content) { + // Extract text content + const text = Array.isArray(msg.content) + ? msg.content.find((c: any) => c.type === 'text')?.text + : msg.content; + if (text && !text.startsWith('/')) { + messages.push(`${role}: ${text}`); + } + } + } + } catch { + // Skip invalid JSON lines + } + } + + return messages.join('\n'); + } catch { + return null; + } +} + +/** + * Save session context to memory when /new command is triggered + */ +const saveSessionToMemory: InternalHookHandler = async (event) => { + // Only trigger on 'new' command + if (event.type !== 'command' || event.action !== 'new') { + return; + } + + try { + console.log('[session-memory] Hook triggered for /new command'); + + const context = event.context || {}; + const cfg = context.cfg as ClawdbotConfig | undefined; + const agentId = resolveAgentIdFromSessionKey(event.sessionKey); + const workspaceDir = cfg + ? resolveAgentWorkspaceDir(cfg, agentId) + : path.join(os.homedir(), 'clawd'); + const memoryDir = path.join(workspaceDir, 'memory'); + await fs.mkdir(memoryDir, { recursive: true }); + + // Get today's date for filename + const now = new Date(event.timestamp); + const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD + + // Generate descriptive slug from session using LLM + const sessionEntry = ( + context.previousSessionEntry || + context.sessionEntry || + {} + ) as Record; + const currentSessionId = sessionEntry.sessionId as string; + const currentSessionFile = sessionEntry.sessionFile as string; + + console.log('[session-memory] Current sessionId:', currentSessionId); + console.log('[session-memory] Current sessionFile:', currentSessionFile); + console.log('[session-memory] cfg present:', !!cfg); + + const sessionFile = currentSessionFile || undefined; + + let slug: string | null = null; + let sessionContent: string | null = null; + + if (sessionFile) { + // Get recent conversation content + sessionContent = await getRecentSessionContent(sessionFile); + console.log('[session-memory] sessionContent length:', sessionContent?.length || 0); + + if (sessionContent && cfg) { + console.log('[session-memory] Calling generateSlugViaLLM...'); + // Dynamically import the LLM slug generator (avoids module caching issues) + // When compiled, handler is at dist/hooks/bundled/session-memory/handler.js + // Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js + const clawdbotRoot = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '../..'); + const slugGenPath = path.join(clawdbotRoot, 'llm-slug-generator.js'); + const { generateSlugViaLLM } = await import(slugGenPath); + + // Use LLM to generate a descriptive slug + slug = await generateSlugViaLLM({ sessionContent, cfg }); + console.log('[session-memory] Generated slug:', slug); + } + } + + // If no slug, use timestamp + if (!slug) { + const timeSlug = now.toISOString().split('T')[1]!.split('.')[0]!.replace(/:/g, ''); + slug = timeSlug.slice(0, 4); // HHMM + console.log('[session-memory] Using fallback timestamp slug:', slug); + } + + // Create filename with date and slug + const filename = `${dateStr}-${slug}.md`; + const memoryFilePath = path.join(memoryDir, filename); + console.log('[session-memory] Generated filename:', filename); + console.log('[session-memory] Full path:', memoryFilePath); + + // Format time as HH:MM:SS UTC + const timeStr = now.toISOString().split('T')[1]!.split('.')[0]; + + // Extract context details + const sessionId = (sessionEntry.sessionId as string) || 'unknown'; + const source = (context.commandSource as string) || 'unknown'; + + // Build Markdown entry + const entryParts = [ + `# Session: ${dateStr} ${timeStr} UTC`, + '', + `- **Session Key**: ${event.sessionKey}`, + `- **Session ID**: ${sessionId}`, + `- **Source**: ${source}`, + '', + ]; + + // Include conversation content if available + if (sessionContent) { + entryParts.push('## Conversation Summary', '', sessionContent, ''); + } + + const entry = entryParts.join('\n'); + + // Write to new memory file + await fs.writeFile(memoryFilePath, entry, 'utf-8'); + console.log('[session-memory] Memory file written successfully'); + + // Send confirmation message to user with filename + const relPath = memoryFilePath.replace(os.homedir(), '~'); + const confirmMsg = `💾 Session context saved to memory before reset.\n📄 ${relPath}`; + event.messages.push(confirmMsg); + console.log('[session-memory] Confirmation message queued:', confirmMsg); + } catch (err) { + console.error( + '[session-memory] Failed to save session memory:', + err instanceof Error ? err.message : String(err) + ); + } +}; + +export default saveSessionToMemory; diff --git a/src/hooks/config.ts b/src/hooks/config.ts new file mode 100644 index 000000000..228a4902a --- /dev/null +++ b/src/hooks/config.ts @@ -0,0 +1,134 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { ClawdbotConfig, HookConfig } from "../config/config.js"; +import { resolveHookKey } from "./frontmatter.js"; +import type { HookEligibilityContext, HookEntry } from "./types.js"; + +const DEFAULT_CONFIG_VALUES: Record = { + "browser.enabled": true, + "workspace.dir": true, +}; + +function isTruthy(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") return value.trim().length > 0; + return true; +} + +export function resolveConfigPath(config: ClawdbotConfig | undefined, pathStr: string) { + const parts = pathStr.split(".").filter(Boolean); + let current: unknown = config; + for (const part of parts) { + if (typeof current !== "object" || current === null) return undefined; + current = (current as Record)[part]; + } + return current; +} + +export function isConfigPathTruthy(config: ClawdbotConfig | undefined, pathStr: string): boolean { + const value = resolveConfigPath(config, pathStr); + if (value === undefined && pathStr in DEFAULT_CONFIG_VALUES) { + return DEFAULT_CONFIG_VALUES[pathStr] === true; + } + return isTruthy(value); +} + +export function resolveHookConfig( + config: ClawdbotConfig | undefined, + hookKey: string, +): HookConfig | undefined { + const hooks = config?.hooks?.internal?.entries; + if (!hooks || typeof hooks !== "object") return undefined; + const entry = (hooks as Record)[hookKey]; + if (!entry || typeof entry !== "object") return undefined; + return entry; +} + +export function resolveRuntimePlatform(): string { + return process.platform; +} + +export function hasBinary(bin: string): boolean { + const pathEnv = process.env.PATH ?? ""; + const parts = pathEnv.split(path.delimiter).filter(Boolean); + for (const part of parts) { + const candidate = path.join(part, bin); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + // keep scanning + } + } + return false; +} + +export function shouldIncludeHook(params: { + entry: HookEntry; + config?: ClawdbotConfig; + eligibility?: HookEligibilityContext; +}): boolean { + const { entry, config, eligibility } = params; + const hookKey = resolveHookKey(entry.hook.name, entry); + const hookConfig = resolveHookConfig(config, hookKey); + const osList = entry.clawdbot?.os ?? []; + const remotePlatforms = eligibility?.remote?.platforms ?? []; + + // Check if explicitly disabled + if (hookConfig?.enabled === false) return false; + + // Check OS requirement + if ( + osList.length > 0 && + !osList.includes(resolveRuntimePlatform()) && + !remotePlatforms.some((platform) => osList.includes(platform)) + ) { + return false; + } + + // If marked as 'always', bypass all other checks + if (entry.clawdbot?.always === true) { + return true; + } + + // Check required binaries (all must be present) + const requiredBins = entry.clawdbot?.requires?.bins ?? []; + if (requiredBins.length > 0) { + for (const bin of requiredBins) { + if (hasBinary(bin)) continue; + if (eligibility?.remote?.hasBin?.(bin)) continue; + return false; + } + } + + // Check anyBins (at least one must be present) + const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? []; + if (requiredAnyBins.length > 0) { + const anyFound = + requiredAnyBins.some((bin) => hasBinary(bin)) || + eligibility?.remote?.hasAnyBin?.(requiredAnyBins); + if (!anyFound) return false; + } + + // Check required environment variables + const requiredEnv = entry.clawdbot?.requires?.env ?? []; + if (requiredEnv.length > 0) { + for (const envName of requiredEnv) { + if (process.env[envName]) continue; + if (hookConfig?.env?.[envName]) continue; + return false; + } + } + + // Check required config paths + const requiredConfig = entry.clawdbot?.requires?.config ?? []; + if (requiredConfig.length > 0) { + for (const configPath of requiredConfig) { + if (!isConfigPathTruthy(config, configPath)) return false; + } + } + + return true; +} diff --git a/src/hooks/frontmatter.ts b/src/hooks/frontmatter.ts new file mode 100644 index 000000000..53bb6941f --- /dev/null +++ b/src/hooks/frontmatter.ts @@ -0,0 +1,152 @@ +import type { + ClawdbotHookMetadata, + HookEntry, + HookInstallSpec, + HookInvocationPolicy, + ParsedHookFrontmatter, +} from "./types.js"; + +function stripQuotes(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +export function parseFrontmatter(content: string): ParsedHookFrontmatter { + const frontmatter: ParsedHookFrontmatter = {}; + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return frontmatter; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return frontmatter; + const block = normalized.slice(4, endIndex); + for (const line of block.split("\n")) { + const match = line.match(/^([\w-]+):\s*(.*)$/); + if (!match) continue; + const key = match[1]; + const value = stripQuotes(match[2].trim()); + if (!key || !value) continue; + frontmatter[key] = value; + } + return frontmatter; +} + +function normalizeStringList(input: unknown): string[] { + if (!input) return []; + if (Array.isArray(input)) { + return input.map((value) => String(value).trim()).filter(Boolean); + } + if (typeof input === "string") { + return input + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + } + return []; +} + +function parseInstallSpec(input: unknown): HookInstallSpec | undefined { + if (!input || typeof input !== "object") return undefined; + const raw = input as Record; + const kindRaw = + typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : ""; + const kind = kindRaw.trim().toLowerCase(); + if (kind !== "bundled" && kind !== "npm" && kind !== "git") { + return undefined; + } + + const spec: HookInstallSpec = { + kind: kind as HookInstallSpec["kind"], + }; + + if (typeof raw.id === "string") spec.id = raw.id; + if (typeof raw.label === "string") spec.label = raw.label; + const bins = normalizeStringList(raw.bins); + if (bins.length > 0) spec.bins = bins; + if (typeof raw.package === "string") spec.package = raw.package; + if (typeof raw.repository === "string") spec.repository = raw.repository; + + return spec; +} + +function getFrontmatterValue(frontmatter: ParsedHookFrontmatter, key: string): string | undefined { + const raw = frontmatter[key]; + return typeof raw === "string" ? raw : undefined; +} + +function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean { + if (!value) return fallback; + const normalized = value.trim().toLowerCase(); + if (!normalized) return fallback; + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if ( + normalized === "false" || + normalized === "0" || + normalized === "no" || + normalized === "off" + ) { + return false; + } + return fallback; +} + +export function resolveClawdbotMetadata( + frontmatter: ParsedHookFrontmatter, +): ClawdbotHookMetadata | undefined { + const raw = getFrontmatterValue(frontmatter, "metadata"); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as { clawdbot?: unknown }; + if (!parsed || typeof parsed !== "object") return undefined; + const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot; + if (!clawdbot || typeof clawdbot !== "object") return undefined; + const clawdbotObj = clawdbot as Record; + const requiresRaw = + typeof clawdbotObj.requires === "object" && clawdbotObj.requires !== null + ? (clawdbotObj.requires as Record) + : undefined; + const installRaw = Array.isArray(clawdbotObj.install) ? (clawdbotObj.install as unknown[]) : []; + const install = installRaw + .map((entry) => parseInstallSpec(entry)) + .filter((entry): entry is HookInstallSpec => Boolean(entry)); + const osRaw = normalizeStringList(clawdbotObj.os); + const eventsRaw = normalizeStringList(clawdbotObj.events); + return { + always: typeof clawdbotObj.always === "boolean" ? clawdbotObj.always : undefined, + emoji: typeof clawdbotObj.emoji === "string" ? clawdbotObj.emoji : undefined, + homepage: typeof clawdbotObj.homepage === "string" ? clawdbotObj.homepage : undefined, + hookKey: typeof clawdbotObj.hookKey === "string" ? clawdbotObj.hookKey : undefined, + export: typeof clawdbotObj.export === "string" ? clawdbotObj.export : undefined, + os: osRaw.length > 0 ? osRaw : undefined, + events: eventsRaw.length > 0 ? eventsRaw : [], + requires: requiresRaw + ? { + bins: normalizeStringList(requiresRaw.bins), + anyBins: normalizeStringList(requiresRaw.anyBins), + env: normalizeStringList(requiresRaw.env), + config: normalizeStringList(requiresRaw.config), + } + : undefined, + install: install.length > 0 ? install : undefined, + }; + } catch { + return undefined; + } +} + +export function resolveHookInvocationPolicy( + frontmatter: ParsedHookFrontmatter, +): HookInvocationPolicy { + return { + enabled: parseFrontmatterBool(getFrontmatterValue(frontmatter, "enabled"), true), + }; +} + +export function resolveHookKey(hookName: string, entry?: HookEntry): string { + return entry?.clawdbot?.hookKey ?? hookName; +} diff --git a/src/hooks/hooks-status.ts b/src/hooks/hooks-status.ts new file mode 100644 index 000000000..a3ddd30d4 --- /dev/null +++ b/src/hooks/hooks-status.ts @@ -0,0 +1,225 @@ +import path from "node:path"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { CONFIG_DIR } from "../utils.js"; +import { + hasBinary, + isConfigPathTruthy, + resolveConfigPath, + resolveHookConfig, +} from "./config.js"; +import type { + HookEligibilityContext, + HookEntry, + HookInstallSpec, +} from "./types.js"; +import { loadWorkspaceHookEntries } from "./workspace.js"; + +export type HookStatusConfigCheck = { + path: string; + value: unknown; + satisfied: boolean; +}; + +export type HookInstallOption = { + id: string; + kind: HookInstallSpec["kind"]; + label: string; + bins: string[]; +}; + +export type HookStatusEntry = { + name: string; + description: string; + source: string; + filePath: string; + baseDir: string; + handlerPath: string; + hookKey: string; + emoji?: string; + homepage?: string; + events: string[]; + always: boolean; + disabled: boolean; + eligible: boolean; + requirements: { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; + missing: { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; + configChecks: HookStatusConfigCheck[]; + install: HookInstallOption[]; +}; + +export type HookStatusReport = { + workspaceDir: string; + managedHooksDir: string; + hooks: HookStatusEntry[]; +}; + +function resolveHookKey(entry: HookEntry): string { + return entry.clawdbot?.hookKey ?? entry.hook.name; +} + +function normalizeInstallOptions(entry: HookEntry): HookInstallOption[] { + const install = entry.clawdbot?.install ?? []; + if (install.length === 0) return []; + + // For hooks, we just list all install options + return install.map((spec, index) => { + const id = (spec.id ?? `${spec.kind}-${index}`).trim(); + const bins = spec.bins ?? []; + let label = (spec.label ?? "").trim(); + + if (!label) { + if (spec.kind === "bundled") { + label = "Bundled with Clawdbot"; + } else if (spec.kind === "npm" && spec.package) { + label = `Install ${spec.package} (npm)`; + } else if (spec.kind === "git" && spec.repository) { + label = `Install from ${spec.repository}`; + } else { + label = "Run installer"; + } + } + + return { id, kind: spec.kind, label, bins }; + }); +} + +function buildHookStatus( + entry: HookEntry, + config?: ClawdbotConfig, + eligibility?: HookEligibilityContext, +): HookStatusEntry { + const hookKey = resolveHookKey(entry); + const hookConfig = resolveHookConfig(config, hookKey); + const disabled = hookConfig?.enabled === false; + const always = entry.clawdbot?.always === true; + const emoji = entry.clawdbot?.emoji ?? entry.frontmatter.emoji; + const homepageRaw = + entry.clawdbot?.homepage ?? + entry.frontmatter.homepage ?? + entry.frontmatter.website ?? + entry.frontmatter.url; + const homepage = homepageRaw?.trim() ? homepageRaw.trim() : undefined; + const events = entry.clawdbot?.events ?? []; + + const requiredBins = entry.clawdbot?.requires?.bins ?? []; + const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? []; + const requiredEnv = entry.clawdbot?.requires?.env ?? []; + const requiredConfig = entry.clawdbot?.requires?.config ?? []; + const requiredOs = entry.clawdbot?.os ?? []; + + const missingBins = requiredBins.filter((bin) => { + if (hasBinary(bin)) return false; + if (eligibility?.remote?.hasBin?.(bin)) return false; + return true; + }); + + const missingAnyBins = + requiredAnyBins.length > 0 && + !( + requiredAnyBins.some((bin) => hasBinary(bin)) || + eligibility?.remote?.hasAnyBin?.(requiredAnyBins) + ) + ? requiredAnyBins + : []; + + const missingOs = + requiredOs.length > 0 && + !requiredOs.includes(process.platform) && + !eligibility?.remote?.platforms?.some((platform) => requiredOs.includes(platform)) + ? requiredOs + : []; + + const missingEnv: string[] = []; + for (const envName of requiredEnv) { + if (process.env[envName]) continue; + if (hookConfig?.env?.[envName]) continue; + missingEnv.push(envName); + } + + const configChecks: HookStatusConfigCheck[] = requiredConfig.map((pathStr) => { + const value = resolveConfigPath(config, pathStr); + const satisfied = isConfigPathTruthy(config, pathStr); + return { path: pathStr, value, satisfied }; + }); + + const missingConfig = configChecks + .filter((check) => !check.satisfied) + .map((check) => check.path); + + const missing = always + ? { bins: [], anyBins: [], env: [], config: [], os: [] } + : { + bins: missingBins, + anyBins: missingAnyBins, + env: missingEnv, + config: missingConfig, + os: missingOs, + }; + + const eligible = + !disabled && + (always || + (missing.bins.length === 0 && + missing.anyBins.length === 0 && + missing.env.length === 0 && + missing.config.length === 0 && + missing.os.length === 0)); + + return { + name: entry.hook.name, + description: entry.hook.description, + source: entry.hook.source, + filePath: entry.hook.filePath, + baseDir: entry.hook.baseDir, + handlerPath: entry.hook.handlerPath, + hookKey, + emoji, + homepage, + events, + always, + disabled, + eligible, + requirements: { + bins: requiredBins, + anyBins: requiredAnyBins, + env: requiredEnv, + config: requiredConfig, + os: requiredOs, + }, + missing, + configChecks, + install: normalizeInstallOptions(entry), + }; +} + +export function buildWorkspaceHookStatus( + workspaceDir: string, + opts?: { + config?: ClawdbotConfig; + managedHooksDir?: string; + entries?: HookEntry[]; + eligibility?: HookEligibilityContext; + }, +): HookStatusReport { + const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks"); + const hookEntries = opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts); + + return { + workspaceDir, + managedHooksDir, + hooks: hookEntries.map((entry) => buildHookStatus(entry, opts?.config, opts?.eligibility)), + }; +} diff --git a/src/hooks/internal-hooks.test.ts b/src/hooks/internal-hooks.test.ts new file mode 100644 index 000000000..217ac3894 --- /dev/null +++ b/src/hooks/internal-hooks.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearInternalHooks, + createInternalHookEvent, + getRegisteredEventKeys, + registerInternalHook, + triggerInternalHook, + unregisterInternalHook, + type InternalHookEvent, +} from './internal-hooks.js'; + +describe('internal-hooks', () => { + beforeEach(() => { + clearInternalHooks(); + }); + + afterEach(() => { + clearInternalHooks(); + }); + + describe('registerInternalHook', () => { + it('should register a hook handler', () => { + const handler = vi.fn(); + registerInternalHook('command:new', handler); + + const keys = getRegisteredEventKeys(); + expect(keys).toContain('command:new'); + }); + + it('should allow multiple handlers for the same event', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + registerInternalHook('command:new', handler1); + registerInternalHook('command:new', handler2); + + const keys = getRegisteredEventKeys(); + expect(keys).toContain('command:new'); + }); + }); + + describe('unregisterInternalHook', () => { + it('should unregister a specific handler', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + registerInternalHook('command:new', handler1); + registerInternalHook('command:new', handler2); + + unregisterInternalHook('command:new', handler1); + + const event = createInternalHookEvent('command', 'new', 'test-session'); + void triggerInternalHook(event); + + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).toHaveBeenCalled(); + }); + + it('should clean up empty handler arrays', () => { + const handler = vi.fn(); + + registerInternalHook('command:new', handler); + unregisterInternalHook('command:new', handler); + + const keys = getRegisteredEventKeys(); + expect(keys).not.toContain('command:new'); + }); + }); + + describe('triggerInternalHook', () => { + it('should trigger handlers for general event type', async () => { + const handler = vi.fn(); + registerInternalHook('command', handler); + + const event = createInternalHookEvent('command', 'new', 'test-session'); + await triggerInternalHook(event); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('should trigger handlers for specific event action', async () => { + const handler = vi.fn(); + registerInternalHook('command:new', handler); + + const event = createInternalHookEvent('command', 'new', 'test-session'); + await triggerInternalHook(event); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('should trigger both general and specific handlers', async () => { + const generalHandler = vi.fn(); + const specificHandler = vi.fn(); + + registerInternalHook('command', generalHandler); + registerInternalHook('command:new', specificHandler); + + const event = createInternalHookEvent('command', 'new', 'test-session'); + await triggerInternalHook(event); + + expect(generalHandler).toHaveBeenCalledWith(event); + expect(specificHandler).toHaveBeenCalledWith(event); + }); + + it('should handle async handlers', async () => { + const handler = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + registerInternalHook('command:new', handler); + + const event = createInternalHookEvent('command', 'new', 'test-session'); + await triggerInternalHook(event); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('should catch and log errors from handlers', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const errorHandler = vi.fn(() => { + throw new Error('Handler failed'); + }); + const successHandler = vi.fn(); + + registerInternalHook('command:new', errorHandler); + registerInternalHook('command:new', successHandler); + + const event = createInternalHookEvent('command', 'new', 'test-session'); + await triggerInternalHook(event); + + expect(errorHandler).toHaveBeenCalled(); + expect(successHandler).toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('Internal hook error'), + expect.stringContaining('Handler failed') + ); + + consoleError.mockRestore(); + }); + + it('should not throw if no handlers are registered', async () => { + const event = createInternalHookEvent('command', 'new', 'test-session'); + await expect(triggerInternalHook(event)).resolves.not.toThrow(); + }); + }); + + describe('createInternalHookEvent', () => { + it('should create a properly formatted event', () => { + const event = createInternalHookEvent('command', 'new', 'test-session', { + foo: 'bar', + }); + + expect(event.type).toBe('command'); + expect(event.action).toBe('new'); + expect(event.sessionKey).toBe('test-session'); + expect(event.context).toEqual({ foo: 'bar' }); + expect(event.timestamp).toBeInstanceOf(Date); + }); + + it('should use empty context if not provided', () => { + const event = createInternalHookEvent('command', 'new', 'test-session'); + + expect(event.context).toEqual({}); + }); + }); + + describe('getRegisteredEventKeys', () => { + it('should return all registered event keys', () => { + registerInternalHook('command:new', vi.fn()); + registerInternalHook('command:stop', vi.fn()); + registerInternalHook('session:start', vi.fn()); + + const keys = getRegisteredEventKeys(); + expect(keys).toContain('command:new'); + expect(keys).toContain('command:stop'); + expect(keys).toContain('session:start'); + }); + + it('should return empty array when no handlers are registered', () => { + const keys = getRegisteredEventKeys(); + expect(keys).toEqual([]); + }); + }); + + describe('clearInternalHooks', () => { + it('should remove all registered handlers', () => { + registerInternalHook('command:new', vi.fn()); + registerInternalHook('command:stop', vi.fn()); + + clearInternalHooks(); + + const keys = getRegisteredEventKeys(); + expect(keys).toEqual([]); + }); + }); + + describe('integration', () => { + it('should handle a complete hook lifecycle', async () => { + const results: InternalHookEvent[] = []; + const handler = vi.fn((event: InternalHookEvent) => { + results.push(event); + }); + + // Register + registerInternalHook('command:new', handler); + + // Trigger + const event1 = createInternalHookEvent('command', 'new', 'session-1'); + await triggerInternalHook(event1); + + const event2 = createInternalHookEvent('command', 'new', 'session-2'); + await triggerInternalHook(event2); + + // Verify + expect(results).toHaveLength(2); + expect(results[0].sessionKey).toBe('session-1'); + expect(results[1].sessionKey).toBe('session-2'); + + // Unregister + unregisterInternalHook('command:new', handler); + + // Trigger again - should not call handler + const event3 = createInternalHookEvent('command', 'new', 'session-3'); + await triggerInternalHook(event3); + + expect(results).toHaveLength(2); + }); + }); +}); diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts new file mode 100644 index 000000000..6f1b91d8b --- /dev/null +++ b/src/hooks/internal-hooks.ts @@ -0,0 +1,155 @@ +/** + * Internal hook system for clawdbot agent events + * + * Provides an extensible event-driven hook system for internal agent events + * like command processing, session lifecycle, etc. + */ + +export type InternalHookEventType = 'command' | 'session' | 'agent'; + +export interface InternalHookEvent { + /** The type of event (command, session, agent, etc.) */ + type: InternalHookEventType; + /** The specific action within the type (e.g., 'new', 'reset', 'stop') */ + action: string; + /** The session key this event relates to */ + sessionKey: string; + /** Additional context specific to the event */ + context: Record; + /** Timestamp when the event occurred */ + timestamp: Date; + /** Messages to send back to the user (hooks can push to this array) */ + messages: string[]; +} + +export type InternalHookHandler = (event: InternalHookEvent) => Promise | void; + +/** Registry of hook handlers by event key */ +const handlers = new Map(); + +/** + * Register a hook handler for a specific event type or event:action combination + * + * @param eventKey - Event type (e.g., 'command') or specific action (e.g., 'command:new') + * @param handler - Function to call when the event is triggered + * + * @example + * ```ts + * // Listen to all command events + * registerInternalHook('command', async (event) => { + * console.log('Command:', event.action); + * }); + * + * // Listen only to /new commands + * registerInternalHook('command:new', async (event) => { + * await saveSessionToMemory(event); + * }); + * ``` + */ +export function registerInternalHook( + eventKey: string, + handler: InternalHookHandler +): void { + if (!handlers.has(eventKey)) { + handlers.set(eventKey, []); + } + handlers.get(eventKey)!.push(handler); +} + +/** + * Unregister a specific hook handler + * + * @param eventKey - Event key the handler was registered for + * @param handler - The handler function to remove + */ +export function unregisterInternalHook( + eventKey: string, + handler: InternalHookHandler +): void { + const eventHandlers = handlers.get(eventKey); + if (!eventHandlers) { + return; + } + + const index = eventHandlers.indexOf(handler); + if (index !== -1) { + eventHandlers.splice(index, 1); + } + + // Clean up empty handler arrays + if (eventHandlers.length === 0) { + handlers.delete(eventKey); + } +} + +/** + * Clear all registered hooks (useful for testing) + */ +export function clearInternalHooks(): void { + handlers.clear(); +} + +/** + * Get all registered event keys (useful for debugging) + */ +export function getRegisteredEventKeys(): string[] { + return Array.from(handlers.keys()); +} + +/** + * Trigger an internal hook event + * + * Calls all handlers registered for: + * 1. The general event type (e.g., 'command') + * 2. The specific event:action combination (e.g., 'command:new') + * + * Handlers are called in registration order. Errors are caught and logged + * but don't prevent other handlers from running. + * + * @param event - The event to trigger + */ +export async function triggerInternalHook(event: InternalHookEvent): Promise { + const typeHandlers = handlers.get(event.type) ?? []; + const specificHandlers = handlers.get(`${event.type}:${event.action}`) ?? []; + + const allHandlers = [...typeHandlers, ...specificHandlers]; + + if (allHandlers.length === 0) { + return; + } + + for (const handler of allHandlers) { + try { + await handler(event); + } catch (err) { + console.error( + `Internal hook error [${event.type}:${event.action}]:`, + err instanceof Error ? err.message : String(err) + ); + } + } +} + +/** + * Create an internal hook event with common fields filled in + * + * @param type - The event type + * @param action - The action within that type + * @param sessionKey - The session key + * @param context - Additional context + */ +export function createInternalHookEvent( + type: InternalHookEventType, + action: string, + sessionKey: string, + context: Record = {} +): InternalHookEvent { + return { + type, + action, + sessionKey, + context, + timestamp: new Date(), + messages: [], + }; +} diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts new file mode 100644 index 000000000..992df6328 --- /dev/null +++ b/src/hooks/llm-slug-generator.ts @@ -0,0 +1,80 @@ +/** + * LLM-based slug generator for session memory filenames + */ + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runEmbeddedPiAgent } from '../agents/pi-embedded.js'; +import type { ClawdbotConfig } from '../config/config.js'; +import { resolveDefaultAgentId, resolveAgentWorkspaceDir, resolveAgentDir } from '../agents/agent-scope.js'; + +/** + * Generate a short 1-2 word filename slug from session content using LLM + */ +export async function generateSlugViaLLM(params: { + sessionContent: string; + cfg: ClawdbotConfig; +}): Promise { + let tempSessionFile: string | null = null; + + try { + const agentId = resolveDefaultAgentId(params.cfg); + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); + const agentDir = resolveAgentDir(params.cfg, agentId); + + // Create a temporary session file for this one-off LLM call + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'clawdbot-slug-')); + tempSessionFile = path.join(tempDir, 'session.jsonl'); + + const prompt = `Based on this conversation, generate a short 1-2 word filename slug (lowercase, hyphen-separated, no file extension). + +Conversation summary: +${params.sessionContent.slice(0, 2000)} + +Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", "bug-fix"`; + + const result = await runEmbeddedPiAgent({ + sessionId: `slug-generator-${Date.now()}`, + sessionKey: 'temp:slug-generator', + sessionFile: tempSessionFile, + workspaceDir, + agentDir, + config: params.cfg, + prompt, + timeoutMs: 15_000, // 15 second timeout + runId: `slug-gen-${Date.now()}`, + }); + + // Extract text from payloads + if (result.payloads && result.payloads.length > 0) { + const text = result.payloads[0]?.text; + if (text) { + // Clean up the response - extract just the slug + const slug = text + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 30); // Max 30 chars + + return slug || null; + } + } + + return null; + } catch (err) { + console.error('[llm-slug-generator] Failed to generate slug:', err); + return null; + } finally { + // Clean up temporary session file + if (tempSessionFile) { + try { + await fs.rm(path.dirname(tempSessionFile), { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } +} diff --git a/src/hooks/loader.test.ts b/src/hooks/loader.test.ts new file mode 100644 index 000000000..7383d6183 --- /dev/null +++ b/src/hooks/loader.test.ts @@ -0,0 +1,271 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadInternalHooks } from './loader.js'; +import { clearInternalHooks, getRegisteredEventKeys, triggerInternalHook, createInternalHookEvent } from './internal-hooks.js'; +import type { ClawdbotConfig } from '../config/config.js'; + +describe('loader', () => { + let tmpDir: string; + let originalBundledDir: string | undefined; + + beforeEach(async () => { + clearInternalHooks(); + // Create a temp directory for test modules + tmpDir = path.join(os.tmpdir(), `clawdbot-test-${Date.now()}`); + await fs.mkdir(tmpDir, { recursive: true }); + + // Disable bundled hooks during tests by setting env var to non-existent directory + originalBundledDir = process.env.CLAWDBOT_BUNDLED_HOOKS_DIR; + process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = '/nonexistent/bundled/hooks'; + }); + + afterEach(async () => { + clearInternalHooks(); + // Restore original env var + if (originalBundledDir === undefined) { + delete process.env.CLAWDBOT_BUNDLED_HOOKS_DIR; + } else { + process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = originalBundledDir; + } + // Clean up temp directory + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('loadInternalHooks', () => { + it('should return 0 when internal hooks are not enabled', async () => { + const cfg: ClawdbotConfig = { + hooks: { + internal: { + enabled: false, + }, + }, + }; + + const count = await loadInternalHooks(cfg, tmpDir); + expect(count).toBe(0); + }); + + it('should return 0 when hooks config is missing', async () => { + const cfg: ClawdbotConfig = {}; + const count = await loadInternalHooks(cfg, tmpDir); + expect(count).toBe(0); + }); + + it('should load a handler from a module', async () => { + // Create a test handler module + const handlerPath = path.join(tmpDir, 'test-handler.js'); + const handlerCode = ` + export default async function(event) { + // Test handler + } + `; + await fs.writeFile(handlerPath, handlerCode, 'utf-8'); + + const cfg: ClawdbotConfig = { + hooks: { + internal: { + enabled: true, + handlers: [ + { + event: 'command:new', + module: handlerPath, + }, + ], + }, + }, + }; + + const count = await loadInternalHooks(cfg, tmpDir); + expect(count).toBe(1); + + const keys = getRegisteredEventKeys(); + expect(keys).toContain('command:new'); + }); + + it('should load multiple handlers', async () => { + // Create test handler modules + const handler1Path = path.join(tmpDir, 'handler1.js'); + const handler2Path = path.join(tmpDir, 'handler2.js'); + + await fs.writeFile(handler1Path, 'export default async function() {}', 'utf-8'); + await fs.writeFile(handler2Path, 'export default async function() {}', 'utf-8'); + + const cfg: ClawdbotConfig = { + hooks: { + internal: { + enabled: true, + handlers: [ + { event: 'command:new', module: handler1Path }, + { event: 'command:stop', module: handler2Path }, + ], + }, + }, + }; + + const count = await loadInternalHooks(cfg, tmpDir); + expect(count).toBe(2); + + const keys = getRegisteredEventKeys(); + expect(keys).toContain('command:new'); + expect(keys).toContain('command:stop'); + }); + + it('should support named exports', async () => { + // Create a handler module with named export + const handlerPath = path.join(tmpDir, 'named-export.js'); + const handlerCode = ` + export const myHandler = async function(event) { + // Named export handler + } + `; + await fs.writeFile(handlerPath, handlerCode, 'utf-8'); + + const cfg: ClawdbotConfig = { + hooks: { + internal: { + enabled: true, + handlers: [ + { + event: 'command:new', + module: handlerPath, + export: 'myHandler', + }, + ], + }, + }, + }; + + const count = await loadInternalHooks(cfg, tmpDir); + expect(count).toBe(1); + }); + + it('should handle module loading errors gracefully', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const cfg: ClawdbotConfig = { + hooks: { + internal: { + enabled: true, + handlers: [ + { + event: 'command:new', + module: '/nonexistent/path/handler.js', + }, + ], + }, + }, + }; + + const count = await loadInternalHooks(cfg, tmpDir); + expect(count).toBe(0); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('Failed to load internal hook handler'), + expect.any(String) + ); + + consoleError.mockRestore(); + }); + + it('should handle non-function exports', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Create a module with a non-function export + const handlerPath = path.join(tmpDir, 'bad-export.js'); + await fs.writeFile(handlerPath, 'export default "not a function";', 'utf-8'); + + const cfg: ClawdbotConfig = { + hooks: { + internal: { + enabled: true, + handlers: [ + { + event: 'command:new', + module: handlerPath, + }, + ], + }, + }, + }; + + const count = await loadInternalHooks(cfg, tmpDir); + expect(count).toBe(0); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('is not a function') + ); + + consoleError.mockRestore(); + }); + + it('should handle relative paths', async () => { + // Create a handler module + const handlerPath = path.join(tmpDir, 'relative-handler.js'); + await fs.writeFile(handlerPath, 'export default async function() {}', 'utf-8'); + + // Get relative path from cwd + const relativePath = path.relative(process.cwd(), handlerPath); + + const cfg: ClawdbotConfig = { + hooks: { + internal: { + enabled: true, + handlers: [ + { + event: 'command:new', + module: relativePath, + }, + ], + }, + }, + }; + + const count = await loadInternalHooks(cfg, tmpDir); + expect(count).toBe(1); + }); + + it('should actually call the loaded handler', async () => { + // Create a handler that we can verify was called + const handlerPath = path.join(tmpDir, 'callable-handler.js'); + const handlerCode = ` + let callCount = 0; + export default async function(event) { + callCount++; + } + export function getCallCount() { + return callCount; + } + `; + await fs.writeFile(handlerPath, handlerCode, 'utf-8'); + + const cfg: ClawdbotConfig = { + hooks: { + internal: { + enabled: true, + handlers: [ + { + event: 'command:new', + module: handlerPath, + }, + ], + }, + }, + }; + + await loadInternalHooks(cfg, tmpDir); + + // Trigger the hook + const event = createInternalHookEvent('command', 'new', 'test-session'); + await triggerInternalHook(event); + + // The handler should have been called, but we can't directly verify + // the call count from this context without more complex test infrastructure + // This test mainly verifies that loading and triggering doesn't crash + expect(getRegisteredEventKeys()).toContain('command:new'); + }); + }); +}); diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts new file mode 100644 index 000000000..70744fa48 --- /dev/null +++ b/src/hooks/loader.ts @@ -0,0 +1,152 @@ +/** + * Dynamic loader for internal hook handlers + * + * Loads hook handlers from external modules based on configuration + * and from directory-based discovery (bundled, managed, workspace) + */ + +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; +import { registerInternalHook } from './internal-hooks.js'; +import type { ClawdbotConfig } from '../config/config.js'; +import type { InternalHookHandler } from './internal-hooks.js'; +import { loadWorkspaceHookEntries } from './workspace.js'; +import { resolveHookConfig } from './config.js'; +import { shouldIncludeHook } from './config.js'; + +/** + * Load and register all internal hook handlers + * + * Loads hooks from both: + * 1. Directory-based discovery (bundled, managed, workspace) + * 2. Legacy config handlers (backwards compatibility) + * + * @param cfg - Clawdbot configuration + * @param workspaceDir - Workspace directory for hook discovery + * @returns Number of handlers successfully loaded + * + * @example + * ```ts + * const config = await loadConfig(); + * const workspaceDir = resolveAgentWorkspaceDir(config, agentId); + * const count = await loadInternalHooks(config, workspaceDir); + * console.log(`Loaded ${count} internal hook handlers`); + * ``` + */ +export async function loadInternalHooks( + cfg: ClawdbotConfig, + workspaceDir: string, +): Promise { + // Check if internal hooks are enabled + if (!cfg.hooks?.internal?.enabled) { + return 0; + } + + let loadedCount = 0; + + // 1. Load hooks from directories (new system) + try { + const hookEntries = loadWorkspaceHookEntries(workspaceDir, { config: cfg }); + + // Filter by eligibility + const eligible = hookEntries.filter((entry) => + shouldIncludeHook({ entry, config: cfg }), + ); + + for (const entry of eligible) { + const hookConfig = resolveHookConfig(cfg, entry.hook.name); + + // Skip if explicitly disabled in config + if (hookConfig?.enabled === false) { + continue; + } + + try { + // Import handler module with cache-busting + const url = pathToFileURL(entry.hook.handlerPath).href; + const cacheBustedUrl = `${url}?t=${Date.now()}`; + const mod = (await import(cacheBustedUrl)) as Record; + + // Get handler function (default or named export) + const exportName = entry.clawdbot?.export ?? 'default'; + const handler = mod[exportName]; + + if (typeof handler !== 'function') { + console.error( + `Internal hook error: Handler '${exportName}' from ${entry.hook.name} is not a function`, + ); + continue; + } + + // Register for all events listed in metadata + const events = entry.clawdbot?.events ?? []; + if (events.length === 0) { + console.warn( + `Internal hook warning: Hook '${entry.hook.name}' has no events defined in metadata`, + ); + continue; + } + + for (const event of events) { + registerInternalHook(event, handler as InternalHookHandler); + } + + console.log( + `Registered internal hook: ${entry.hook.name} -> ${events.join(', ')}${exportName !== 'default' ? ` (export: ${exportName})` : ''}`, + ); + loadedCount++; + } catch (err) { + console.error( + `Failed to load internal hook ${entry.hook.name}:`, + err instanceof Error ? err.message : String(err), + ); + } + } + } catch (err) { + console.error( + 'Failed to load directory-based hooks:', + err instanceof Error ? err.message : String(err), + ); + } + + // 2. Load legacy config handlers (backwards compatibility) + const handlers = cfg.hooks.internal.handlers ?? []; + for (const handlerConfig of handlers) { + try { + // Resolve module path (absolute or relative to cwd) + const modulePath = path.isAbsolute(handlerConfig.module) + ? handlerConfig.module + : path.join(process.cwd(), handlerConfig.module); + + // Import the module with cache-busting to ensure fresh reload + const url = pathToFileURL(modulePath).href; + const cacheBustedUrl = `${url}?t=${Date.now()}`; + const mod = (await import(cacheBustedUrl)) as Record; + + // Get the handler function + const exportName = handlerConfig.export ?? 'default'; + const handler = mod[exportName]; + + if (typeof handler !== 'function') { + console.error( + `Internal hook error: Handler '${exportName}' from ${modulePath} is not a function`, + ); + continue; + } + + // Register the handler + registerInternalHook(handlerConfig.event, handler as InternalHookHandler); + console.log( + `Registered internal hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== 'default' ? `#${exportName}` : ''}`, + ); + loadedCount++; + } catch (err) { + console.error( + `Failed to load internal hook handler from ${handlerConfig.module}:`, + err instanceof Error ? err.message : String(err), + ); + } + } + + return loadedCount; +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 000000000..b4035718b --- /dev/null +++ b/src/hooks/types.ts @@ -0,0 +1,64 @@ +export type HookInstallSpec = { + id?: string; + kind: "bundled" | "npm" | "git"; + label?: string; + package?: string; + repository?: string; + bins?: string[]; +}; + +export type ClawdbotHookMetadata = { + always?: boolean; + hookKey?: string; + emoji?: string; + homepage?: string; + /** Events this hook handles (e.g., ["command:new", "session:start"]) */ + events: string[]; + /** Optional export name (default: "default") */ + export?: string; + os?: string[]; + requires?: { + bins?: string[]; + anyBins?: string[]; + env?: string[]; + config?: string[]; + }; + install?: HookInstallSpec[]; +}; + +export type HookInvocationPolicy = { + enabled: boolean; +}; + +export type ParsedHookFrontmatter = Record; + +export type Hook = { + name: string; + description: string; + source: "clawdbot-bundled" | "clawdbot-managed" | "clawdbot-workspace"; + filePath: string; // Path to HOOK.md + baseDir: string; // Directory containing hook + handlerPath: string; // Path to handler module (handler.ts/js) +}; + +export type HookEntry = { + hook: Hook; + frontmatter: ParsedHookFrontmatter; + clawdbot?: ClawdbotHookMetadata; + invocation?: HookInvocationPolicy; +}; + +export type HookEligibilityContext = { + remote?: { + platforms: string[]; + hasBin: (bin: string) => boolean; + hasAnyBin: (bins: string[]) => boolean; + note?: string; + }; +}; + +export type HookSnapshot = { + hooks: Array<{ name: string; events: string[] }>; + resolvedHooks?: Hook[]; + version?: number; +}; diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts new file mode 100644 index 000000000..fcbee602a --- /dev/null +++ b/src/hooks/workspace.ts @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { resolveBundledHooksDir } from "./bundled-dir.js"; +import { shouldIncludeHook } from "./config.js"; +import { + parseFrontmatter, + resolveClawdbotMetadata, + resolveHookInvocationPolicy, +} from "./frontmatter.js"; +import type { + Hook, + HookEligibilityContext, + HookEntry, + HookSnapshot, + ParsedHookFrontmatter, +} from "./types.js"; + +function filterHookEntries( + entries: HookEntry[], + config?: ClawdbotConfig, + eligibility?: HookEligibilityContext, +): HookEntry[] { + return entries.filter((entry) => shouldIncludeHook({ entry, config, eligibility })); +} + +/** + * Scan a directory for hooks (subdirectories containing HOOK.md) + */ +function loadHooksFromDir(params: { dir: string; source: string }): Hook[] { + const { dir, source } = params; + + // Check if directory exists + if (!fs.existsSync(dir)) return []; + + const stat = fs.statSync(dir); + if (!stat.isDirectory()) return []; + + const hooks: Hook[] = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const hookDir = path.join(dir, entry.name); + const hookMdPath = path.join(hookDir, "HOOK.md"); + + // Skip if no HOOK.md file + if (!fs.existsSync(hookMdPath)) continue; + + try { + // Read HOOK.md to extract name and description + const content = fs.readFileSync(hookMdPath, "utf-8"); + const frontmatter = parseFrontmatter(content); + + const name = frontmatter.name || entry.name; + const description = frontmatter.description || ""; + + // Locate handler file (handler.ts, handler.js, index.ts, index.js) + const handlerCandidates = [ + "handler.ts", + "handler.js", + "index.ts", + "index.js", + ]; + + let handlerPath: string | undefined; + for (const candidate of handlerCandidates) { + const candidatePath = path.join(hookDir, candidate); + if (fs.existsSync(candidatePath)) { + handlerPath = candidatePath; + break; + } + } + + // Skip if no handler file found + if (!handlerPath) { + console.warn(`[hooks] Hook "${name}" has HOOK.md but no handler file in ${hookDir}`); + continue; + } + + hooks.push({ + name, + description, + source: source as Hook["source"], + filePath: hookMdPath, + baseDir: hookDir, + handlerPath, + }); + } catch (err) { + console.warn(`[hooks] Failed to load hook from ${hookDir}:`, err); + continue; + } + } + + return hooks; +} + +function loadHookEntries( + workspaceDir: string, + opts?: { + config?: ClawdbotConfig; + managedHooksDir?: string; + bundledHooksDir?: string; + }, +): HookEntry[] { + const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks"); + const workspaceHooksDir = path.join(workspaceDir, "hooks"); + const bundledHooksDir = opts?.bundledHooksDir ?? resolveBundledHooksDir(); + const extraDirsRaw = opts?.config?.hooks?.internal?.load?.extraDirs ?? []; + const extraDirs = extraDirsRaw + .map((d) => (typeof d === "string" ? d.trim() : "")) + .filter(Boolean); + + const bundledHooks = bundledHooksDir + ? loadHooksFromDir({ + dir: bundledHooksDir, + source: "clawdbot-bundled", + }) + : []; + const extraHooks = extraDirs.flatMap((dir) => { + const resolved = resolveUserPath(dir); + return loadHooksFromDir({ + dir: resolved, + source: "clawdbot-workspace", // Extra dirs treated as workspace + }); + }); + const managedHooks = loadHooksFromDir({ + dir: managedHooksDir, + source: "clawdbot-managed", + }); + const workspaceHooks = loadHooksFromDir({ + dir: workspaceHooksDir, + source: "clawdbot-workspace", + }); + + const merged = new Map(); + // Precedence: extra < bundled < managed < workspace (workspace wins) + for (const hook of extraHooks) merged.set(hook.name, hook); + for (const hook of bundledHooks) merged.set(hook.name, hook); + for (const hook of managedHooks) merged.set(hook.name, hook); + for (const hook of workspaceHooks) merged.set(hook.name, hook); + + const hookEntries: HookEntry[] = Array.from(merged.values()).map((hook) => { + let frontmatter: ParsedHookFrontmatter = {}; + try { + const raw = fs.readFileSync(hook.filePath, "utf-8"); + frontmatter = parseFrontmatter(raw); + } catch { + // ignore malformed hooks + } + return { + hook, + frontmatter, + clawdbot: resolveClawdbotMetadata(frontmatter), + invocation: resolveHookInvocationPolicy(frontmatter), + }; + }); + return hookEntries; +} + +export function buildWorkspaceHookSnapshot( + workspaceDir: string, + opts?: { + config?: ClawdbotConfig; + managedHooksDir?: string; + bundledHooksDir?: string; + entries?: HookEntry[]; + eligibility?: HookEligibilityContext; + snapshotVersion?: number; + }, +): HookSnapshot { + const hookEntries = opts?.entries ?? loadHookEntries(workspaceDir, opts); + const eligible = filterHookEntries(hookEntries, opts?.config, opts?.eligibility); + + return { + hooks: eligible.map((entry) => ({ + name: entry.hook.name, + events: entry.clawdbot?.events ?? [], + })), + resolvedHooks: eligible.map((entry) => entry.hook), + version: opts?.snapshotVersion, + }; +} + +export function loadWorkspaceHookEntries( + workspaceDir: string, + opts?: { + config?: ClawdbotConfig; + managedHooksDir?: string; + bundledHooksDir?: string; + }, +): HookEntry[] { + return loadHookEntries(workspaceDir, opts); +} diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 64ee88804..eaf5d504b 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -19,6 +19,7 @@ import { } from "../commands/onboard-helpers.js"; import { promptRemoteGatewayConfig } from "../commands/onboard-remote.js"; import { setupSkills } from "../commands/onboard-skills.js"; +import { setupInternalHooks } from "../commands/onboard-hooks.js"; import type { GatewayAuthChoice, OnboardMode, @@ -403,6 +404,10 @@ export async function runOnboardingWizard( } else { nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter); } + + // Setup internal hooks (session memory on /new) + nextConfig = await setupInternalHooks(nextConfig, runtime, prompter); + nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig);