aline-ai 0.1.9__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aline_ai-0.1.9.dist-info → aline_ai-0.2.0.dist-info}/METADATA +1 -1
- aline_ai-0.2.0.dist-info/RECORD +25 -0
- realign/__init__.py +1 -1
- realign/commands/auto_commit.py +1 -1
- realign/commands/commit.py +100 -0
- realign/commands/config.py +13 -12
- realign/commands/init.py +54 -28
- realign/commands/search.py +57 -31
- realign/commands/session_utils.py +28 -0
- realign/commands/show.py +25 -38
- realign/file_lock.py +120 -0
- realign/hooks.py +267 -42
- realign/mcp_server.py +4 -54
- realign/mcp_watcher.py +356 -253
- aline_ai-0.1.9.dist-info/RECORD +0 -23
- {aline_ai-0.1.9.dist-info → aline_ai-0.2.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.1.9.dist-info → aline_ai-0.2.0.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.1.9.dist-info → aline_ai-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.1.9.dist-info → aline_ai-0.2.0.dist-info}/top_level.txt +0 -0
realign/file_lock.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""File-based locking mechanism for cross-process synchronization."""
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileLock:
|
|
12
|
+
"""Simple file-based lock using fcntl (Unix/macOS only)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, lock_file: Path, timeout: float = 10.0):
|
|
15
|
+
"""
|
|
16
|
+
Initialize a file lock.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
lock_file: Path to the lock file
|
|
20
|
+
timeout: Maximum time to wait for lock acquisition (seconds)
|
|
21
|
+
"""
|
|
22
|
+
self.lock_file = lock_file
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
self.fd: Optional[int] = None
|
|
25
|
+
|
|
26
|
+
def acquire(self, blocking: bool = True) -> bool:
|
|
27
|
+
"""
|
|
28
|
+
Acquire the lock.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
blocking: If True, wait for lock; if False, return immediately
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
True if lock was acquired, False otherwise
|
|
35
|
+
"""
|
|
36
|
+
# Create lock file directory if needed
|
|
37
|
+
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
# Open lock file
|
|
40
|
+
self.fd = os.open(str(self.lock_file), os.O_CREAT | os.O_RDWR)
|
|
41
|
+
|
|
42
|
+
if blocking:
|
|
43
|
+
# Try to acquire with timeout
|
|
44
|
+
start_time = time.time()
|
|
45
|
+
while True:
|
|
46
|
+
try:
|
|
47
|
+
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
48
|
+
return True
|
|
49
|
+
except BlockingIOError:
|
|
50
|
+
if time.time() - start_time > self.timeout:
|
|
51
|
+
os.close(self.fd)
|
|
52
|
+
self.fd = None
|
|
53
|
+
return False
|
|
54
|
+
time.sleep(0.1)
|
|
55
|
+
else:
|
|
56
|
+
# Non-blocking attempt
|
|
57
|
+
try:
|
|
58
|
+
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
59
|
+
return True
|
|
60
|
+
except BlockingIOError:
|
|
61
|
+
os.close(self.fd)
|
|
62
|
+
self.fd = None
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def release(self):
|
|
66
|
+
"""Release the lock."""
|
|
67
|
+
if self.fd is not None:
|
|
68
|
+
try:
|
|
69
|
+
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
|
70
|
+
os.close(self.fd)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
finally:
|
|
74
|
+
self.fd = None
|
|
75
|
+
|
|
76
|
+
def __enter__(self):
|
|
77
|
+
"""Context manager entry."""
|
|
78
|
+
if not self.acquire():
|
|
79
|
+
raise TimeoutError(f"Could not acquire lock on {self.lock_file} within {self.timeout}s")
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
83
|
+
"""Context manager exit."""
|
|
84
|
+
self.release()
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def __del__(self):
|
|
88
|
+
"""Cleanup on deletion."""
|
|
89
|
+
self.release()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@contextmanager
|
|
93
|
+
def commit_lock(repo_path: Path, timeout: float = 10.0):
|
|
94
|
+
"""
|
|
95
|
+
Context manager for acquiring a commit lock.
|
|
96
|
+
|
|
97
|
+
Prevents multiple watchers from committing simultaneously.
|
|
98
|
+
|
|
99
|
+
Usage:
|
|
100
|
+
with commit_lock(repo_path):
|
|
101
|
+
# Perform git commit
|
|
102
|
+
subprocess.run(["git", "commit", ...])
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
repo_path: Path to the repository
|
|
106
|
+
timeout: Maximum time to wait for lock (seconds)
|
|
107
|
+
|
|
108
|
+
Yields:
|
|
109
|
+
True if lock was acquired
|
|
110
|
+
"""
|
|
111
|
+
lock_file = repo_path / ".realign" / ".commit.lock"
|
|
112
|
+
lock = FileLock(lock_file, timeout=timeout)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
if lock.acquire():
|
|
116
|
+
yield True
|
|
117
|
+
else:
|
|
118
|
+
yield False
|
|
119
|
+
finally:
|
|
120
|
+
lock.release()
|
realign/hooks.py
CHANGED
|
@@ -167,6 +167,41 @@ def find_codex_latest_session(project_path: Path, days_back: int = 7) -> Optiona
|
|
|
167
167
|
return matching_sessions[0] if matching_sessions else None
|
|
168
168
|
|
|
169
169
|
|
|
170
|
+
def find_all_claude_sessions() -> List[Path]:
|
|
171
|
+
"""
|
|
172
|
+
Find all active Claude Code sessions from ALL projects.
|
|
173
|
+
|
|
174
|
+
Scans ~/.claude/projects/ and returns the latest session from each project.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
List of session file paths from all Claude projects
|
|
178
|
+
"""
|
|
179
|
+
sessions = []
|
|
180
|
+
claude_base = Path.home() / ".claude" / "projects"
|
|
181
|
+
|
|
182
|
+
if not claude_base.exists():
|
|
183
|
+
logger.debug(f"Claude projects directory not found: {claude_base}")
|
|
184
|
+
return sessions
|
|
185
|
+
|
|
186
|
+
# Iterate through all project directories
|
|
187
|
+
for project_dir in claude_base.iterdir():
|
|
188
|
+
if not project_dir.is_dir():
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
# Skip system directories
|
|
192
|
+
if project_dir.name.startswith('.'):
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
# Find the latest session in this project directory
|
|
196
|
+
session = find_latest_session(project_dir)
|
|
197
|
+
if session:
|
|
198
|
+
sessions.append(session)
|
|
199
|
+
logger.debug(f"Found Claude session in {project_dir.name}: {session.name}")
|
|
200
|
+
|
|
201
|
+
logger.info(f"Found {len(sessions)} Claude session(s) across all projects")
|
|
202
|
+
return sessions
|
|
203
|
+
|
|
204
|
+
|
|
170
205
|
def find_all_active_sessions(
|
|
171
206
|
config: ReAlignConfig,
|
|
172
207
|
project_path: Optional[Path] = None
|
|
@@ -181,13 +216,15 @@ def find_all_active_sessions(
|
|
|
181
216
|
|
|
182
217
|
Args:
|
|
183
218
|
config: Configuration object
|
|
184
|
-
project_path: Optional path to the current project (git repo root)
|
|
219
|
+
project_path: Optional path to the current project (git repo root).
|
|
220
|
+
If None, will find sessions from ALL projects (multi-project mode).
|
|
185
221
|
|
|
186
222
|
Returns:
|
|
187
223
|
List of session file paths (may be empty if no sessions found)
|
|
188
224
|
"""
|
|
189
225
|
logger.info("Searching for active AI sessions")
|
|
190
226
|
logger.debug(f"Config: auto_detect_codex={config.auto_detect_codex}, auto_detect_claude={config.auto_detect_claude}")
|
|
227
|
+
logger.debug(f"Project path: {project_path}")
|
|
191
228
|
|
|
192
229
|
sessions = []
|
|
193
230
|
|
|
@@ -203,15 +240,47 @@ def find_all_active_sessions(
|
|
|
203
240
|
logger.warning(f"No session found at explicit path: {history_path}")
|
|
204
241
|
return sessions
|
|
205
242
|
|
|
243
|
+
# Multi-project mode: find sessions from ALL projects
|
|
244
|
+
if project_path is None:
|
|
245
|
+
logger.info("Multi-project mode: scanning all projects")
|
|
246
|
+
|
|
247
|
+
# Find all Claude sessions if enabled
|
|
248
|
+
if config.auto_detect_claude:
|
|
249
|
+
logger.debug("Scanning all Claude projects")
|
|
250
|
+
claude_sessions = find_all_claude_sessions()
|
|
251
|
+
sessions.extend(claude_sessions)
|
|
252
|
+
|
|
253
|
+
# TODO: Add Codex multi-project support if needed
|
|
254
|
+
# For now, Codex sessions are only found when project_path is specified
|
|
255
|
+
|
|
256
|
+
if sessions:
|
|
257
|
+
logger.info(f"Multi-project scan complete: found {len(sessions)} session(s)")
|
|
258
|
+
return sessions
|
|
259
|
+
|
|
260
|
+
# Fallback: try local history path
|
|
261
|
+
logger.debug("No sessions found in multi-project scan, trying fallback path")
|
|
262
|
+
history_path = config.expanded_local_history_path
|
|
263
|
+
session = find_latest_session(history_path)
|
|
264
|
+
if session:
|
|
265
|
+
sessions.append(session)
|
|
266
|
+
logger.info(f"Found session at fallback path: {session}")
|
|
267
|
+
else:
|
|
268
|
+
logger.warning(f"No session found at fallback path: {history_path}")
|
|
269
|
+
|
|
270
|
+
return sessions
|
|
271
|
+
|
|
272
|
+
# Single-project mode: find sessions for specific project
|
|
273
|
+
logger.info(f"Single-project mode for: {project_path}")
|
|
274
|
+
|
|
206
275
|
# Try Codex auto-detection if enabled
|
|
207
|
-
if config.auto_detect_codex
|
|
276
|
+
if config.auto_detect_codex:
|
|
208
277
|
logger.debug("Attempting Codex auto-detection")
|
|
209
278
|
codex_session = find_codex_latest_session(project_path)
|
|
210
279
|
if codex_session:
|
|
211
280
|
sessions.append(codex_session)
|
|
212
281
|
|
|
213
282
|
# Try Claude auto-detection if enabled
|
|
214
|
-
if config.auto_detect_claude
|
|
283
|
+
if config.auto_detect_claude:
|
|
215
284
|
logger.debug("Attempting Claude auto-detection")
|
|
216
285
|
claude_dir = find_claude_sessions_dir(project_path)
|
|
217
286
|
if claude_dir:
|
|
@@ -328,13 +397,48 @@ def simple_summarize(content: str, max_chars: int = 500) -> str:
|
|
|
328
397
|
summary = " | ".join(summaries[:3])
|
|
329
398
|
return summary[:max_chars]
|
|
330
399
|
|
|
400
|
+
# Fallback: surface the first few non-empty raw lines to give context
|
|
401
|
+
fallback_lines = []
|
|
402
|
+
for raw_line in lines:
|
|
403
|
+
stripped = raw_line.strip()
|
|
404
|
+
if not stripped:
|
|
405
|
+
continue
|
|
406
|
+
# Skip noisy JSON braces-only lines
|
|
407
|
+
if stripped in ("{", "}", "[", "]"):
|
|
408
|
+
continue
|
|
409
|
+
fallback_lines.append(stripped[:120])
|
|
410
|
+
if len(fallback_lines) == 3:
|
|
411
|
+
break
|
|
412
|
+
|
|
413
|
+
if fallback_lines:
|
|
414
|
+
summary = " | ".join(fallback_lines)
|
|
415
|
+
return summary[:max_chars]
|
|
416
|
+
|
|
331
417
|
return f"Session updated with {len(lines)} new lines"
|
|
332
418
|
|
|
333
419
|
|
|
334
|
-
def
|
|
420
|
+
def detect_agent_from_session_path(session_relpath: str) -> str:
|
|
421
|
+
"""Infer agent type based on session filename."""
|
|
422
|
+
lower_path = session_relpath.lower()
|
|
423
|
+
|
|
424
|
+
if "codex" in lower_path or "rollout-" in lower_path:
|
|
425
|
+
return "Codex"
|
|
426
|
+
if "claude" in lower_path or "agent-" in lower_path:
|
|
427
|
+
return "Claude"
|
|
428
|
+
if lower_path.endswith(".jsonl"):
|
|
429
|
+
# Default to Unknown to avoid mislabeling generic files
|
|
430
|
+
return "Unknown"
|
|
431
|
+
return "Unknown"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def generate_summary_with_llm(
|
|
435
|
+
content: str,
|
|
436
|
+
max_chars: int = 500,
|
|
437
|
+
provider: str = "auto"
|
|
438
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
335
439
|
"""
|
|
336
440
|
Generate summary using LLM (Anthropic Claude or OpenAI) for NEW content only.
|
|
337
|
-
Returns None if LLM is
|
|
441
|
+
Returns (summary, model_name) tuple, or (None, None) if LLM is unavailable.
|
|
338
442
|
|
|
339
443
|
Args:
|
|
340
444
|
content: Raw text content of new session additions
|
|
@@ -345,7 +449,7 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
|
|
|
345
449
|
|
|
346
450
|
if not content or not content.strip():
|
|
347
451
|
logger.debug("No content provided for summarization")
|
|
348
|
-
return "No new content in this session"
|
|
452
|
+
return "No new content in this session", None
|
|
349
453
|
|
|
350
454
|
# Truncate content for API (to avoid token limits)
|
|
351
455
|
# Approximately 4000 chars = ~1000 tokens
|
|
@@ -395,13 +499,13 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
|
|
|
395
499
|
logger.info(f"Claude API success: {len(summary)} chars in {elapsed:.2f}s")
|
|
396
500
|
logger.debug(f"Claude response: {summary[:100]}...")
|
|
397
501
|
print(" ✅ Anthropic (Claude) summary successful", file=sys.stderr)
|
|
398
|
-
return summary[:max_chars]
|
|
502
|
+
return summary[:max_chars], "claude-3-5-haiku-20241022"
|
|
399
503
|
|
|
400
504
|
except ImportError:
|
|
401
505
|
logger.warning("Anthropic package not installed")
|
|
402
506
|
if provider == "claude":
|
|
403
507
|
print(" ❌ Anthropic package not installed", file=sys.stderr)
|
|
404
|
-
return None
|
|
508
|
+
return None, None
|
|
405
509
|
else:
|
|
406
510
|
print(" ❌ Anthropic package not installed, trying OpenAI...", file=sys.stderr)
|
|
407
511
|
except Exception as e:
|
|
@@ -417,7 +521,7 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
|
|
|
417
521
|
print(f" ❌ Anthropic quota/credit issue", file=sys.stderr)
|
|
418
522
|
else:
|
|
419
523
|
print(f" ❌ Anthropic API error: {e}", file=sys.stderr)
|
|
420
|
-
return None
|
|
524
|
+
return None, None
|
|
421
525
|
else:
|
|
422
526
|
# Auto mode: try falling back to OpenAI
|
|
423
527
|
if "authentication" in error_msg.lower() or "invalid" in error_msg.lower():
|
|
@@ -433,7 +537,7 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
|
|
|
433
537
|
logger.debug("ANTHROPIC_API_KEY not set")
|
|
434
538
|
if provider == "claude":
|
|
435
539
|
print(" ❌ ANTHROPIC_API_KEY not set", file=sys.stderr)
|
|
436
|
-
return None
|
|
540
|
+
return None, None
|
|
437
541
|
else:
|
|
438
542
|
print(" ⓘ ANTHROPIC_API_KEY not set, trying OpenAI...", file=sys.stderr)
|
|
439
543
|
|
|
@@ -471,12 +575,12 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
|
|
|
471
575
|
logger.info(f"OpenAI API success: {len(summary)} chars in {elapsed:.2f}s")
|
|
472
576
|
logger.debug(f"OpenAI response: {summary[:100]}...")
|
|
473
577
|
print(" ✅ OpenAI (GPT) summary successful", file=sys.stderr)
|
|
474
|
-
return summary[:max_chars]
|
|
578
|
+
return summary[:max_chars], "gpt-3.5-turbo"
|
|
475
579
|
|
|
476
580
|
except ImportError:
|
|
477
581
|
logger.warning("OpenAI package not installed")
|
|
478
582
|
print(" ❌ OpenAI package not installed", file=sys.stderr)
|
|
479
|
-
return None
|
|
583
|
+
return None, None
|
|
480
584
|
except Exception as e:
|
|
481
585
|
error_msg = str(e)
|
|
482
586
|
logger.error(f"OpenAI API error: {error_msg}", exc_info=True)
|
|
@@ -488,17 +592,17 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
|
|
|
488
592
|
print(f" ❌ OpenAI quota/billing issue", file=sys.stderr)
|
|
489
593
|
else:
|
|
490
594
|
print(f" ❌ OpenAI API error: {e}", file=sys.stderr)
|
|
491
|
-
return None
|
|
595
|
+
return None, None
|
|
492
596
|
elif try_openai:
|
|
493
597
|
logger.debug("OPENAI_API_KEY not set")
|
|
494
598
|
print(" ❌ OPENAI_API_KEY not set", file=sys.stderr)
|
|
495
|
-
return None
|
|
599
|
+
return None, None
|
|
496
600
|
|
|
497
601
|
# No API keys available or provider not configured
|
|
498
602
|
logger.warning(f"No LLM API keys available (provider: {provider})")
|
|
499
603
|
if provider == "auto":
|
|
500
604
|
print(" ❌ No LLM API keys configured", file=sys.stderr)
|
|
501
|
-
return None
|
|
605
|
+
return None, None
|
|
502
606
|
|
|
503
607
|
|
|
504
608
|
def generate_session_filename(user: str, agent: str = "claude") -> str:
|
|
@@ -509,6 +613,63 @@ def generate_session_filename(user: str, agent: str = "claude") -> str:
|
|
|
509
613
|
return f"{timestamp}_{user_short}_{agent}_{short_id}.jsonl"
|
|
510
614
|
|
|
511
615
|
|
|
616
|
+
def extract_codex_rollout_hash(filename: str) -> Optional[str]:
|
|
617
|
+
"""
|
|
618
|
+
Extract stable hash from Codex rollout filename.
|
|
619
|
+
|
|
620
|
+
Primary Codex rollout format:
|
|
621
|
+
rollout-YYYY-MM-DDTHH-MM-SS-<uuid>.jsonl
|
|
622
|
+
Example: rollout-2025-11-16T18-10-42-019a8ddc-b4b3-7942-9a4f-fac74d1580c9.jsonl
|
|
623
|
+
-> 019a8ddc-b4b3-7942-9a4f-fac74d1580c9
|
|
624
|
+
|
|
625
|
+
Legacy format (still supported):
|
|
626
|
+
rollout-<timestamp>-<hash>.jsonl
|
|
627
|
+
Example: rollout-1763315655-abc123def.jsonl -> abc123def
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
filename: Original Codex rollout filename
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
Hash string, or None if parsing fails
|
|
634
|
+
"""
|
|
635
|
+
if not filename.startswith("rollout-"):
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
# Normalize filename (strip extension) and remove prefix
|
|
639
|
+
stem = Path(filename).stem
|
|
640
|
+
if stem.startswith("rollout-"):
|
|
641
|
+
stem = stem[len("rollout-"):]
|
|
642
|
+
|
|
643
|
+
if not stem:
|
|
644
|
+
return None
|
|
645
|
+
|
|
646
|
+
def looks_like_uuid(value: str) -> bool:
|
|
647
|
+
"""Return True if value matches canonical UUID format."""
|
|
648
|
+
parts = value.split("-")
|
|
649
|
+
expected_lengths = [8, 4, 4, 4, 12]
|
|
650
|
+
if len(parts) != 5:
|
|
651
|
+
return False
|
|
652
|
+
hex_digits = set("0123456789abcdefABCDEF")
|
|
653
|
+
for part, length in zip(parts, expected_lengths):
|
|
654
|
+
if len(part) != length or not set(part).issubset(hex_digits):
|
|
655
|
+
return False
|
|
656
|
+
return True
|
|
657
|
+
|
|
658
|
+
# Newer Codex exports append a full UUID after the human-readable timestamp.
|
|
659
|
+
uuid_candidate_parts = stem.rsplit("-", 5)
|
|
660
|
+
if len(uuid_candidate_parts) == 6:
|
|
661
|
+
candidate_uuid = "-".join(uuid_candidate_parts[1:])
|
|
662
|
+
if looks_like_uuid(candidate_uuid):
|
|
663
|
+
return candidate_uuid.lower()
|
|
664
|
+
|
|
665
|
+
# Fallback for legacy rollout names: everything after first '-' is the hash.
|
|
666
|
+
legacy_parts = stem.split("-", 1)
|
|
667
|
+
if len(legacy_parts) == 2 and legacy_parts[1]:
|
|
668
|
+
return legacy_parts[1]
|
|
669
|
+
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
|
|
512
673
|
def get_git_user() -> str:
|
|
513
674
|
"""Get git user name."""
|
|
514
675
|
try:
|
|
@@ -551,6 +712,8 @@ def copy_session_to_repo(
|
|
|
551
712
|
'_' not in stem and
|
|
552
713
|
len(stem) == 36 # UUID is 36 chars including hyphens
|
|
553
714
|
)
|
|
715
|
+
# Codex rollout exports always start with rollout-<timestamp>-
|
|
716
|
+
is_codex_rollout = original_filename.startswith("rollout-")
|
|
554
717
|
|
|
555
718
|
# Read session content first to detect agent type
|
|
556
719
|
try:
|
|
@@ -566,6 +729,16 @@ def copy_session_to_repo(
|
|
|
566
729
|
user_short = user.split()[0].lower() if user else "unknown"
|
|
567
730
|
new_filename = f"{user_short}_unknown_{short_id}.jsonl"
|
|
568
731
|
dest_path = sessions_dir / new_filename
|
|
732
|
+
elif is_codex_rollout:
|
|
733
|
+
# Extract stable hash from rollout filename
|
|
734
|
+
rollout_hash = extract_codex_rollout_hash(original_filename)
|
|
735
|
+
user_short = user.split()[0].lower() if user else "unknown"
|
|
736
|
+
if rollout_hash:
|
|
737
|
+
new_filename = f"{user_short}_codex_{rollout_hash}.jsonl"
|
|
738
|
+
else:
|
|
739
|
+
# Fallback if hash extraction fails
|
|
740
|
+
new_filename = generate_session_filename(user, "codex")
|
|
741
|
+
dest_path = sessions_dir / new_filename
|
|
569
742
|
else:
|
|
570
743
|
dest_path = sessions_dir / original_filename
|
|
571
744
|
temp_path = dest_path.with_suffix(".tmp")
|
|
@@ -611,6 +784,18 @@ def copy_session_to_repo(
|
|
|
611
784
|
# Format: username_agent_shortid.jsonl (no timestamp for consistency)
|
|
612
785
|
new_filename = f"{user_short}_{agent_type}_{short_id}.jsonl"
|
|
613
786
|
dest_path = sessions_dir / new_filename
|
|
787
|
+
elif is_codex_rollout:
|
|
788
|
+
# Extract stable hash from rollout filename
|
|
789
|
+
codex_agent = agent_type if agent_type != "unknown" else "codex"
|
|
790
|
+
rollout_hash = extract_codex_rollout_hash(original_filename)
|
|
791
|
+
user_short = user.split()[0].lower() if user else "unknown"
|
|
792
|
+
if rollout_hash:
|
|
793
|
+
# Format: username_codex_hash.jsonl (stable naming)
|
|
794
|
+
new_filename = f"{user_short}_{codex_agent}_{rollout_hash}.jsonl"
|
|
795
|
+
else:
|
|
796
|
+
# Fallback if hash extraction fails
|
|
797
|
+
new_filename = generate_session_filename(user, codex_agent)
|
|
798
|
+
dest_path = sessions_dir / new_filename
|
|
614
799
|
else:
|
|
615
800
|
# Keep original filename (could be timestamp_user_agent_id format or other)
|
|
616
801
|
dest_path = sessions_dir / original_filename
|
|
@@ -678,7 +863,7 @@ def process_sessions(
|
|
|
678
863
|
user: User name override (optional)
|
|
679
864
|
|
|
680
865
|
Returns:
|
|
681
|
-
Dictionary with keys: summary, session_relpaths, redacted
|
|
866
|
+
Dictionary with keys: summary, session_relpaths, redacted, summary_entries, summary_model
|
|
682
867
|
"""
|
|
683
868
|
import time
|
|
684
869
|
start_time = time.time()
|
|
@@ -746,7 +931,13 @@ def process_sessions(
|
|
|
746
931
|
|
|
747
932
|
if not session_relpaths:
|
|
748
933
|
logger.warning("No session files copied successfully")
|
|
749
|
-
return {
|
|
934
|
+
return {
|
|
935
|
+
"summary": "",
|
|
936
|
+
"session_relpaths": [],
|
|
937
|
+
"redacted": False,
|
|
938
|
+
"summary_entries": [],
|
|
939
|
+
"summary_model": "",
|
|
940
|
+
}
|
|
750
941
|
|
|
751
942
|
logger.info(f"Copied {len(session_relpaths)} session(s): {session_relpaths}")
|
|
752
943
|
|
|
@@ -758,6 +949,8 @@ def process_sessions(
|
|
|
758
949
|
"summary": "",
|
|
759
950
|
"session_relpaths": session_relpaths,
|
|
760
951
|
"redacted": any_redacted,
|
|
952
|
+
"summary_entries": [],
|
|
953
|
+
"summary_model": "",
|
|
761
954
|
}
|
|
762
955
|
|
|
763
956
|
# For prepare-commit-msg mode, we need to stage files first to get accurate diff
|
|
@@ -776,7 +969,9 @@ def process_sessions(
|
|
|
776
969
|
|
|
777
970
|
# For prepare-commit-msg mode, generate summary from all sessions
|
|
778
971
|
logger.info("Generating summaries for sessions")
|
|
779
|
-
|
|
972
|
+
summary_entries: List[Dict[str, str]] = []
|
|
973
|
+
legacy_summary_chunks: List[str] = []
|
|
974
|
+
summary_model_label: Optional[str] = None
|
|
780
975
|
|
|
781
976
|
for session_relpath in session_relpaths:
|
|
782
977
|
# Extract NEW content using git diff
|
|
@@ -787,37 +982,50 @@ def process_sessions(
|
|
|
787
982
|
continue
|
|
788
983
|
|
|
789
984
|
# Generate summary for NEW content only
|
|
790
|
-
|
|
985
|
+
summary_text: Optional[str] = None
|
|
986
|
+
is_llm_summary = False
|
|
987
|
+
llm_model_name: Optional[str] = None
|
|
791
988
|
if config.use_LLM:
|
|
792
989
|
print(f"🤖 Attempting to generate LLM summary (provider: {config.llm_provider})...", file=sys.stderr)
|
|
793
|
-
|
|
990
|
+
summary_text, llm_model_name = generate_summary_with_llm(
|
|
991
|
+
new_content,
|
|
992
|
+
config.summary_max_chars,
|
|
993
|
+
config.llm_provider
|
|
994
|
+
)
|
|
794
995
|
|
|
795
|
-
if
|
|
996
|
+
if summary_text:
|
|
796
997
|
print("✅ LLM summary generated successfully", file=sys.stderr)
|
|
998
|
+
is_llm_summary = True
|
|
999
|
+
if summary_model_label is None:
|
|
1000
|
+
summary_model_label = llm_model_name or config.llm_provider
|
|
797
1001
|
else:
|
|
798
1002
|
print("⚠️ LLM summary failed - falling back to local summarization", file=sys.stderr)
|
|
799
1003
|
print(" Check your API keys: ANTHROPIC_API_KEY or OPENAI_API_KEY", file=sys.stderr)
|
|
800
1004
|
|
|
801
|
-
if not
|
|
1005
|
+
if not summary_text:
|
|
802
1006
|
# Fallback to simple summarize
|
|
803
1007
|
logger.info("Using local summarization (no LLM)")
|
|
804
1008
|
print("📝 Using local summarization (no LLM)", file=sys.stderr)
|
|
805
|
-
|
|
1009
|
+
summary_text = simple_summarize(new_content, config.summary_max_chars)
|
|
806
1010
|
|
|
807
1011
|
# Identify agent type from filename
|
|
808
|
-
agent_name =
|
|
809
|
-
if "rollout-" in session_relpath:
|
|
810
|
-
agent_name = "Codex"
|
|
811
|
-
elif "agent-" in session_relpath or ".jsonl" in session_relpath:
|
|
812
|
-
agent_name = "Claude"
|
|
1012
|
+
agent_name = detect_agent_from_session_path(session_relpath)
|
|
813
1013
|
|
|
814
|
-
|
|
815
|
-
|
|
1014
|
+
summary_text = summary_text.strip()
|
|
1015
|
+
logger.debug(f"Summary for {session_relpath} ({agent_name}): {summary_text[:100]}...")
|
|
1016
|
+
summary_entries.append({
|
|
1017
|
+
"agent": agent_name,
|
|
1018
|
+
"text": summary_text,
|
|
1019
|
+
"source": "llm" if is_llm_summary else "local",
|
|
1020
|
+
})
|
|
1021
|
+
legacy_summary_chunks.append(f"[{agent_name}] {summary_text}")
|
|
816
1022
|
|
|
817
1023
|
# Combine all summaries
|
|
818
|
-
if
|
|
819
|
-
|
|
820
|
-
|
|
1024
|
+
if summary_entries:
|
|
1025
|
+
if summary_model_label is None:
|
|
1026
|
+
summary_model_label = "Local summarizer"
|
|
1027
|
+
combined_summary = " | ".join(legacy_summary_chunks)
|
|
1028
|
+
logger.info(f"Generated {len(summary_entries)} summary(ies)")
|
|
821
1029
|
else:
|
|
822
1030
|
combined_summary = "No new content in sessions"
|
|
823
1031
|
logger.info("No summaries generated (no new content)")
|
|
@@ -829,6 +1037,8 @@ def process_sessions(
|
|
|
829
1037
|
"summary": combined_summary,
|
|
830
1038
|
"session_relpaths": session_relpaths,
|
|
831
1039
|
"redacted": any_redacted,
|
|
1040
|
+
"summary_entries": summary_entries,
|
|
1041
|
+
"summary_model": summary_model_label or "",
|
|
832
1042
|
}
|
|
833
1043
|
|
|
834
1044
|
|
|
@@ -875,22 +1085,37 @@ def prepare_commit_msg_hook():
|
|
|
875
1085
|
Generates session summary and appends to commit message.
|
|
876
1086
|
"""
|
|
877
1087
|
# Get commit message file path from command line arguments
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1088
|
+
# When called via __main__ with --prepare-commit-msg flag, the file is at index 2
|
|
1089
|
+
# When called directly as a hook entry point, the file is at index 1
|
|
1090
|
+
if sys.argv[1] == "--prepare-commit-msg":
|
|
1091
|
+
# Called via: python -m realign.hooks --prepare-commit-msg <msg-file> <source>
|
|
1092
|
+
if len(sys.argv) < 3:
|
|
1093
|
+
print("Error: Commit message file path not provided", file=sys.stderr)
|
|
1094
|
+
sys.exit(1)
|
|
1095
|
+
msg_file = sys.argv[2]
|
|
1096
|
+
else:
|
|
1097
|
+
# Called via: realign-hook-prepare-commit-msg <msg-file> <source>
|
|
1098
|
+
msg_file = sys.argv[1]
|
|
883
1099
|
|
|
884
1100
|
# Process sessions and generate summary
|
|
885
1101
|
result = process_sessions(pre_commit_mode=False)
|
|
886
1102
|
|
|
887
1103
|
# Append summary to commit message
|
|
888
|
-
|
|
1104
|
+
summary_entries = result.get("summary_entries") or []
|
|
1105
|
+
if summary_entries:
|
|
889
1106
|
try:
|
|
890
1107
|
with open(msg_file, "a", encoding="utf-8") as f:
|
|
891
|
-
|
|
892
|
-
f.write(
|
|
893
|
-
|
|
1108
|
+
summary_model = result.get("summary_model") or "Local summarizer"
|
|
1109
|
+
f.write("\n\n")
|
|
1110
|
+
f.write(f"--- LLM-Summary ({summary_model}) ---\n")
|
|
1111
|
+
for entry in summary_entries:
|
|
1112
|
+
agent_label = entry.get("agent", "Agent")
|
|
1113
|
+
text = (entry.get("text") or "").strip()
|
|
1114
|
+
if not text:
|
|
1115
|
+
continue
|
|
1116
|
+
f.write(f"* [{agent_label}] {text}\n")
|
|
1117
|
+
f.write("\n")
|
|
1118
|
+
if result.get("redacted"):
|
|
894
1119
|
f.write("Agent-Redacted: true\n")
|
|
895
1120
|
except Exception as e:
|
|
896
1121
|
print(f"Warning: Could not append to commit message: {e}", file=sys.stderr)
|