diff --git a/examples/claude-code-hooks/Hooks.md b/examples/claude-code-hooks/Hooks.md new file mode 100644 index 00000000..98cd8a64 --- /dev/null +++ b/examples/claude-code-hooks/Hooks.md @@ -0,0 +1,27 @@ +# Claude Code Hooks — Quick Reference + +## Hook Events & Unique Params + +| Hook | Key unique params | +|------|------------------| +| `SessionStart` | `source`, `model`, `agent_type` | +| `UserPromptSubmit` | `prompt` | +| `PreToolUse` | `tool_name`, `tool_use_id`, `tool_input` | +| `PermissionRequest` | `tool_name`, `tool_input`, `permission_suggestions` | +| `PostToolUse` | `tool_name`, `tool_input`, `tool_response`, `tool_use_id` | +| `PostToolUseFailure` | `tool_name`, `tool_input`, `error`, `is_interrupt` | +| `Notification` | `message`, `title`, `notification_type` | +| `SubagentStart` | `agent_id`, `agent_type` | +| `SubagentStop` | `agent_id`, `agent_type`, `agent_transcript_path`, `last_assistant_message`, `stop_hook_active` | +| `Stop` | `last_assistant_message`, `stop_hook_active` | +| `TeammateIdle` | `teammate_name`, `team_name` | +| `TaskCompleted` | `task_id`, `task_subject`, `task_description`, `teammate_name`, `team_name` | +| `ConfigChange` | `source`, `file_path` | +| `WorktreeCreate` | `name` | +| `WorktreeRemove` | `worktree_path` | +| `PreCompact` | `trigger`, `custom_instructions` | +| `SessionEnd` | `reason` | + +## Common Params (all hooks) + +`session_id`, `transcript_path`, `cwd`, `permission_mode`, `hook_event_name` diff --git a/examples/claude-code-hooks/README.md b/examples/claude-code-hooks/README.md new file mode 100644 index 00000000..ef19b289 --- /dev/null +++ b/examples/claude-code-hooks/README.md @@ -0,0 +1,159 @@ +# Claude Code × OpenViking Memory Hooks + +Auto-extract memories from [Claude Code](https://docs.anthropic.com/en/docs/claude-code) sessions into OpenViking using [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks). + +## How It Works + +Three hooks capture conversation transcripts at strategic lifecycle points and pipe them into OpenViking's memory system: + +| Hook | Trigger | What it does | +|------|---------|--------------| +| `SubagentStop` | A subagent finishes | Extracts the subagent's transcript and saves it as a memory | +| `PreCompact` | Before context compaction | Snapshots the conversation before details are summarized away | +| `SessionEnd` | Session terminates | Archives the full session via structured `ov session` workflow | + +``` +Claude Code Session + │ + ├── SubagentStop ──→ ov add-memory + │ + ├── PreCompact ────→ ov add-memory + │ + └── SessionEnd ────→ ov session new + → ov session add-message × N + → ov session commit +``` + +`SubagentStop` and `PreCompact` use `ov add-memory`, which accepts a batch of messages in a single call — ideal when you already have the full transcript in hand and want to minimize client-server round trips. + +`SessionEnd` uses `ov session add-message` per message, then commits at the end. This is intentionally kept as a reference pattern: if you integrate hooks that fire incrementally (e.g. `UserPromptSubmit` or `Stop`), you'd call `ov session add-message` after each turn to build up the session over time — without committing on the spot — and only commit when the session is complete. + +All hooks run **async** (non-blocking) and the slow LLM extraction step runs in a `nohup` background process so it never delays Claude's responses. + +## How Claude Code Hooks Work + +When a hook event fires, Claude Code runs the registered shell command and passes event data as **JSON via stdin**. Every hook receives a common base payload plus event-specific fields: + +```json +{ + "hook_event_name": "SubagentStop", + "session_id": "abc123", + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/Users/you/project", + "permission_mode": "default", + "agent_type": "general-purpose", + "agent_transcript_path": "/path/to/subagent.jsonl" +} +``` + +Hook scripts read this with `INPUT=$(cat)` and parse fields with `jq`. See [Hooks.md](./Hooks.md) for all events and their unique params. + +Exit codes control behavior for blocking hooks — exit `0` to allow, `1` to block, `2` to request user confirmation. Hooks registered with `"async": true` (like these) run in the background and their exit code is ignored. + +## Prerequisites + +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed +- [OpenViking CLI](../../README.md) configured (`~/.openviking/ovcli.conf`) +- `jq` installed (`brew install jq` / `apt install jq`) +- `python3` available (used for unicode-safe content truncation in logs) + +## Setup + +### 1. Copy hooks + +```bash +mkdir -p ~/.claude/hooks +cp hooks/*.sh ~/.claude/hooks/ +chmod +x ~/.claude/hooks/*.sh +``` + +### 2. Register in Claude Code settings + +Add to `~/.claude/settings.json`: + +```json +{ + "hooks": { + "SubagentStop": [ + { + "hooks": [ + { "type": "command", "command": "$HOME/.claude/hooks/ov-memory-subagent-stop.sh", "async": true } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { "type": "command", "command": "$HOME/.claude/hooks/ov-memory-pre-compact.sh", "async": true } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { "type": "command", "command": "$HOME/.claude/hooks/ov-memory-session-end.sh", "async": true } + ] + } + ] + } +} +``` + +### 3. Verify + +```bash +claude +/hooks +``` + +## Debugging + +Set `OV_HOOK_DEBUG=1` to enable logging to `/tmp/ov.log`: + +```bash +export OV_HOOK_DEBUG=1 +claude +``` + +Then watch the log in another terminal: + +```bash +tail -f /tmp/ov.log +``` + +Log output uses color — gray timestamps, purple `ov` commands — and message content is truncated to 120 characters for readability. The actual data sent to OpenViking is always the full untruncated content. + +Example output: + +``` +[2026-02-22 10:00:01] SubagentStop: queued 4 msgs from general-purpose +2026-02-22 10:00:01 ov add-memory '[{"role":"user","content":"You are a senior backend engineer..."},...]' +[2026-02-22 10:00:03] SubagentStop: saved 4 msgs from general-purpose + +[2026-02-22 10:30:00] SessionEnd: queued commit 56 msgs (ov=abc123, reason=other) +2026-02-22 10:30:00 ov session new -o json -c +2026-02-22 10:30:00 ov session add-message --role 'user' --content 'what hooks do you have now' abc123 +2026-02-22 10:30:00 ov session commit abc123 +``` + +## Verifying Memories + +After hooks fire, check what was extracted: + +```bash +ov search "what did I work on today" +``` + +## Extending to Other Hooks + +See [Hooks.md](./Hooks.md) for a quick reference of all 17 Claude Code hook events and the params each provides via stdin. Useful starting points for extending this example: + +- `UserPromptSubmit` — add a message to an open `ov session` on every user turn +- `Stop` — commit the session when Claude finishes responding (pairs with `UserPromptSubmit`) +- `PostToolUse` — capture tool results alongside conversation turns + +## Customization + +- **Skip short sessions**: Add a `$COUNT` threshold check before running `ov` commands +- **Change log file**: Edit `LOG=` in each script (only matters when `OV_HOOK_DEBUG=1`) +- **Add project tags**: Pass metadata via `ov session` flags diff --git a/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh b/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh new file mode 100755 index 00000000..89665745 --- /dev/null +++ b/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# ov-memory-pre-compact.sh +# Hook: PreCompact +# +# WHAT: Snapshot the current conversation into OpenViking before context is compacted. +# +# PSEUDOCODE: +# read stdin → transcript_path, trigger (manual|auto) +# if no transcript → exit +# parse transcript → keep user/assistant text messages only +# if no messages → exit +# if trigger=manual and last msg is not assistant → sleep + re-read (race condition) +# write messages to tmpfile +# log: ov add-memory (content truncated) +# background: ov add-memory → log result → rm tmpfile +# +# SPECIAL CASES: +# trigger=auto — fires BEFORE Claude responds (context full); last role=user is +# expected, not a race condition — no retry needed +# trigger=manual — fires AFTER Claude's last response; last role should be assistant; +# if not, retry once after brief sleep (race condition fix) +# nohup background — compaction may proceed before ov finishes; that's fine + +LOG=/tmp/ov.log + +_log() { [ "$OV_HOOK_DEBUG" = "1" ] && echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"; } +_logcmd() { [ "$OV_HOOK_DEBUG" = "1" ] && printf "\033[90m%s\033[0m \033[35m%s\033[0m\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; } +_trunc() { printf '%s' "$1" | python3 -c "import sys; s=sys.stdin.read(); print(s[:120]+('...' if len(s)>120 else ''), end='')"; } + +INPUT=$(cat) +TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty') +TRIGGER=$(echo "$INPUT" | jq -r '.trigger // "auto"') + +if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then + _log "PreCompact: no transcript (trigger=$TRIGGER)" + exit 0 +fi + +MESSAGES=$(jq -sc ' + map(select(.type == "user" or .type == "assistant")) + | map({ + role: .message.role, + content: ( + .message.content + | if type == "string" then . + elif type == "array" then (map(select(.type == "text") | .text) | join("\n")) + else "" + end + ) + }) + | map(select(.content != "" and .content != null)) +' "$TRANSCRIPT") + +COUNT=$(echo "$MESSAGES" | jq 'length') + +if [ "$COUNT" -eq 0 ]; then + _log "PreCompact: nothing to snapshot (trigger=$TRIGGER)" + exit 0 +fi + +# Race condition: for manual compaction, PreCompact fires after Claude's last response, +# but the assistant message may not be flushed yet. Retry once after a brief wait. +# (For auto compaction, last role=user is expected — Claude hasn't responded yet.) +if [ "$TRIGGER" = "manual" ]; then + LAST_ROLE=$(echo "$MESSAGES" | jq -r '.[-1].role // empty') + if [ "$LAST_ROLE" != "assistant" ]; then + sleep 0.5 + MESSAGES=$(jq -sc ' + map(select(.type == "user" or .type == "assistant")) + | map({ + role: .message.role, + content: ( + .message.content + | if type == "string" then . + elif type == "array" then (map(select(.type == "text") | .text) | join("\n")) + else "" + end + ) + }) + | map(select(.content != "" and .content != null)) + ' "$TRANSCRIPT") + COUNT=$(echo "$MESSAGES" | jq 'length') + fi +fi + +TMPFILE=$(mktemp /tmp/ov-hook-XXXXXX.json) +echo "$MESSAGES" > "$TMPFILE" + +_logcmd "ov add-memory '$(jq -c 'map(.content = (.content | if length > 120 then .[0:120] + "..." else . end))' "$TMPFILE")'" +nohup bash -c " + ov add-memory \"\$(cat $TMPFILE)\" >> '$LOG' 2>&1 + [ \"\$OV_HOOK_DEBUG\" = '1' ] && echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] PreCompact: snapshotted $COUNT msgs (trigger=$TRIGGER)\" >> '$LOG' + rm -f '$TMPFILE' +" > /dev/null 2>&1 & + +_log "PreCompact: queued $COUNT msgs (trigger=$TRIGGER)" diff --git a/examples/claude-code-hooks/hooks/ov-memory-session-end.sh b/examples/claude-code-hooks/hooks/ov-memory-session-end.sh new file mode 100755 index 00000000..c87e6112 --- /dev/null +++ b/examples/claude-code-hooks/hooks/ov-memory-session-end.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# ov-memory-session-end.sh +# Hook: SessionEnd +# +# WHAT: Archive the full session conversation into OpenViking when a session closes. +# +# PSEUDOCODE: +# read stdin → transcript_path, reason +# if no transcript → exit +# parse transcript → keep user/assistant text messages only +# if no messages → exit +# if last msg is not assistant → sleep + re-read (race condition) +# ov session new → get session_id +# if no session_id → exit (ov server likely down) +# for each message → log + ov session add-message (content truncated in log) +# log: ov session commit +# background: ov session commit → triggers LLM memory extraction → log result +# +# SPECIAL CASES: +# reason=clear — user ran /clear; session wiped intentionally +# reason=logout — user logged out of Claude Code +# reason=prompt_input_exit — Ctrl+C or natural exit +# race condition — SessionEnd fires before the final assistant response is +# flushed; retry once after brief sleep +# failed session new — ov server is down; skip gracefully +# nohup background — session process exits before LLM extraction finishes + +LOG=/tmp/ov.log + +_log() { [ "$OV_HOOK_DEBUG" = "1" ] && echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"; } +_logcmd() { [ "$OV_HOOK_DEBUG" = "1" ] && printf "\033[90m%s\033[0m \033[35m%s\033[0m\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; } +_trunc() { printf '%s' "$1" | python3 -c "import sys; s=sys.stdin.read(); print(s[:120]+('...' if len(s)>120 else ''), end='')"; } + +INPUT=$(cat) +TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty') +REASON=$(echo "$INPUT" | jq -r '.reason // "other"') + +if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then + _log "SessionEnd: no transcript (reason=$REASON)" + exit 0 +fi + +MESSAGES=$(jq -sc ' + map(select(.type == "user" or .type == "assistant")) + | map({ + role: .message.role, + content: ( + .message.content + | if type == "string" then . + elif type == "array" then (map(select(.type == "text") | .text) | join("\n")) + else "" + end + ) + }) + | map(select(.content != "" and .content != null)) +' "$TRANSCRIPT") + +COUNT=$(echo "$MESSAGES" | jq 'length') + +if [ "$COUNT" -eq 0 ]; then + _log "SessionEnd: no messages (reason=$REASON)" + exit 0 +fi + +# Race condition: SessionEnd fires before the final assistant response is flushed to disk. +# Retry once after a brief wait if the last captured entry is not from the assistant. +LAST_ROLE=$(echo "$MESSAGES" | jq -r '.[-1].role // empty') +if [ "$LAST_ROLE" != "assistant" ]; then + sleep 0.5 + MESSAGES=$(jq -sc ' + map(select(.type == "user" or .type == "assistant")) + | map({ + role: .message.role, + content: ( + .message.content + | if type == "string" then . + elif type == "array" then (map(select(.type == "text") | .text) | join("\n")) + else "" + end + ) + }) + | map(select(.content != "" and .content != null)) + ' "$TRANSCRIPT") + COUNT=$(echo "$MESSAGES" | jq 'length') +fi + +_logcmd "ov session new -o json -c" +OV_RAW=$(ov session new -o json -c 2>>"$LOG") +OV_SESSION_ID=$(echo "$OV_RAW" | jq -r '.result.session_id // empty') + +if [ -z "$OV_SESSION_ID" ]; then + _log "SessionEnd: failed to create ov session" + exit 0 +fi + +while IFS= read -r msg; do + ROLE=$(echo "$msg" | jq -r '.role') + CONTENT=$(echo "$msg" | jq -r '.content') + _logcmd "ov session add-message --role '$ROLE' --content '$(_trunc "$CONTENT")' $OV_SESSION_ID" + ov session add-message --role "$ROLE" --content "$CONTENT" "$OV_SESSION_ID" > /dev/null 2>&1 +done < <(echo "$MESSAGES" | jq -c '.[]') + +_logcmd "ov session commit $OV_SESSION_ID" +nohup bash -c " + ov session commit '$OV_SESSION_ID' >> '$LOG' 2>&1 + [ \"\$OV_HOOK_DEBUG\" = '1' ] && echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd: committed $COUNT msgs (ov=$OV_SESSION_ID, reason=$REASON)\" >> '$LOG' +" > /dev/null 2>&1 & + +_log "SessionEnd: queued commit $COUNT msgs (ov=$OV_SESSION_ID, reason=$REASON)" diff --git a/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh b/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh new file mode 100755 index 00000000..adc663cc --- /dev/null +++ b/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# ov-memory-subagent-stop.sh +# Hook: SubagentStop +# +# WHAT: Save a finished subagent's conversation into OpenViking memory. +# +# PSEUDOCODE: +# read stdin → agent_type, transcript_path, last_assistant_message +# if no transcript → exit +# parse transcript → keep user/assistant text messages only +# if no messages → exit +# if last message is not assistant → append last_assistant_message from payload +# write messages to tmpfile +# log: ov add-memory (content truncated) +# background: ov add-memory → log result → rm tmpfile +# +# SPECIAL CASES: +# no transcript — subagent exited before writing output (e.g. killed early) +# empty messages — transcript exists but has no readable text blocks +# race condition — SubagentStop fires before assistant response is flushed to +# agent_transcript_path; fixed by using last_assistant_message +# from the hook payload as a guaranteed fallback +# nohup background — ov call survives if parent exits before it completes + +LOG=/tmp/ov.log + +_log() { [ "$OV_HOOK_DEBUG" = "1" ] && echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"; } +_logcmd() { [ "$OV_HOOK_DEBUG" = "1" ] && printf "\033[90m%s\033[0m \033[35m%s\033[0m\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; } +_trunc() { printf '%s' "$1" | python3 -c "import sys; s=sys.stdin.read(); print(s[:120]+('...' if len(s)>120 else ''), end='')"; } + +INPUT=$(cat) +AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // "unknown"') +AGENT_TRANSCRIPT=$(echo "$INPUT" | jq -r '.agent_transcript_path // empty') +LAST_ASSISTANT_TEXT=$(echo "$INPUT" | jq -r '.last_assistant_message // empty') + +if [ -z "$AGENT_TRANSCRIPT" ] || [ ! -f "$AGENT_TRANSCRIPT" ]; then + _log "SubagentStop: no transcript for $AGENT_TYPE" + exit 0 +fi + +MESSAGES=$(jq -sc ' + map(select(.type == "user" or .type == "assistant")) + | map({ + role: .message.role, + content: ( + .message.content + | if type == "string" then . + elif type == "array" then (map(select(.type == "text") | .text) | join("\n")) + else "" + end + ) + }) + | map(select(.content != "" and .content != null)) +' "$AGENT_TRANSCRIPT") + +COUNT=$(echo "$MESSAGES" | jq 'length') + +if [ "$COUNT" -eq 0 ]; then + _log "SubagentStop: no text messages for $AGENT_TYPE" + exit 0 +fi + +# Race condition: SubagentStop fires before the final assistant response is flushed +# to agent_transcript_path. If the last captured entry is not from the assistant, +# append last_assistant_message from the hook payload (always present at hook time). +LAST_ROLE=$(echo "$MESSAGES" | jq -r '.[-1].role // empty') +if [ "$LAST_ROLE" != "assistant" ] && [ -n "$LAST_ASSISTANT_TEXT" ]; then + MESSAGES=$(echo "$MESSAGES" | jq --arg txt "$LAST_ASSISTANT_TEXT" '. + [{role: "assistant", content: $txt}]') + COUNT=$(echo "$MESSAGES" | jq 'length') +fi + +TMPFILE=$(mktemp /tmp/ov-hook-XXXXXX.json) +echo "$MESSAGES" > "$TMPFILE" + +_logcmd "ov add-memory '$(jq -c 'map(.content = (.content | if length > 120 then .[0:120] + "..." else . end))' "$TMPFILE")'" +nohup bash -c " + ov add-memory \"\$(cat $TMPFILE)\" >> '$LOG' 2>&1 + [ \"\$OV_HOOK_DEBUG\" = '1' ] && echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop: saved $COUNT msgs from $AGENT_TYPE\" >> '$LOG' + rm -f '$TMPFILE' +" > /dev/null 2>&1 & + +_log "SubagentStop: queued $COUNT msgs from $AGENT_TYPE"