Skip to content

Commit c04acd7

Browse files
author
Vinicius Rocha
committed
feat(title-gen): Replace claude with opencode, add queue serialization
- Use opencode CLI with free models (minimax/minimax-m2.5-pro-free) - Add worker lock (_acquire_worker_lock) to serialize background processes - Implement queue drain (_drain_retry_queue) to process all pending titles - Refactor _background_generate to acquire lock and drain queue after success - Enqueue failed titles (rate_limit/timeout) for retry - Add TITLE_MODEL and WORKER_LOCK configuration constants - Reduce CPU contention when multiple SessionEnd hooks fire simultaneously
1 parent 02d97dc commit c04acd7

2 files changed

Lines changed: 185 additions & 13 deletions

File tree

api/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ class Settings(BaseSettings):
105105
description="Allowed headers for CORS",
106106
)
107107

108+
# Title generation
109+
title_model: str = Field(
110+
default="minimax/minimax-m2.5-pro-free",
111+
description="Model used by opencode for session title generation (CLAUDE_KARMA_TITLE_MODEL)",
112+
)
113+
108114
# Logging
109115
log_level: str = Field(
110116
default="INFO", description="Logging level (DEBUG, INFO, WARNING, ERROR)"

hooks/session_title_generator.py

Lines changed: 179 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
1010
"""
1111

12+
import fcntl
1213
import json
14+
import logging
1315
import os
1416
import re
1517
import subprocess
@@ -18,6 +20,22 @@
1820
from typing import Optional, Tuple
1921

2022
API_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"
2139
MAX_PROMPT_LENGTH = 500
2240
MAX_RESPONSE_LENGTH = 300
2341
TITLE_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+
68124
def 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

100239
def 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+
241407
def 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

Comments
 (0)