99
1010"""
1111
12+ import fcntl
1213import json
14+ import logging
1315import os
1416import re
1517import subprocess
1820from typing import Optional , Tuple
1921
2022API_BASE = os .environ .get ("CLAUDE_KARMA_API" , "http://localhost:8000" )
23+ TITLE_MODEL = os .environ .get ("CLAUDE_KARMA_TITLE_MODEL" , "minimax/minimax-m2.5-pro-free" )
24+
25+ # Logging setup
26+ LOG_DIR = Path (os .path .expanduser ("~/.claude_karma" ))
27+ LOG_DIR .mkdir (parents = True , exist_ok = True )
28+ logging .basicConfig (
29+ filename = str (LOG_DIR / "title-generator.log" ),
30+ level = logging .DEBUG ,
31+ format = "%(asctime)s [%(levelname)s] %(message)s" ,
32+ datefmt = "%Y-%m-%d %H:%M:%S" ,
33+ )
34+ log = logging .getLogger ("title-gen" )
35+
36+ # Lock and queue
37+ KARMA_BASE = Path (os .path .expanduser ("~/.claude_karma" ))
38+ WORKER_LOCK = KARMA_BASE / ".title-worker.lock"
2139MAX_PROMPT_LENGTH = 500
2240MAX_RESPONSE_LENGTH = 300
2341TITLE_MAX_WORDS = 10
@@ -65,36 +83,157 @@ def _get_session_start_iso(transcript_path: str) -> Optional[str]:
6583 return None
6684
6785
86+ def _acquire_worker_lock ():
87+ """Return lock file handle if acquired, None if another worker is running."""
88+ try :
89+ fh = open (WORKER_LOCK , "w" )
90+ fcntl .flock (fh , fcntl .LOCK_EX | fcntl .LOCK_NB )
91+ return fh
92+ except OSError :
93+ fh .close () if 'fh' in locals () else None
94+ return None
95+
96+
97+ def _drain_retry_queue () -> None :
98+ """Process all pending retries in ~/.claude_karma/title-retry/."""
99+ retry_dir = KARMA_BASE / "title-retry"
100+ if not retry_dir .exists ():
101+ return
102+ for path in sorted (retry_dir .glob ("*.json" )):
103+ try :
104+ data = json .loads (path .read_text (encoding = "utf-8" ))
105+ except (json .JSONDecodeError , OSError ):
106+ path .unlink (missing_ok = True )
107+ continue
108+
109+ session_id = data .get ("session_id" , "" )
110+ title , source = generate_title (
111+ data .get ("initial_prompt" , "" ),
112+ data .get ("first_response" ),
113+ data .get ("git_context" ),
114+ )
115+ if title :
116+ if post_title (session_id , title ):
117+ path .unlink (missing_ok = True )
118+ log .info ("Drained retry: session=%s source=%s" , session_id [:12 ], source )
119+ elif source in ("rate_limited" , "timeout" ):
120+ log .warning ("Retry still failing (%s), leaving in queue" , source )
121+ break
122+
123+
68124def main ():
125+ # Background mode: called from detached subprocess with context file
126+ if len (sys .argv ) > 1 and sys .argv [1 ] == "--background" :
127+ if len (sys .argv ) < 3 :
128+ log .warning ("Background: missing context file arg" )
129+ return
130+
131+ context_file = Path (sys .argv [2 ])
132+ if not context_file .exists ():
133+ log .warning ("Background: context file not found: %s" , context_file )
134+ return
135+
136+ try :
137+ data = json .loads (context_file .read_text (encoding = "utf-8" ))
138+ except (json .JSONDecodeError , OSError ) as e :
139+ log .error ("Background: failed to read context file: %s" , e )
140+ return
141+ finally :
142+ context_file .unlink (missing_ok = True )
143+
144+ # Try to acquire lock (non-blocking)
145+ lock_fh = _acquire_worker_lock ()
146+ if not lock_fh :
147+ log .info ("Background: lock busy, enqueuing for later retry" )
148+ # Enqueue this session for retry
149+ enqueue_title_retry (
150+ data .get ("session_id" , "" ),
151+ data .get ("transcript_path" , "" ),
152+ data .get ("initial_prompt" , "" ),
153+ data .get ("first_response" ),
154+ data .get ("cwd" , "" ),
155+ )
156+ return
157+
158+ try :
159+ # Generate title for this session
160+ title , source = generate_title (
161+ data .get ("initial_prompt" , "" ),
162+ data .get ("first_response" ),
163+ data .get ("git_context" ),
164+ )
165+ if title :
166+ post_title (data .get ("session_id" , "" ), title )
167+ elif source in ("rate_limited" , "timeout" ):
168+ enqueue_title_retry (
169+ data .get ("session_id" , "" ),
170+ data .get ("transcript_path" , "" ),
171+ data .get ("initial_prompt" , "" ),
172+ data .get ("first_response" ),
173+ data .get ("cwd" , "" ),
174+ )
175+
176+ # Drain the full retry queue before releasing lock
177+ log .info ("Background: draining retry queue" )
178+ _drain_retry_queue ()
179+ finally :
180+ fcntl .flock (lock_fh , fcntl .LOCK_UN )
181+ lock_fh .close ()
182+ return
183+
184+ # Normal hook mode: called with stdin JSON
69185 try :
70186 data = json .loads (sys .stdin .read ())
71187 except (json .JSONDecodeError , EOFError ):
188+ log .warning ("Failed to parse stdin JSON" )
72189 return
73190
74191 session_id = data .get ("session_id" , "" )
75192 transcript_path = data .get ("transcript_path" , "" )
76193 cwd = data .get ("cwd" , "" )
77194 reason = data .get ("reason" , "" )
78195
196+ log .info ("SessionEnd hook fired — session=%s reason=%s" , session_id [:12 ], reason )
197+
79198 # Skip if no transcript or if cleared (not meaningful sessions)
80199 if not transcript_path or not Path (transcript_path ).exists ():
200+ log .info ("Skipped — no transcript found" )
81201 return
82- if reason in ("clear" ,):
202+ if reason in ("clear" , "resume" ):
203+ log .info ("Skipped — reason is %r" , reason )
83204 return
84205
85- # Extract context from JSONL
206+ # Extract context from JSONL (fast, no network)
86207 initial_prompt , first_response = extract_session_context (transcript_path )
87208 if not initial_prompt :
209+ log .info ("Skipped — no initial prompt extracted" )
88210 return
89211
90- # Get git commits during session
212+ # Get git commits during session (fast, local)
91213 git_context = get_git_context (cwd , transcript_path )
92214
93- # Generate title
94- title , source = generate_title (initial_prompt , first_response , git_context )
95-
96- if title :
97- post_title (session_id , title )
215+ # Spawn background process to generate title (non-blocking)
216+ context_payload = json .dumps ({
217+ "session_id" : session_id ,
218+ "transcript_path" : transcript_path ,
219+ "initial_prompt" : initial_prompt ,
220+ "first_response" : first_response ,
221+ "cwd" : cwd ,
222+ "git_context" : git_context ,
223+ })
224+
225+ context_file = Path (f"/tmp/session_title_{ session_id } .json" )
226+ context_file .write_text (context_payload , encoding = "utf-8" )
227+
228+ log .info ("Spawning background process for title generation" )
229+
230+ subprocess .Popen (
231+ [sys .executable , __file__ , "--background" , str (context_file )],
232+ stdin = subprocess .DEVNULL ,
233+ stdout = subprocess .DEVNULL ,
234+ stderr = subprocess .DEVNULL ,
235+ start_new_session = True ,
236+ )
98237
99238
100239def extract_session_context (transcript_path : str ) -> Tuple [Optional [str ], Optional [str ]]:
@@ -194,7 +333,7 @@ def generate_title(
194333 title = " " .join (words [:TITLE_MAX_WORDS ])
195334 return title , "git"
196335
197- # 2. Fall back to Haiku ( with --no-session-persistence to avoid session bloat)
336+ # 2. Fall back to opencode with free model
198337 parts = [f"User asked: { initial_prompt } " ]
199338 if first_response :
200339 parts .append (f"Assistant did: { first_response } " )
@@ -210,13 +349,13 @@ def generate_title(
210349
211350 try :
212351 env = os .environ .copy ()
213- env .pop ("CLAUDECODE" , None ) # Allow nested claude invocation
352+ env .pop ("CLAUDECODE" , None )
214353
215354 result = subprocess .run (
216- ["claude " , "-p " , prompt , "--model" , "haiku" , "--no-session-persistence" , "--output-format" , "text" ],
355+ ["opencode " , "run " , prompt , "--model" , TITLE_MODEL ],
217356 capture_output = True ,
218357 text = True ,
219- timeout = 12 ,
358+ timeout = 30 ,
220359 env = env ,
221360 )
222361
@@ -226,7 +365,7 @@ def generate_title(
226365 words = title .split ()
227366 if len (words ) > TITLE_MAX_WORDS :
228367 title = " " .join (words [:TITLE_MAX_WORDS ])
229- return title , "haiku "
368+ return title , "opencode "
230369
231370 except (subprocess .TimeoutExpired , FileNotFoundError , OSError ):
232371 pass
@@ -238,6 +377,33 @@ def generate_title(
238377 return fallback , "fallback"
239378
240379
380+ def enqueue_title_retry (
381+ session_id : str ,
382+ transcript_path : str ,
383+ initial_prompt : str ,
384+ first_response : Optional [str ],
385+ cwd : str ,
386+ ) -> None :
387+ """Save session context to the retry queue for later processing."""
388+ try :
389+ retry_dir = KARMA_BASE / "title-retry"
390+ retry_dir .mkdir (parents = True , exist_ok = True )
391+ payload = {
392+ "session_id" : session_id ,
393+ "transcript_path" : transcript_path ,
394+ "initial_prompt" : initial_prompt ,
395+ "first_response" : first_response ,
396+ "cwd" : cwd ,
397+ "git_context" : get_git_context (cwd , transcript_path ),
398+ }
399+ (retry_dir / f"{ session_id } .json" ).write_text (
400+ json .dumps (payload , ensure_ascii = False ), encoding = "utf-8"
401+ )
402+ log .info ("Enqueued retry for session=%s" , session_id [:12 ])
403+ except OSError as e :
404+ log .error ("Failed to enqueue retry: %s" , e )
405+
406+
241407def post_title (session_id : str , title : str ) -> bool :
242408 """POST the generated title to the Claude Code Karma API. Returns True on success."""
243409 import urllib .request
0 commit comments