Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions examples/claude-code-hooks/Hooks.md
Original file line number Diff line number Diff line change
@@ -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`
159 changes: 159 additions & 0 deletions examples/claude-code-hooks/README.md
Original file line number Diff line number Diff line change
@@ -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 <messages>
├── PreCompact ────→ ov add-memory <messages>
└── 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
96 changes: 96 additions & 0 deletions examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh
Original file line number Diff line number Diff line change
@@ -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 <tmpfile> → 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)"
109 changes: 109 additions & 0 deletions examples/claude-code-hooks/hooks/ov-memory-session-end.sh
Original file line number Diff line number Diff line change
@@ -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)"
Loading
Loading