From e8502da5514f54e9a1e51763b888c125748a4fac Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Sun, 22 Feb 2026 16:01:30 +0800 Subject: [PATCH 1/4] feat: add Claude Code memory hooks example Add example showing how to auto-extract memories from Claude Code sessions into OpenViking using Claude Code's hook system. Three hooks capture conversation transcripts at strategic lifecycle points: - SubagentStop: when subagents finish focused work - PreCompact: before context window compaction loses details - SessionEnd: full session archival with structured ov session workflow All hooks run async to avoid blocking Claude Code responses. --- examples/claude-code-hooks/README.md | 168 ++++++++++++++++++ .../hooks/ov-memory-pre-compact.sh | 43 +++++ .../hooks/ov-memory-session-end.sh | 60 +++++++ .../hooks/ov-memory-subagent-stop.sh | 42 +++++ 4 files changed, 313 insertions(+) create mode 100644 examples/claude-code-hooks/README.md create mode 100755 examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh create mode 100755 examples/claude-code-hooks/hooks/ov-memory-session-end.sh create mode 100755 examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh diff --git a/examples/claude-code-hooks/README.md b/examples/claude-code-hooks/README.md new file mode 100644 index 00000000..13895b79 --- /dev/null +++ b/examples/claude-code-hooks/README.md @@ -0,0 +1,168 @@ +# 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 | Why | +|------|---------|-----| +| `SubagentStop` | Subagent finishes | Subagent transcripts are complete and self-contained | +| `PreCompact` | Before context compaction | Last chance to save details before they're summarized away | +| `SessionEnd` | Session terminates | Full conversation is available for structured archival | + +``` +Claude Code Session + │ + ├── SubagentStop ──→ ov add-memory (one-shot) + │ + ├── PreCompact ────→ ov add-memory (one-shot) + │ + └── SessionEnd ────→ ov session new → add-message × N → commit +``` + +## 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`) + +## Setup + +### 1. Copy hooks to Claude Code hooks directory + +```bash +mkdir -p ~/.claude/hooks +cp hooks/*.sh ~/.claude/hooks/ +chmod +x ~/.claude/hooks/*.sh +``` + +### 2. Register hooks in Claude Code settings + +Add the following to `~/.claude/settings.json` (create if it doesn't exist): + +```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 + } + ] + } + ] + } +} +``` + +> **Note:** All hooks use `"async": true` so they don't block Claude's responses. + +### 3. Verify + +```bash +# Start Claude Code and check hooks are loaded +claude +/hooks +``` + +## Hook Details + +### Transcript Format + +Claude Code transcripts are JSONL files. Each line: + +```json +{ + "type": "user", + "message": { + "role": "user", + "content": [{"type": "text", "text": "actual message"}] + } +} +``` + +All three hooks use the same jq pattern to extract user/assistant text turns: + +```bash +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)) +' +``` + +### SubagentStop & PreCompact + +Use `ov add-memory` for one-shot memory extraction — creates a session, adds messages, commits, and extracts memories in a single call. + +### SessionEnd + +Uses the full session workflow for structured archival: + +```bash +ov session new # create session +ov session add-message ... # add each message +ov session commit # archive + extract memories +``` + +This gives OpenViking more context for richer memory extraction. + +## Logs + +All hooks append to `/tmp/ov-hooks.log`: + +``` +[2026-02-22 10:00:00] SubagentStop/memory: saved 12 msgs to ov +[2026-02-22 10:05:00] PreCompact/memory: snapshotted 34 msgs before auto compaction +[2026-02-22 10:30:00] SessionEnd/memory: committed 56 msgs (ov=abc123, reason=other) +``` + +## Verifying Memories + +After hooks fire, search for extracted memories: + +```bash +ov search "what did I work on today" +``` + +## Customization + +- **Filter by session length**: Skip short sessions by checking `$COUNT` threshold +- **Tag memories**: Add project context via `ov session` metadata +- **Change log location**: Edit `LOG=` in each hook script 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..e9e2fbd5 --- /dev/null +++ b/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# ov-memory-pre-compact.sh +# Hook: PreCompact +# Before context compaction, snapshot the conversation into OpenViking memory +# so details aren't lost when the context window is trimmed. + +set -euo pipefail + +INPUT=$(cat) +TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty') +TRIGGER=$(echo "$INPUT" | jq -r '.trigger // "auto"') +LOG=/tmp/ov-hooks.log + +if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: no transcript (trigger=$TRIGGER)" >> "$LOG" + exit 0 +fi + +# Extract user/assistant text turns from JSONL transcript +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 + echo "[$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: nothing to snapshot (trigger=$TRIGGER)" >> "$LOG" + exit 0 +fi + +ov add-memory "$MESSAGES" >> "$LOG" 2>&1 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: snapshotted $COUNT msgs before $TRIGGER compaction" >> "$LOG" 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..07c54a3f --- /dev/null +++ b/examples/claude-code-hooks/hooks/ov-memory-session-end.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# ov-memory-session-end.sh +# Hook: SessionEnd +# On session end, create an OpenViking session, load all conversation messages, +# then commit — which archives and extracts memories automatically. + +set -euo pipefail + +INPUT=$(cat) +TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty') +REASON=$(echo "$INPUT" | jq -r '.reason // "other"') +LOG=/tmp/ov-hooks.log + +if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: no transcript (reason=$REASON)" >> "$LOG" + exit 0 +fi + +# Extract user/assistant text turns from JSONL transcript +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 + echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: no messages to archive (reason=$REASON)" >> "$LOG" + exit 0 +fi + +# Create a new OpenViking session +OV_RAW=$(ov session new -o json -c 2>/dev/null) +OV_SESSION_ID=$(echo "$OV_RAW" | jq -r '.result.session_id // empty') + +if [ -z "$OV_SESSION_ID" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: failed to create ov session" >> "$LOG" + exit 0 +fi + +# Add each message to the OpenViking session +echo "$MESSAGES" | jq -c '.[]' | while IFS= read -r msg; do + ROLE=$(echo "$msg" | jq -r '.role') + CONTENT=$(echo "$msg" | jq -r '.content') + ov session add-message --role "$ROLE" --content "$CONTENT" "$OV_SESSION_ID" > /dev/null 2>&1 +done + +# Commit — archives messages and extracts memories +ov session commit "$OV_SESSION_ID" >> "$LOG" 2>&1 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: committed $COUNT msgs (ov=$OV_SESSION_ID, reason=$REASON)" >> "$LOG" 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..ff1fc852 --- /dev/null +++ b/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# ov-memory-subagent-stop.sh +# Hook: SubagentStop +# When a subagent finishes, extract its transcript into OpenViking memory. + +set -euo pipefail + +INPUT=$(cat) +AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // "unknown"') +AGENT_TRANSCRIPT=$(echo "$INPUT" | jq -r '.agent_transcript_path // empty') +LOG=/tmp/ov-hooks.log + +if [ -z "$AGENT_TRANSCRIPT" ] || [ ! -f "$AGENT_TRANSCRIPT" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: no transcript for $AGENT_TYPE" >> "$LOG" + exit 0 +fi + +# Extract user/assistant text turns from JSONL transcript +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 + echo "[$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: no text messages for $AGENT_TYPE" >> "$LOG" + exit 0 +fi + +ov add-memory "$MESSAGES" >> "$LOG" 2>&1 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: saved $COUNT msgs from $AGENT_TYPE to ov" >> "$LOG" From 10fa64ca74a4df05e9f91a95fd0d345ea94cbbc6 Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Sun, 22 Feb 2026 16:34:47 +0800 Subject: [PATCH 2/4] fix: use nohup for slow ov commands to survive parent exit Claude Code kills async hook processes when the session ends. ov add-memory and ov session commit take ~30s (LLM extraction), so they need to be backgrounded with nohup to complete. Also removed set -euo pipefail to avoid silent failures. --- .../hooks/ov-memory-pre-compact.sh | 19 ++++++++------ .../hooks/ov-memory-session-end.sh | 25 ++++++++++--------- .../hooks/ov-memory-subagent-stop.sh | 17 +++++++++---- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh b/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh index e9e2fbd5..f5bf8461 100755 --- a/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh +++ b/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh @@ -1,22 +1,19 @@ #!/bin/bash # ov-memory-pre-compact.sh # Hook: PreCompact -# Before context compaction, snapshot the conversation into OpenViking memory -# so details aren't lost when the context window is trimmed. +# Before context compaction, snapshot the conversation into OpenViking memory. -set -euo pipefail +LOG=/tmp/ov-hooks.log INPUT=$(cat) TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty') TRIGGER=$(echo "$INPUT" | jq -r '.trigger // "auto"') -LOG=/tmp/ov-hooks.log if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: no transcript (trigger=$TRIGGER)" >> "$LOG" exit 0 fi -# Extract user/assistant text turns from JSONL transcript MESSAGES=$(jq -sc ' map(select(.type == "user" or .type == "assistant")) | map({ @@ -39,5 +36,13 @@ if [ "$COUNT" -eq 0 ]; then exit 0 fi -ov add-memory "$MESSAGES" >> "$LOG" 2>&1 -echo "[$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: snapshotted $COUNT msgs before $TRIGGER compaction" >> "$LOG" +TMPFILE=$(mktemp /tmp/ov-hook-XXXXXX.json) +echo "$MESSAGES" > "$TMPFILE" + +nohup bash -c " + ov add-memory \"\$(cat $TMPFILE)\" >> '$LOG' 2>&1 + echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: snapshotted $COUNT msgs before $TRIGGER compaction\" >> '$LOG' + rm -f '$TMPFILE' +" > /dev/null 2>&1 & + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: queued $COUNT msgs (trigger=$TRIGGER)" >> "$LOG" diff --git a/examples/claude-code-hooks/hooks/ov-memory-session-end.sh b/examples/claude-code-hooks/hooks/ov-memory-session-end.sh index 07c54a3f..6575dd88 100755 --- a/examples/claude-code-hooks/hooks/ov-memory-session-end.sh +++ b/examples/claude-code-hooks/hooks/ov-memory-session-end.sh @@ -4,19 +4,17 @@ # On session end, create an OpenViking session, load all conversation messages, # then commit — which archives and extracts memories automatically. -set -euo pipefail +LOG=/tmp/ov-hooks.log INPUT=$(cat) TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty') REASON=$(echo "$INPUT" | jq -r '.reason // "other"') -LOG=/tmp/ov-hooks.log if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: no transcript (reason=$REASON)" >> "$LOG" exit 0 fi -# Extract user/assistant text turns from JSONL transcript MESSAGES=$(jq -sc ' map(select(.type == "user" or .type == "assistant")) | map({ @@ -35,12 +33,12 @@ MESSAGES=$(jq -sc ' COUNT=$(echo "$MESSAGES" | jq 'length') if [ "$COUNT" -eq 0 ]; then - echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: no messages to archive (reason=$REASON)" >> "$LOG" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: no messages (reason=$REASON)" >> "$LOG" exit 0 fi -# Create a new OpenViking session -OV_RAW=$(ov session new -o json -c 2>/dev/null) +# Create ov session and add messages (fast, no LLM needed) +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 @@ -48,13 +46,16 @@ if [ -z "$OV_SESSION_ID" ]; then exit 0 fi -# Add each message to the OpenViking session -echo "$MESSAGES" | jq -c '.[]' | while IFS= read -r msg; do +while IFS= read -r msg; do ROLE=$(echo "$msg" | jq -r '.role') CONTENT=$(echo "$msg" | jq -r '.content') ov session add-message --role "$ROLE" --content "$CONTENT" "$OV_SESSION_ID" > /dev/null 2>&1 -done +done < <(echo "$MESSAGES" | jq -c '.[]') + +# Commit in background (slow, LLM extraction) — nohup survives parent exit +nohup bash -c " + ov session commit '$OV_SESSION_ID' >> '$LOG' 2>&1 + echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: committed $COUNT msgs (ov=$OV_SESSION_ID, reason=$REASON)\" >> '$LOG' +" > /dev/null 2>&1 & -# Commit — archives messages and extracts memories -ov session commit "$OV_SESSION_ID" >> "$LOG" 2>&1 -echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: committed $COUNT msgs (ov=$OV_SESSION_ID, reason=$REASON)" >> "$LOG" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: queued commit $COUNT msgs (ov=$OV_SESSION_ID, reason=$REASON)" >> "$LOG" diff --git a/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh b/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh index ff1fc852..db90d32a 100755 --- a/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh +++ b/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh @@ -3,19 +3,17 @@ # Hook: SubagentStop # When a subagent finishes, extract its transcript into OpenViking memory. -set -euo pipefail +LOG=/tmp/ov-hooks.log INPUT=$(cat) AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // "unknown"') AGENT_TRANSCRIPT=$(echo "$INPUT" | jq -r '.agent_transcript_path // empty') -LOG=/tmp/ov-hooks.log if [ -z "$AGENT_TRANSCRIPT" ] || [ ! -f "$AGENT_TRANSCRIPT" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: no transcript for $AGENT_TYPE" >> "$LOG" exit 0 fi -# Extract user/assistant text turns from JSONL transcript MESSAGES=$(jq -sc ' map(select(.type == "user" or .type == "assistant")) | map({ @@ -38,5 +36,14 @@ if [ "$COUNT" -eq 0 ]; then exit 0 fi -ov add-memory "$MESSAGES" >> "$LOG" 2>&1 -echo "[$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: saved $COUNT msgs from $AGENT_TYPE to ov" >> "$LOG" +# Write messages to temp file for background process +TMPFILE=$(mktemp /tmp/ov-hook-XXXXXX.json) +echo "$MESSAGES" > "$TMPFILE" + +nohup bash -c " + ov add-memory \"\$(cat $TMPFILE)\" >> '$LOG' 2>&1 + echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: saved $COUNT msgs from $AGENT_TYPE to ov\" >> '$LOG' + rm -f '$TMPFILE' +" > /dev/null 2>&1 & + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: queued $COUNT msgs from $AGENT_TYPE" >> "$LOG" From 8c276707f0c3f29e324f6acf14cee83a125c99ef Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Sun, 22 Feb 2026 22:28:46 +0800 Subject: [PATCH 3/4] docs(examples): improve claude-code-hooks with debug logging, pseudocode headers, and Hooks.md reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OV_HOOK_DEBUG=1 gating — all logging is silent by default, opt-in via env var - Consolidate logs to single /tmp/ov.log (was split between ov-hooks.log and hooks.md) - Add pseudocode + special cases comment block to each hook script - Add _log/_logcmd/_trunc helpers; truncate message content to 120 chars (unicode-safe) in logs only - Add Hooks.md: quick reference for all 17 Claude Code hook events and their stdin params - Rewrite README: how hooks work (stdin JSON), add-memory vs session workflow rationale, debug instructions, extending guide Co-Authored-By: Claude Sonnet 4.6 --- examples/claude-code-hooks/Hooks.md | 27 ++++ examples/claude-code-hooks/README.md | 151 ++++++++---------- .../hooks/ov-memory-pre-compact.sh | 32 +++- .../hooks/ov-memory-session-end.sh | 43 +++-- .../hooks/ov-memory-subagent-stop.sh | 33 +++- 5 files changed, 183 insertions(+), 103 deletions(-) create mode 100644 examples/claude-code-hooks/Hooks.md 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 index 13895b79..ef19b289 100644 --- a/examples/claude-code-hooks/README.md +++ b/examples/claude-code-hooks/README.md @@ -6,31 +6,60 @@ Auto-extract memories from [Claude Code](https://docs.anthropic.com/en/docs/clau Three hooks capture conversation transcripts at strategic lifecycle points and pipe them into OpenViking's memory system: -| Hook | Trigger | Why | -|------|---------|-----| -| `SubagentStop` | Subagent finishes | Subagent transcripts are complete and self-contained | -| `PreCompact` | Before context compaction | Last chance to save details before they're summarized away | -| `SessionEnd` | Session terminates | Full conversation is available for structured archival | +| 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 (one-shot) + ├── SubagentStop ──→ ov add-memory │ - ├── PreCompact ────→ ov add-memory (one-shot) + ├── PreCompact ────→ ov add-memory │ - └── SessionEnd ────→ ov session new → add-message × N → commit + └── 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 to Claude Code hooks directory +### 1. Copy hooks ```bash mkdir -p ~/.claude/hooks @@ -38,9 +67,9 @@ cp hooks/*.sh ~/.claude/hooks/ chmod +x ~/.claude/hooks/*.sh ``` -### 2. Register hooks in Claude Code settings +### 2. Register in Claude Code settings -Add the following to `~/.claude/settings.json` (create if it doesn't exist): +Add to `~/.claude/settings.json`: ```json { @@ -48,33 +77,21 @@ Add the following to `~/.claude/settings.json` (create if it doesn't exist): "SubagentStop": [ { "hooks": [ - { - "type": "command", - "command": "$HOME/.claude/hooks/ov-memory-subagent-stop.sh", - "async": true - } + { "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 - } + { "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 - } + { "type": "command", "command": "$HOME/.claude/hooks/ov-memory-session-end.sh", "async": true } ] } ] @@ -82,87 +99,61 @@ Add the following to `~/.claude/settings.json` (create if it doesn't exist): } ``` -> **Note:** All hooks use `"async": true` so they don't block Claude's responses. - ### 3. Verify ```bash -# Start Claude Code and check hooks are loaded claude /hooks ``` -## Hook Details - -### Transcript Format +## Debugging -Claude Code transcripts are JSONL files. Each line: - -```json -{ - "type": "user", - "message": { - "role": "user", - "content": [{"type": "text", "text": "actual message"}] - } -} -``` - -All three hooks use the same jq pattern to extract user/assistant text turns: +Set `OV_HOOK_DEBUG=1` to enable logging to `/tmp/ov.log`: ```bash -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)) -' +export OV_HOOK_DEBUG=1 +claude ``` -### SubagentStop & PreCompact - -Use `ov add-memory` for one-shot memory extraction — creates a session, adds messages, commits, and extracts memories in a single call. - -### SessionEnd - -Uses the full session workflow for structured archival: +Then watch the log in another terminal: ```bash -ov session new # create session -ov session add-message ... # add each message -ov session commit # archive + extract memories +tail -f /tmp/ov.log ``` -This gives OpenViking more context for richer memory extraction. - -## Logs +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. -All hooks append to `/tmp/ov-hooks.log`: +Example output: ``` -[2026-02-22 10:00:00] SubagentStop/memory: saved 12 msgs to ov -[2026-02-22 10:05:00] PreCompact/memory: snapshotted 34 msgs before auto compaction -[2026-02-22 10:30:00] SessionEnd/memory: committed 56 msgs (ov=abc123, reason=other) +[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, search for extracted 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 -- **Filter by session length**: Skip short sessions by checking `$COUNT` threshold -- **Tag memories**: Add project context via `ov session` metadata -- **Change log location**: Edit `LOG=` in each hook script +- **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 index f5bf8461..efcf0351 100755 --- a/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh +++ b/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh @@ -1,16 +1,35 @@ #!/bin/bash # ov-memory-pre-compact.sh # Hook: PreCompact -# Before context compaction, snapshot the conversation into OpenViking memory. +# +# 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 +# write messages to tmpfile +# log: ov add-memory (content truncated) +# background: ov add-memory → log result → rm tmpfile +# +# SPECIAL CASES: +# trigger=auto — context limit reached, Claude triggered compaction itself +# trigger=manual — user ran /compact explicitly +# nohup background — compaction may proceed before ov finishes; that's fine -LOG=/tmp/ov-hooks.log +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 - echo "[$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: no transcript (trigger=$TRIGGER)" >> "$LOG" + _log "PreCompact: no transcript (trigger=$TRIGGER)" exit 0 fi @@ -32,17 +51,18 @@ MESSAGES=$(jq -sc ' COUNT=$(echo "$MESSAGES" | jq 'length') if [ "$COUNT" -eq 0 ]; then - echo "[$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: nothing to snapshot (trigger=$TRIGGER)" >> "$LOG" + _log "PreCompact: nothing to snapshot (trigger=$TRIGGER)" exit 0 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 - echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: snapshotted $COUNT msgs before $TRIGGER compaction\" >> '$LOG' + [ \"\$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 & -echo "[$(date '+%Y-%m-%d %H:%M:%S')] PreCompact/memory: queued $COUNT msgs (trigger=$TRIGGER)" >> "$LOG" +_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 index 6575dd88..0008f8c2 100755 --- a/examples/claude-code-hooks/hooks/ov-memory-session-end.sh +++ b/examples/claude-code-hooks/hooks/ov-memory-session-end.sh @@ -1,17 +1,39 @@ #!/bin/bash # ov-memory-session-end.sh # Hook: SessionEnd -# On session end, create an OpenViking session, load all conversation messages, -# then commit — which archives and extracts memories automatically. +# +# 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 +# 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 +# failed session new — ov server is down; skip gracefully +# nohup background — session process exits before LLM extraction finishes -LOG=/tmp/ov-hooks.log +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 - echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: no transcript (reason=$REASON)" >> "$LOG" + _log "SessionEnd: no transcript (reason=$REASON)" exit 0 fi @@ -33,29 +55,30 @@ MESSAGES=$(jq -sc ' COUNT=$(echo "$MESSAGES" | jq 'length') if [ "$COUNT" -eq 0 ]; then - echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: no messages (reason=$REASON)" >> "$LOG" + _log "SessionEnd: no messages (reason=$REASON)" exit 0 fi -# Create ov session and add messages (fast, no LLM needed) +_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 - echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: failed to create ov session" >> "$LOG" + _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 '.[]') -# Commit in background (slow, LLM extraction) — nohup survives parent exit +_logcmd "ov session commit $OV_SESSION_ID" nohup bash -c " ov session commit '$OV_SESSION_ID' >> '$LOG' 2>&1 - echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: committed $COUNT msgs (ov=$OV_SESSION_ID, reason=$REASON)\" >> '$LOG' + [ \"\$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 & -echo "[$(date '+%Y-%m-%d %H:%M:%S')] SessionEnd/memory: queued commit $COUNT msgs (ov=$OV_SESSION_ID, reason=$REASON)" >> "$LOG" +_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 index db90d32a..52487dd8 100755 --- a/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh +++ b/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh @@ -1,16 +1,35 @@ #!/bin/bash # ov-memory-subagent-stop.sh # Hook: SubagentStop -# When a subagent finishes, extract its transcript into OpenViking memory. +# +# WHAT: Save a finished subagent's conversation into OpenViking memory. +# +# PSEUDOCODE: +# read stdin → agent_type, transcript_path +# if no transcript → exit +# parse transcript → keep user/assistant text messages only +# if no messages → exit +# 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 +# nohup background — ov call survives if parent exits before it completes -LOG=/tmp/ov-hooks.log +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') if [ -z "$AGENT_TRANSCRIPT" ] || [ ! -f "$AGENT_TRANSCRIPT" ]; then - echo "[$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: no transcript for $AGENT_TYPE" >> "$LOG" + _log "SubagentStop: no transcript for $AGENT_TYPE" exit 0 fi @@ -32,18 +51,18 @@ MESSAGES=$(jq -sc ' COUNT=$(echo "$MESSAGES" | jq 'length') if [ "$COUNT" -eq 0 ]; then - echo "[$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: no text messages for $AGENT_TYPE" >> "$LOG" + _log "SubagentStop: no text messages for $AGENT_TYPE" exit 0 fi -# Write messages to temp file for background process 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 - echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: saved $COUNT msgs from $AGENT_TYPE to ov\" >> '$LOG' + [ \"\$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 & -echo "[$(date '+%Y-%m-%d %H:%M:%S')] SubagentStop/memory: queued $COUNT msgs from $AGENT_TYPE" >> "$LOG" +_log "SubagentStop: queued $COUNT msgs from $AGENT_TYPE" From 25bc90b19e9ac4bc5cc3cbddc17023584628da58 Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Sun, 22 Feb 2026 23:45:08 +0800 Subject: [PATCH 4/4] feat: support, wait for file flush --- .../hooks/ov-memory-pre-compact.sh | 32 +++++++++++++++++-- .../hooks/ov-memory-session-end.sh | 25 +++++++++++++++ .../hooks/ov-memory-subagent-stop.sh | 22 ++++++++++--- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh b/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh index efcf0351..89665745 100755 --- a/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh +++ b/examples/claude-code-hooks/hooks/ov-memory-pre-compact.sh @@ -9,13 +9,16 @@ # 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 — context limit reached, Claude triggered compaction itself -# trigger=manual — user ran /compact explicitly +# 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 @@ -55,6 +58,31 @@ if [ "$COUNT" -eq 0 ]; then 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" diff --git a/examples/claude-code-hooks/hooks/ov-memory-session-end.sh b/examples/claude-code-hooks/hooks/ov-memory-session-end.sh index 0008f8c2..c87e6112 100755 --- a/examples/claude-code-hooks/hooks/ov-memory-session-end.sh +++ b/examples/claude-code-hooks/hooks/ov-memory-session-end.sh @@ -9,6 +9,7 @@ # 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) @@ -19,6 +20,8 @@ # 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 @@ -59,6 +62,28 @@ if [ "$COUNT" -eq 0 ]; then 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') diff --git a/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh b/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh index 52487dd8..adc663cc 100755 --- a/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh +++ b/examples/claude-code-hooks/hooks/ov-memory-subagent-stop.sh @@ -5,18 +5,22 @@ # WHAT: Save a finished subagent's conversation into OpenViking memory. # # PSEUDOCODE: -# read stdin → agent_type, transcript_path +# 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 -# nohup background — ov call survives if parent exits before it completes +# 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 @@ -27,6 +31,7 @@ _trunc() { printf '%s' "$1" | python3 -c "import sys; s=sys.stdin.read(); print 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" @@ -55,6 +60,15 @@ if [ "$COUNT" -eq 0 ]; then 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"