2026-06-21 17:42:42 +00:00
#!/usr/bin/env python3
"""
Stop hook ( async ) : automatic learning extraction via haiku - as - judge .
After each Claude response , sends the user message + assistant response to
haiku to detect corrections , preferences , decisions , or facts worth storing .
2026-06-22 09:24:42 +00:00
If learning events are detected , stores them via the ` homelab memory ` CLI — the
only sanctioned memory path on the devvm ( no direct HTTP , no local SQLite ) .
2026-06-21 17:42:42 +00:00
Runs with async : true — does NOT block the user .
"""
import io
import json
import logging
import os
import shutil
import subprocess
import sys
logger = logging . getLogger ( __name__ )
JUDGE_PROMPT = """ You are a memory extraction judge. Analyze this exchange between a user and an AI assistant.
USER MESSAGE :
{ user_message }
ASSISTANT RESPONSE :
{ assistant_response }
Your job : determine if any of these learning events occurred :
1. USER CORRECTION — user corrected the assistant ' s mistake or misunderstanding
2. PREFERENCE — user stated a preference , habit , or " I like/prefer/want " statement
3. DECISION — a decision was reached about how to do something
4. FACT — user shared a durable fact about themselves , their team , tools , or environment
If ANY learning event occurred , return JSON :
{ { " events " : [ { { " type " : " correction|preference|decision|fact " , " content " : " concise fact to remember (one sentence) " , " importance " : 0.7 , " expanded_keywords " : " space-separated semantically related search terms for recall (minimum 5 words) " , " supersedes " : null } } ] } }
If NO learning event occurred , return :
{ { " events " : [ ] } }
Rules :
- Only extract DURABLE facts , not transient task details
- Corrections are highest value ( 0.8 - 0.9 )
- Be conservative — false negatives are better than false positives
- " expanded_keywords " should include synonyms , related concepts , and adjacent topics that would help find this memory later
- " supersedes " should be a search query to find the old outdated memory , or null
- Return ONLY valid JSON , no other text """
2026-06-22 09:24:42 +00:00
def _store_via_homelab_cli ( content , category , tags , importance , expanded_keywords ) :
""" Store one memory via the homelab CLI — the only sanctioned memory path on
the devvm ( no direct HTTP , no local SQLite ) . The CLI defaults the API URL and
reads CLAUDE_MEMORY_API_KEY / MEMORY_API_KEY from the environment ; if neither
is set ( e . g . a user without a minted key ) it no - ops silently . """
homelab = shutil . which ( " homelab " ) or " /usr/local/bin/homelab "
if not os . path . exists ( homelab ) :
return
if not ( os . environ . get ( " CLAUDE_MEMORY_API_KEY " ) or os . environ . get ( " MEMORY_API_KEY " ) ) :
return
cmd = [
homelab , " memory " , " store " , content ,
" --category " , category ,
" --tags " , tags ,
" --importance " , str ( importance ) ,
]
if expanded_keywords :
# CLI wants comma-separated keywords; the judge emits space-separated terms.
keywords = " , " . join ( expanded_keywords . replace ( " , " , " " ) . split ( ) )
if keywords :
cmd + = [ " --keywords " , keywords ]
subprocess . run ( cmd , capture_output = True , text = True , timeout = 15 , env = os . environ )
2026-06-21 17:42:42 +00:00
def main ( ) - > None :
# Graceful exit if claude CLI is not available
if not shutil . which ( " claude " ) :
return
try :
hook_input = json . load ( sys . stdin )
except ( json . JSONDecodeError , EOFError ) :
return
if isinstance ( hook_input , dict ) and hook_input . get ( " stop_hook_active " , False ) :
return
transcript_path = " "
if isinstance ( hook_input , dict ) :
transcript_path = hook_input . get ( " transcript_path " , " " )
if not transcript_path or not os . path . exists ( transcript_path ) :
return
user_message = " "
assistant_response = " "
try :
MAX_TAIL_BYTES = 50_000
with open ( transcript_path , " rb " ) as f :
f . seek ( 0 , io . SEEK_END )
size = f . tell ( )
f . seek ( max ( 0 , size - MAX_TAIL_BYTES ) )
tail = f . read ( ) . decode ( " utf-8 " , errors = " replace " )
lines = tail . split ( " \n " )
for line in reversed ( lines ) :
line = line . strip ( )
if not line :
continue
try :
entry = json . loads ( line )
except json . JSONDecodeError :
continue
role = entry . get ( " role " , " " )
content = entry . get ( " content " , " " )
if isinstance ( content , list ) :
content = " " . join (
b . get ( " text " , " " ) for b in content
if isinstance ( b , dict ) and b . get ( " type " ) == " text "
)
content = str ( content ) [ : 2000 ]
if role == " assistant " and not assistant_response :
assistant_response = content
elif role == " user " and not user_message :
user_message = content
if user_message and assistant_response :
break
except Exception :
return
if not user_message or len ( user_message . strip ( ) ) < 10 :
return
prompt = JUDGE_PROMPT . format (
user_message = user_message ,
assistant_response = assistant_response [ : 1000 ] ,
)
try :
result = subprocess . run (
[ " claude " , " -p " , prompt , " --model " , " haiku " ] ,
capture_output = True , text = True , timeout = 30 ,
env = { * * os . environ , " CLAUDECODE " : " " } ,
)
if result . returncode != 0 :
return
response_text = result . stdout . strip ( )
if response_text . startswith ( " ``` " ) :
lines = response_text . split ( " \n " )
lines = [ l for l in lines if not l . strip ( ) . startswith ( " ``` " ) ]
response_text = " \n " . join ( lines ) . strip ( )
judge_result = json . loads ( response_text )
events = judge_result . get ( " events " , [ ] )
if not events :
return
except ( subprocess . TimeoutExpired , json . JSONDecodeError , OSError ) :
return
category_map = {
" correction " : " preferences " ,
" preference " : " preferences " ,
" decision " : " decisions " ,
" fact " : " facts " ,
}
for event in events :
content = event . get ( " content " , " " )
if not content :
continue
event_type = event . get ( " type " , " fact " )
importance = max ( 0.0 , min ( 1.0 , float ( event . get ( " importance " , 0.7 ) ) ) )
category = category_map . get ( event_type , " facts " )
tags = f " auto-learned, { event_type } "
expanded_keywords = event . get ( " expanded_keywords " , " " )
try :
2026-06-22 09:24:42 +00:00
_store_via_homelab_cli ( content , category , tags , importance , expanded_keywords )
2026-06-21 17:42:42 +00:00
except Exception :
pass # Never crash the async hook
if __name__ == " __main__ " :
main ( )