From 93415257f39484514300e0155f4e4f6c8222458a Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 14 Mar 2026 11:44:04 +0000 Subject: [PATCH] feat: OpenBrain integration for session hooks SessionStart: query OpenBrain for recent activity + project context, inject into Claude's context window on every new session. Stop: async save session context to OpenBrain when git changes exist. PreCompact: save working state to OpenBrain before context compaction. API key read from ~/.claude/brain.key or CORE_BRAIN_KEY env var. Co-Authored-By: Virgil --- claude/code/hooks.json | 14 ++++ claude/code/scripts/pre-compact.sh | 65 ++++++++++------ claude/code/scripts/session-save.sh | 53 +++++++++++++ claude/code/scripts/session-start.sh | 112 +++++++++++++++++++++++---- 4 files changed, 208 insertions(+), 36 deletions(-) create mode 100755 claude/code/scripts/session-save.sh diff --git a/claude/code/hooks.json b/claude/code/hooks.json index c14cfe7..69d886c 100644 --- a/claude/code/hooks.json +++ b/claude/code/hooks.json @@ -55,6 +55,20 @@ "description": "Warn about uncommitted work after git commit" } ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-save.sh", + "async": true, + "timeout": 10 + } + ], + "description": "Save session context to OpenBrain (async, only when git changes exist)" + } + ], "PreCompact": [ { "matcher": "*", diff --git a/claude/code/scripts/pre-compact.sh b/claude/code/scripts/pre-compact.sh index bb9d841..5b4996c 100755 --- a/claude/code/scripts/pre-compact.sh +++ b/claude/code/scripts/pre-compact.sh @@ -1,23 +1,24 @@ #!/bin/bash -# Pre-compact: Save minimal state for Claude to resume after auto-compact +# Pre-compact: Save state locally + to OpenBrain before context compaction # # Captures: # - Working directory + branch # - Git status (files touched) # - Todo state (in_progress items) -# - Context facts (decisions, actionables) +# - Saves to OpenBrain so the memory survives across sessions STATE_FILE="${HOME}/.claude/sessions/scratchpad.md" -CONTEXT_FILE="${HOME}/.claude/sessions/context.json" +BRAIN_URL="${CORE_BRAIN_URL:-https://api.lthn.sh}" +BRAIN_KEY="${CORE_BRAIN_KEY:-}" +BRAIN_KEY_FILE="${HOME}/.claude/brain.key" TIMESTAMP=$(date '+%s') CWD=$(pwd) mkdir -p "${HOME}/.claude/sessions" -# Get todo state -TODOS="" -if [[ -f "${HOME}/.claude/todos/current.json" ]]; then - TODOS=$(cat "${HOME}/.claude/todos/current.json" 2>/dev/null | head -50) +# Load API key +if [[ -z "$BRAIN_KEY" && -f "$BRAIN_KEY_FILE" ]]; then + BRAIN_KEY=$(cat "$BRAIN_KEY_FILE" 2>/dev/null | tr -d '[:space:]') fi # Get git status @@ -28,12 +29,18 @@ if git rev-parse --git-dir > /dev/null 2>&1; then BRANCH=$(git branch --show-current 2>/dev/null) fi -# Get context facts -CONTEXT="" -if [[ -f "$CONTEXT_FILE" ]]; then - CONTEXT=$(jq -r '.[] | "- [\(.source)] \(.fact)"' "$CONTEXT_FILE" 2>/dev/null | tail -10) -fi +# Detect project +PROJECT="" +case "$CWD" in + */core/go-*) PROJECT=$(basename "$CWD" | sed 's/^go-//') ;; + */core/php-*) PROJECT=$(basename "$CWD" | sed 's/^php-//') ;; + */core/*) PROJECT=$(basename "$CWD") ;; + */host-uk/*) PROJECT=$(basename "$CWD") ;; + */lthn/*) PROJECT=$(basename "$CWD") ;; + */snider/*) PROJECT=$(basename "$CWD") ;; +esac +# Save local scratchpad cat > "$STATE_FILE" << EOF --- timestamp: ${TIMESTAMP} @@ -53,17 +60,31 @@ You were mid-task. Do NOT assume work is complete. ${GIT_STATUS:-none} \`\`\` -## Todos (in_progress = NOT done) -\`\`\`json -${TODOS:-check /todos} -\`\`\` - -## Context (decisions & actionables) -${CONTEXT:-none captured} - ## Next -Continue the in_progress todo. +Continue the in_progress todo. Check OpenBrain for context. EOF -echo "[PreCompact] Snapshot saved" >&2 +echo "[PreCompact] Local snapshot saved" >&2 + +# Save to OpenBrain +if [[ -n "$BRAIN_KEY" && -n "$GIT_STATUS" ]]; then + CHANGED_FILES=$(echo "$GIT_STATUS" | awk '{print $2}' | tr '\n' ', ') + CONTENT="Context compaction during work on ${PROJECT:-unknown} (branch: ${BRANCH:-none}). Changed files: ${CHANGED_FILES}" + + curl -s --max-time 5 "${BRAIN_URL}/v1/brain/remember" \ + -X POST \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H "Authorization: Bearer ${BRAIN_KEY}" \ + -d "{ + \"content\": $(echo "$CONTENT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'), + \"type\": \"context\", + \"project\": \"${PROJECT:-unknown}\", + \"agent_id\": \"cladius\", + \"tags\": [\"pre-compact\", \"session\"] + }" >/dev/null 2>&1 + + echo "[PreCompact] Saved to OpenBrain" >&2 +fi + exit 0 diff --git a/claude/code/scripts/session-save.sh b/claude/code/scripts/session-save.sh new file mode 100755 index 0000000..98fcd97 --- /dev/null +++ b/claude/code/scripts/session-save.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Stop hook: Save session context to OpenBrain after each response +# Runs async so it doesn't block the conversation +# +# Only saves if there are git changes (indicates real work happened) + +BRAIN_URL="${CORE_BRAIN_URL:-https://api.lthn.sh}" +BRAIN_KEY="${CORE_BRAIN_KEY:-}" +BRAIN_KEY_FILE="${HOME}/.claude/brain.key" + +# Load API key +if [[ -z "$BRAIN_KEY" && -f "$BRAIN_KEY_FILE" ]]; then + BRAIN_KEY=$(cat "$BRAIN_KEY_FILE" 2>/dev/null | tr -d '[:space:]') +fi + +[[ -z "$BRAIN_KEY" ]] && exit 0 + +# Only save if there are uncommitted changes (real work happened) +GIT_STATUS=$(git status --short 2>/dev/null | head -5) +[[ -z "$GIT_STATUS" ]] && exit 0 + +# Detect project +PROJECT="" +CWD=$(pwd) +case "$CWD" in + */core/go-*) PROJECT=$(basename "$CWD" | sed 's/^go-//') ;; + */core/php-*) PROJECT=$(basename "$CWD" | sed 's/^php-//') ;; + */core/*) PROJECT=$(basename "$CWD") ;; + */host-uk/*) PROJECT=$(basename "$CWD") ;; + */lthn/*) PROJECT=$(basename "$CWD") ;; + */snider/*) PROJECT=$(basename "$CWD") ;; +esac +[[ -z "$PROJECT" ]] && PROJECT="unknown" + +BRANCH=$(git branch --show-current 2>/dev/null || echo "none") +CHANGED_FILES=$(git diff --name-only 2>/dev/null | head -10 | tr '\n' ', ') + +CONTENT="Working on ${PROJECT} (branch: ${BRANCH}). Changed files: ${CHANGED_FILES}" + +curl -s --max-time 5 "${BRAIN_URL}/v1/brain/remember" \ + -X POST \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H "Authorization: Bearer ${BRAIN_KEY}" \ + -d "{ + \"content\": $(echo "$CONTENT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'), + \"type\": \"context\", + \"project\": \"${PROJECT}\", + \"agent_id\": \"cladius\", + \"tags\": [\"auto-save\", \"session\"] + }" >/dev/null 2>&1 + +exit 0 diff --git a/claude/code/scripts/session-start.sh b/claude/code/scripts/session-start.sh index 3a44d97..10613b4 100755 --- a/claude/code/scripts/session-start.sh +++ b/claude/code/scripts/session-start.sh @@ -1,32 +1,116 @@ #!/bin/bash -# Session start: Read scratchpad if recent, otherwise start fresh -# 3 hour window - if older, you've moved on mentally +# Session start: Load OpenBrain context + recent scratchpad +# +# 1. Query OpenBrain for project-relevant memories +# 2. Read local scratchpad if recent (<3h) +# 3. Output to stdout → injected into Claude's context +BRAIN_URL="${CORE_BRAIN_URL:-https://api.lthn.sh}" +BRAIN_KEY="${CORE_BRAIN_KEY:-}" +BRAIN_KEY_FILE="${HOME}/.claude/brain.key" STATE_FILE="${HOME}/.claude/sessions/scratchpad.md" -THREE_HOURS=10800 # seconds +THREE_HOURS=10800 +# Load API key from file if not in env +if [[ -z "$BRAIN_KEY" && -f "$BRAIN_KEY_FILE" ]]; then + BRAIN_KEY=$(cat "$BRAIN_KEY_FILE" 2>/dev/null | tr -d '[:space:]') +fi + +# --- OpenBrain Recall --- +if [[ -n "$BRAIN_KEY" ]]; then + # Detect project from CWD + PROJECT="" + CWD=$(pwd) + case "$CWD" in + */core/go-*) PROJECT=$(basename "$CWD" | sed 's/^go-//') ;; + */core/php-*) PROJECT=$(basename "$CWD" | sed 's/^php-//') ;; + */core/*) PROJECT=$(basename "$CWD") ;; + */host-uk/*) PROJECT=$(basename "$CWD") ;; + */lthn/*) PROJECT=$(basename "$CWD") ;; + */snider/*) PROJECT=$(basename "$CWD") ;; + esac + + echo "[SessionStart] OpenBrain: querying memories..." >&2 + + # 1. Recent session summaries (what did we do recently?) + RECENT=$(curl -s --max-time 5 "${BRAIN_URL}/v1/brain/recall" \ + -X POST \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H "Authorization: Bearer ${BRAIN_KEY}" \ + -d "{\"query\": \"session summary milestone recent work completed\", \"top_k\": 3, \"agent_id\": \"cladius\"}" 2>/dev/null) + + # 2. Project-specific context (if we're in a project dir) + PROJECT_CTX="" + if [[ -n "$PROJECT" ]]; then + PROJECT_CTX=$(curl -s --max-time 5 "${BRAIN_URL}/v1/brain/recall" \ + -X POST \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H "Authorization: Bearer ${BRAIN_KEY}" \ + -d "{\"query\": \"architecture decisions conventions for ${PROJECT}\", \"top_k\": 3, \"agent_id\": \"cladius\", \"project\": \"${PROJECT}\"}" 2>/dev/null) + fi + + # Output to stdout (injected into context) + RECENT_COUNT=$(echo "$RECENT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('memories',[])))" 2>/dev/null || echo "0") + + if [[ "$RECENT_COUNT" -gt 0 ]]; then + echo "" + echo "## OpenBrain — Recent Activity" + echo "" + echo "$RECENT" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for m in data.get('memories', []): + t = m.get('type', '?') + p = m.get('project', '?') + content = m.get('content', '')[:300] + print(f'**[{t}]** ({p}): {content}') + print() +" 2>/dev/null + fi + + if [[ -n "$PROJECT" && -n "$PROJECT_CTX" ]]; then + PROJECT_COUNT=$(echo "$PROJECT_CTX" | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('memories',[])))" 2>/dev/null || echo "0") + if [[ "$PROJECT_COUNT" -gt 0 ]]; then + echo "" + echo "## OpenBrain — ${PROJECT} Context" + echo "" + echo "$PROJECT_CTX" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for m in data.get('memories', []): + t = m.get('type', '?') + content = m.get('content', '')[:300] + print(f'**[{t}]**: {content}') + print() +" 2>/dev/null + fi + fi + + echo "[SessionStart] OpenBrain: ${RECENT_COUNT} recent + ${PROJECT_COUNT:-0} project memories loaded" >&2 +else + echo "[SessionStart] OpenBrain: no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)" >&2 +fi + +# --- Local Scratchpad --- if [[ -f "$STATE_FILE" ]]; then - # Get timestamp from file FILE_TS=$(grep -E '^timestamp:' "$STATE_FILE" 2>/dev/null | cut -d' ' -f2) NOW=$(date '+%s') if [[ -n "$FILE_TS" ]]; then AGE=$((NOW - FILE_TS)) - if [[ $AGE -lt $THREE_HOURS ]]; then - # Recent - read it back - echo "[SessionStart] Found recent scratchpad ($(($AGE / 60)) min ago)" >&2 - echo "[SessionStart] Reading previous state..." >&2 - echo "" >&2 - cat "$STATE_FILE" >&2 - echo "" >&2 + echo "[SessionStart] Scratchpad: $(($AGE / 60)) min old" >&2 + echo "" + echo "## Recent Scratchpad ($(($AGE / 60)) min ago)" + echo "" + cat "$STATE_FILE" else - # Stale - delete and start fresh rm -f "$STATE_FILE" - echo "[SessionStart] Previous session >3h old - starting fresh" >&2 + echo "[SessionStart] Scratchpad: >3h old, cleared" >&2 fi else - # No timestamp, delete it rm -f "$STATE_FILE" fi fi