aline-ai 0.2.5__py3-none-any.whl → 0.3.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.2.5.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
- aline_ai-0.3.0.dist-info/RECORD +41 -0
- aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
- realign/__init__.py +32 -1
- realign/cli.py +203 -19
- realign/commands/__init__.py +2 -2
- realign/commands/clean.py +149 -0
- realign/commands/config.py +1 -1
- realign/commands/export_shares.py +1785 -0
- realign/commands/hide.py +112 -24
- realign/commands/import_history.py +873 -0
- realign/commands/init.py +104 -217
- realign/commands/mirror.py +131 -0
- realign/commands/pull.py +101 -0
- realign/commands/push.py +155 -245
- realign/commands/review.py +216 -54
- realign/commands/session_utils.py +139 -4
- realign/commands/share.py +965 -0
- realign/commands/status.py +559 -0
- realign/commands/sync.py +91 -0
- realign/commands/undo.py +423 -0
- realign/commands/watcher.py +805 -0
- realign/config.py +21 -10
- realign/file_lock.py +3 -1
- realign/hash_registry.py +310 -0
- realign/hooks.py +368 -384
- realign/logging_config.py +2 -2
- realign/mcp_server.py +263 -549
- realign/mcp_watcher.py +999 -142
- realign/mirror_utils.py +322 -0
- realign/prompts/__init__.py +21 -0
- realign/prompts/presets.py +238 -0
- realign/redactor.py +168 -16
- realign/tracker/__init__.py +9 -0
- realign/tracker/git_tracker.py +1123 -0
- realign/watcher_daemon.py +115 -0
- aline_ai-0.2.5.dist-info/RECORD +0 -28
- aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
- realign/commands/auto_commit.py +0 -231
- realign/commands/commit.py +0 -379
- realign/commands/search.py +0 -449
- realign/commands/show.py +0 -416
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
realign/hooks.py
CHANGED
|
@@ -7,6 +7,7 @@ invoked directly from git hooks without copying any Python files to the target r
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import sys
|
|
11
12
|
import json
|
|
12
13
|
import time
|
|
@@ -30,6 +31,53 @@ except ImportError:
|
|
|
30
31
|
logger = setup_logger('realign.hooks', 'hooks.log')
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
# ============================================================================
|
|
35
|
+
# Message Cleaning Utilities
|
|
36
|
+
# ============================================================================
|
|
37
|
+
|
|
38
|
+
def clean_user_message(text: str) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Clean user message by removing IDE context tags and other system noise.
|
|
41
|
+
|
|
42
|
+
This function removes IDE-generated context that's not part of the actual
|
|
43
|
+
user intent, making commit messages and session logs cleaner.
|
|
44
|
+
|
|
45
|
+
Removes:
|
|
46
|
+
- <ide_opened_file>...</ide_opened_file> tags
|
|
47
|
+
- <ide_selection>...</ide_selection> tags
|
|
48
|
+
- System interrupt messages like "[Request interrupted by user for tool use]"
|
|
49
|
+
- Other system-generated context tags
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
text: Raw user message text
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Cleaned message text with system tags removed, or empty string if message is purely system-generated
|
|
56
|
+
"""
|
|
57
|
+
if not text:
|
|
58
|
+
return text
|
|
59
|
+
|
|
60
|
+
# Check for system interrupt messages first (return empty for these)
|
|
61
|
+
# These are generated when user stops the AI mid-execution
|
|
62
|
+
if text.strip() == "[Request interrupted by user for tool use]":
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
# Remove IDE opened file tags
|
|
66
|
+
text = re.sub(r'<ide_opened_file>.*?</ide_opened_file>\s*', '', text, flags=re.DOTALL)
|
|
67
|
+
|
|
68
|
+
# Remove IDE selection tags
|
|
69
|
+
text = re.sub(r'<ide_selection>.*?</ide_selection>\s*', '', text, flags=re.DOTALL)
|
|
70
|
+
|
|
71
|
+
# Remove other common system tags if needed
|
|
72
|
+
# text = re.sub(r'<system_context>.*?</system_context>\s*', '', text, flags=re.DOTALL)
|
|
73
|
+
|
|
74
|
+
# Clean up extra whitespace
|
|
75
|
+
text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text) # Replace multiple blank lines with double newline
|
|
76
|
+
text = text.strip()
|
|
77
|
+
|
|
78
|
+
return text
|
|
79
|
+
|
|
80
|
+
|
|
33
81
|
def get_new_content_from_git_diff(repo_root: Path, session_relpath: str) -> str:
|
|
34
82
|
"""
|
|
35
83
|
Extract new content added in this commit by using git diff.
|
|
@@ -356,6 +404,115 @@ def find_latest_session(history_path: Path, explicit_path: Optional[str] = None)
|
|
|
356
404
|
return max(session_files, key=lambda p: p.stat().st_mtime)
|
|
357
405
|
|
|
358
406
|
|
|
407
|
+
def filter_session_content(content: str) -> Tuple[str, str, str]:
|
|
408
|
+
"""
|
|
409
|
+
Filter session content to extract meaningful information for LLM summarization.
|
|
410
|
+
|
|
411
|
+
Filters out exploratory operations (Read, Grep, Glob) and technical details,
|
|
412
|
+
keeping only user requests, AI responses, and code changes.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
content: Raw text content of new session additions
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Tuple of (user_messages, assistant_replies, code_changes)
|
|
419
|
+
"""
|
|
420
|
+
if not content or not content.strip():
|
|
421
|
+
return "", "", ""
|
|
422
|
+
|
|
423
|
+
user_messages = []
|
|
424
|
+
assistant_replies = []
|
|
425
|
+
code_changes = []
|
|
426
|
+
|
|
427
|
+
lines = content.strip().split("\n")
|
|
428
|
+
|
|
429
|
+
for line in lines:
|
|
430
|
+
line = line.strip()
|
|
431
|
+
if not line:
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
obj = json.loads(line)
|
|
436
|
+
|
|
437
|
+
# Extract user messages and tool results
|
|
438
|
+
if obj.get("type") == "user":
|
|
439
|
+
msg = obj.get("message", {})
|
|
440
|
+
if isinstance(msg, dict):
|
|
441
|
+
content_data = msg.get("content", "")
|
|
442
|
+
if isinstance(content_data, str) and content_data.strip():
|
|
443
|
+
user_messages.append(content_data.strip())
|
|
444
|
+
elif isinstance(content_data, list):
|
|
445
|
+
# Extract text from content list
|
|
446
|
+
for item in content_data:
|
|
447
|
+
if isinstance(item, dict):
|
|
448
|
+
if item.get("type") == "text":
|
|
449
|
+
text = item.get("text", "").strip()
|
|
450
|
+
if text:
|
|
451
|
+
user_messages.append(text)
|
|
452
|
+
# Extract code changes from tool results
|
|
453
|
+
elif item.get("type") == "tool_result":
|
|
454
|
+
tool_use_result = obj.get("toolUseResult", {})
|
|
455
|
+
if "oldString" in tool_use_result and "newString" in tool_use_result:
|
|
456
|
+
# This is an Edit operation
|
|
457
|
+
new_string = tool_use_result.get("newString", "")
|
|
458
|
+
if new_string:
|
|
459
|
+
code_changes.append(f"Edit: {new_string[:300]}")
|
|
460
|
+
elif "content" in tool_use_result and "filePath" in tool_use_result:
|
|
461
|
+
# This is a Write operation
|
|
462
|
+
new_content = tool_use_result.get("content", "")
|
|
463
|
+
if new_content:
|
|
464
|
+
code_changes.append(f"Write: {new_content[:300]}")
|
|
465
|
+
|
|
466
|
+
# Extract assistant text replies (not tool use)
|
|
467
|
+
elif obj.get("type") == "assistant":
|
|
468
|
+
msg = obj.get("message", {})
|
|
469
|
+
if isinstance(msg, dict):
|
|
470
|
+
content_data = msg.get("content", [])
|
|
471
|
+
if isinstance(content_data, list):
|
|
472
|
+
for item in content_data:
|
|
473
|
+
if isinstance(item, dict):
|
|
474
|
+
# Only extract text blocks, skip tool_use blocks
|
|
475
|
+
if item.get("type") == "text":
|
|
476
|
+
text = item.get("text", "").strip()
|
|
477
|
+
if text:
|
|
478
|
+
assistant_replies.append(text)
|
|
479
|
+
# Extract code changes from Edit/Write tool uses
|
|
480
|
+
elif item.get("type") == "tool_use":
|
|
481
|
+
tool_name = item.get("name", "")
|
|
482
|
+
if tool_name in ("Edit", "Write"):
|
|
483
|
+
params = item.get("input", {})
|
|
484
|
+
if tool_name == "Edit":
|
|
485
|
+
new_string = params.get("new_string", "")
|
|
486
|
+
if new_string:
|
|
487
|
+
code_changes.append(f"Edit: {new_string[:200]}")
|
|
488
|
+
elif tool_name == "Write":
|
|
489
|
+
new_content = params.get("content", "")
|
|
490
|
+
if new_content:
|
|
491
|
+
code_changes.append(f"Write: {new_content[:200]}")
|
|
492
|
+
|
|
493
|
+
# Also handle simple role/content format (for compatibility)
|
|
494
|
+
elif obj.get("role") == "user":
|
|
495
|
+
content_text = obj.get("content", "")
|
|
496
|
+
if isinstance(content_text, str) and content_text.strip():
|
|
497
|
+
user_messages.append(content_text.strip())
|
|
498
|
+
|
|
499
|
+
elif obj.get("role") == "assistant":
|
|
500
|
+
content_text = obj.get("content", "")
|
|
501
|
+
if isinstance(content_text, str) and content_text.strip():
|
|
502
|
+
assistant_replies.append(content_text.strip())
|
|
503
|
+
|
|
504
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
505
|
+
# Not JSON or doesn't have expected structure, skip
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
# Join with newlines for better readability
|
|
509
|
+
user_str = "\n".join(user_messages) if user_messages else ""
|
|
510
|
+
assistant_str = "\n".join(assistant_replies) if assistant_replies else ""
|
|
511
|
+
code_str = "\n".join(code_changes) if code_changes else ""
|
|
512
|
+
|
|
513
|
+
return user_str, assistant_str, code_str
|
|
514
|
+
|
|
515
|
+
|
|
359
516
|
def simple_summarize(content: str, max_chars: int = 500) -> str:
|
|
360
517
|
"""
|
|
361
518
|
Generate a simple summary from new session content.
|
|
@@ -445,34 +602,78 @@ def generate_summary_with_llm(
|
|
|
445
602
|
content: str,
|
|
446
603
|
max_chars: int = 500,
|
|
447
604
|
provider: str = "auto"
|
|
448
|
-
) -> Tuple[Optional[str], Optional[str]]:
|
|
605
|
+
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
449
606
|
"""
|
|
450
607
|
Generate summary using LLM (Anthropic Claude or OpenAI) for NEW content only.
|
|
451
|
-
Returns (
|
|
608
|
+
Returns (title, model_name, description) tuple, or (None, None, None) if LLM is unavailable.
|
|
452
609
|
|
|
453
610
|
Args:
|
|
454
611
|
content: Raw text content of new session additions
|
|
455
|
-
max_chars: Maximum characters in summary
|
|
612
|
+
max_chars: Maximum characters in summary (not used, kept for compatibility)
|
|
456
613
|
provider: LLM provider to use - "auto" (try Claude then OpenAI), "claude", or "openai"
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
Tuple of (title, model_name, description) where:
|
|
617
|
+
- title: One-line summary (max 150 chars)
|
|
618
|
+
- model_name: Name of the model used
|
|
619
|
+
- description: Detailed description of what happened (200-400 chars)
|
|
457
620
|
"""
|
|
458
|
-
logger.info(f"Attempting to generate LLM summary (provider: {provider}
|
|
621
|
+
logger.info(f"Attempting to generate LLM summary (provider: {provider})")
|
|
459
622
|
|
|
460
623
|
if not content or not content.strip():
|
|
461
624
|
logger.debug("No content provided for summarization")
|
|
462
|
-
return "No new content in this session", None
|
|
463
|
-
|
|
464
|
-
#
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
625
|
+
return "No new content in this session", None, ""
|
|
626
|
+
|
|
627
|
+
# Filter content to extract meaningful information
|
|
628
|
+
user_messages, assistant_replies, code_changes = filter_session_content(content)
|
|
629
|
+
|
|
630
|
+
# If no meaningful content after filtering, return early
|
|
631
|
+
if not user_messages and not assistant_replies and not code_changes:
|
|
632
|
+
logger.debug("No meaningful content after filtering")
|
|
633
|
+
return "Session update with no significant changes", None, "No significant changes detected in this session"
|
|
634
|
+
|
|
635
|
+
# System prompt for structured summarization
|
|
636
|
+
system_prompt = """You are a git commit message generator for AI chat sessions.
|
|
637
|
+
Analyze the conversation and code changes, then generate a summary in JSON format:
|
|
638
|
+
{
|
|
639
|
+
"title": "One-line summary (imperative mood, like 'Add feature X' or 'Fix bug in Y'. Aim for 80-120 chars, up to 150 max. Use complete words only - never truncate words. Omit articles like 'the', 'a' when possible to save space)",
|
|
640
|
+
"description": "Detailed description of what happened in this session. Aim for 300-600 words - be thorough and complete. Focus on key actions, decisions, and outcomes. Include specific details like function names, features discussed, bugs fixed, etc. NEVER truncate - write complete sentences."
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
IMPORTANT for title:
|
|
644
|
+
- Keep it concise (80-120 chars ideal, 150 max) but COMPLETE - no truncated words
|
|
645
|
+
- Use imperative mood (e.g., "Add", "Fix", "Refactor", "Implement")
|
|
646
|
+
- If you can't fit everything, prioritize the most important change
|
|
647
|
+
|
|
648
|
+
IMPORTANT for description:
|
|
649
|
+
- Be thorough and complete (300-600 words)
|
|
650
|
+
- Focus on WHAT was accomplished and WHY, not HOW
|
|
651
|
+
- Include technical details: function names, module names, specific features
|
|
652
|
+
- Mention key decisions or discussions
|
|
653
|
+
- Write in clear, complete sentences - grammar matters for readability
|
|
654
|
+
- NEVER truncate mid-sentence - always finish your thought
|
|
655
|
+
- Avoid mentioning tool names like 'Edit', 'Write', 'Read'
|
|
656
|
+
- For discussions without code: summarize the topics and conclusions
|
|
657
|
+
- For code changes: describe what was changed and the purpose
|
|
658
|
+
- If there were multiple related changes, group them logically
|
|
659
|
+
|
|
660
|
+
Return JSON only, no other text."""
|
|
661
|
+
|
|
662
|
+
# Build user prompt with filtered content
|
|
663
|
+
user_prompt_parts = ["Summarize this AI chat session:\n"]
|
|
664
|
+
|
|
665
|
+
if user_messages:
|
|
666
|
+
user_prompt_parts.append(f"User requests:\n{user_messages[:1500]}\n")
|
|
667
|
+
|
|
668
|
+
if assistant_replies:
|
|
669
|
+
user_prompt_parts.append(f"AI responses:\n{assistant_replies[:1500]}\n")
|
|
670
|
+
|
|
671
|
+
if code_changes:
|
|
672
|
+
user_prompt_parts.append(f"Code changes:\n{code_changes[:1500]}\n")
|
|
673
|
+
|
|
674
|
+
user_prompt_parts.append("\nReturn JSON only, no other text.")
|
|
675
|
+
|
|
676
|
+
user_prompt = "\n".join(user_prompt_parts)
|
|
476
677
|
|
|
477
678
|
# Determine which providers to try based on the provider parameter
|
|
478
679
|
try_claude = provider in ("auto", "claude")
|
|
@@ -493,7 +694,7 @@ def generate_summary_with_llm(
|
|
|
493
694
|
|
|
494
695
|
response = client.messages.create(
|
|
495
696
|
model="claude-3-5-haiku-20241022", # Fast and cost-effective
|
|
496
|
-
max_tokens=
|
|
697
|
+
max_tokens=1000, # Increased to allow complete descriptions (300-600 words)
|
|
497
698
|
temperature=0.7,
|
|
498
699
|
system=system_prompt,
|
|
499
700
|
messages=[
|
|
@@ -505,17 +706,57 @@ def generate_summary_with_llm(
|
|
|
505
706
|
)
|
|
506
707
|
|
|
507
708
|
elapsed = time.time() - start_time
|
|
508
|
-
|
|
509
|
-
logger.info(f"Claude API success: {len(
|
|
510
|
-
logger.debug(f"Claude response: {
|
|
511
|
-
|
|
512
|
-
|
|
709
|
+
response_text = response.content[0].text.strip()
|
|
710
|
+
logger.info(f"Claude API success: {len(response_text)} chars in {elapsed:.2f}s")
|
|
711
|
+
logger.debug(f"Claude response: {response_text[:200]}...")
|
|
712
|
+
|
|
713
|
+
# Parse JSON response
|
|
714
|
+
try:
|
|
715
|
+
# Try to extract JSON if wrapped in markdown code blocks
|
|
716
|
+
json_str = response_text
|
|
717
|
+
if "```json" in response_text:
|
|
718
|
+
json_start = response_text.find("```json") + 7
|
|
719
|
+
json_end = response_text.find("```", json_start)
|
|
720
|
+
# Only extract if closing ``` was found
|
|
721
|
+
if json_end != -1:
|
|
722
|
+
json_str = response_text[json_start:json_end].strip()
|
|
723
|
+
elif "```" in response_text:
|
|
724
|
+
json_start = response_text.find("```") + 3
|
|
725
|
+
json_end = response_text.find("```", json_start)
|
|
726
|
+
# Only extract if closing ``` was found
|
|
727
|
+
if json_end != -1:
|
|
728
|
+
json_str = response_text[json_start:json_end].strip()
|
|
729
|
+
|
|
730
|
+
summary_data = json.loads(json_str)
|
|
731
|
+
title = summary_data.get("title", "")
|
|
732
|
+
description = summary_data.get("description", "")
|
|
733
|
+
|
|
734
|
+
# Validate title is not just brackets or very short
|
|
735
|
+
if not title or len(title.strip()) < 2:
|
|
736
|
+
logger.warning(f"Generated title is empty or too short: '{title}'")
|
|
737
|
+
raise json.JSONDecodeError("Title validation failed", json_str, 0)
|
|
738
|
+
|
|
739
|
+
print(" ✅ Anthropic (Claude) summary successful", file=sys.stderr)
|
|
740
|
+
return title, "claude-3-5-haiku-20241022", description
|
|
741
|
+
|
|
742
|
+
except json.JSONDecodeError as e:
|
|
743
|
+
logger.warning(f"Failed to parse JSON from Claude response: {e}")
|
|
744
|
+
logger.debug(f"Raw response: {response_text}")
|
|
745
|
+
# Fallback: use first line as title, empty description
|
|
746
|
+
first_line = response_text.split("\n")[0][:150].strip()
|
|
747
|
+
# Validate fallback title is reasonable
|
|
748
|
+
if first_line and len(first_line) >= 2 and not first_line.startswith("{"):
|
|
749
|
+
print(" ⚠️ Claude response was not valid JSON, using fallback", file=sys.stderr)
|
|
750
|
+
return first_line, "claude-3-5-haiku-20241022", ""
|
|
751
|
+
else:
|
|
752
|
+
logger.error(f"Claude fallback title validation failed: '{first_line}'")
|
|
753
|
+
return None, None, None
|
|
513
754
|
|
|
514
755
|
except ImportError:
|
|
515
756
|
logger.warning("Anthropic package not installed")
|
|
516
757
|
if provider == "claude":
|
|
517
758
|
print(" ❌ Anthropic package not installed", file=sys.stderr)
|
|
518
|
-
return None, None
|
|
759
|
+
return None, None, None
|
|
519
760
|
else:
|
|
520
761
|
print(" ❌ Anthropic package not installed, trying OpenAI...", file=sys.stderr)
|
|
521
762
|
except Exception as e:
|
|
@@ -531,7 +772,7 @@ def generate_summary_with_llm(
|
|
|
531
772
|
print(f" ❌ Anthropic quota/credit issue", file=sys.stderr)
|
|
532
773
|
else:
|
|
533
774
|
print(f" ❌ Anthropic API error: {e}", file=sys.stderr)
|
|
534
|
-
return None, None
|
|
775
|
+
return None, None, None
|
|
535
776
|
else:
|
|
536
777
|
# Auto mode: try falling back to OpenAI
|
|
537
778
|
if "authentication" in error_msg.lower() or "invalid" in error_msg.lower():
|
|
@@ -547,7 +788,7 @@ def generate_summary_with_llm(
|
|
|
547
788
|
logger.debug("ANTHROPIC_API_KEY not set")
|
|
548
789
|
if provider == "claude":
|
|
549
790
|
print(" ❌ ANTHROPIC_API_KEY not set", file=sys.stderr)
|
|
550
|
-
return None, None
|
|
791
|
+
return None, None, None
|
|
551
792
|
else:
|
|
552
793
|
print(" ⓘ ANTHROPIC_API_KEY not set, trying OpenAI...", file=sys.stderr)
|
|
553
794
|
|
|
@@ -576,21 +817,61 @@ def generate_summary_with_llm(
|
|
|
576
817
|
"content": user_prompt
|
|
577
818
|
}
|
|
578
819
|
],
|
|
579
|
-
max_tokens=
|
|
820
|
+
max_tokens=1000, # Increased to allow complete descriptions (300-600 words)
|
|
580
821
|
temperature=0.7,
|
|
581
822
|
)
|
|
582
823
|
|
|
583
824
|
elapsed = time.time() - start_time
|
|
584
|
-
|
|
585
|
-
logger.info(f"OpenAI API success: {len(
|
|
586
|
-
logger.debug(f"OpenAI response: {
|
|
587
|
-
|
|
588
|
-
|
|
825
|
+
response_text = response.choices[0].message.content.strip()
|
|
826
|
+
logger.info(f"OpenAI API success: {len(response_text)} chars in {elapsed:.2f}s")
|
|
827
|
+
logger.debug(f"OpenAI response: {response_text[:200]}...")
|
|
828
|
+
|
|
829
|
+
# Parse JSON response
|
|
830
|
+
try:
|
|
831
|
+
# Try to extract JSON if wrapped in markdown code blocks
|
|
832
|
+
json_str = response_text
|
|
833
|
+
if "```json" in response_text:
|
|
834
|
+
json_start = response_text.find("```json") + 7
|
|
835
|
+
json_end = response_text.find("```", json_start)
|
|
836
|
+
# Only extract if closing ``` was found
|
|
837
|
+
if json_end != -1:
|
|
838
|
+
json_str = response_text[json_start:json_end].strip()
|
|
839
|
+
elif "```" in response_text:
|
|
840
|
+
json_start = response_text.find("```") + 3
|
|
841
|
+
json_end = response_text.find("```", json_start)
|
|
842
|
+
# Only extract if closing ``` was found
|
|
843
|
+
if json_end != -1:
|
|
844
|
+
json_str = response_text[json_start:json_end].strip()
|
|
845
|
+
|
|
846
|
+
summary_data = json.loads(json_str)
|
|
847
|
+
title = summary_data.get("title", "")
|
|
848
|
+
description = summary_data.get("description", "")
|
|
849
|
+
|
|
850
|
+
# Validate title is not just brackets or very short
|
|
851
|
+
if not title or len(title.strip()) < 2:
|
|
852
|
+
logger.warning(f"Generated title is empty or too short: '{title}'")
|
|
853
|
+
raise json.JSONDecodeError("Title validation failed", json_str, 0)
|
|
854
|
+
|
|
855
|
+
print(" ✅ OpenAI (GPT) summary successful", file=sys.stderr)
|
|
856
|
+
return title, "gpt-3.5-turbo", description
|
|
857
|
+
|
|
858
|
+
except json.JSONDecodeError as e:
|
|
859
|
+
logger.warning(f"Failed to parse JSON from OpenAI response: {e}")
|
|
860
|
+
logger.debug(f"Raw response: {response_text}")
|
|
861
|
+
# Fallback: use first line as title, empty description
|
|
862
|
+
first_line = response_text.split("\n")[0][:150].strip()
|
|
863
|
+
# Validate fallback title is reasonable
|
|
864
|
+
if first_line and len(first_line) >= 2 and not first_line.startswith("{"):
|
|
865
|
+
print(" ⚠️ OpenAI response was not valid JSON, using fallback", file=sys.stderr)
|
|
866
|
+
return first_line, "gpt-3.5-turbo", ""
|
|
867
|
+
else:
|
|
868
|
+
logger.error(f"OpenAI fallback title validation failed: '{first_line}'")
|
|
869
|
+
return None, None, None
|
|
589
870
|
|
|
590
871
|
except ImportError:
|
|
591
872
|
logger.warning("OpenAI package not installed")
|
|
592
873
|
print(" ❌ OpenAI package not installed", file=sys.stderr)
|
|
593
|
-
return None, None
|
|
874
|
+
return None, None, None
|
|
594
875
|
except Exception as e:
|
|
595
876
|
error_msg = str(e)
|
|
596
877
|
logger.error(f"OpenAI API error: {error_msg}", exc_info=True)
|
|
@@ -602,17 +883,17 @@ def generate_summary_with_llm(
|
|
|
602
883
|
print(f" ❌ OpenAI quota/billing issue", file=sys.stderr)
|
|
603
884
|
else:
|
|
604
885
|
print(f" ❌ OpenAI API error: {e}", file=sys.stderr)
|
|
605
|
-
return None, None
|
|
886
|
+
return None, None, None
|
|
606
887
|
elif try_openai:
|
|
607
888
|
logger.debug("OPENAI_API_KEY not set")
|
|
608
889
|
print(" ❌ OPENAI_API_KEY not set", file=sys.stderr)
|
|
609
|
-
return None, None
|
|
890
|
+
return None, None, None
|
|
610
891
|
|
|
611
892
|
# No API keys available or provider not configured
|
|
612
893
|
logger.warning(f"No LLM API keys available (provider: {provider})")
|
|
613
894
|
if provider == "auto":
|
|
614
895
|
print(" ❌ No LLM API keys configured", file=sys.stderr)
|
|
615
|
-
return None, None
|
|
896
|
+
return None, None, None
|
|
616
897
|
|
|
617
898
|
|
|
618
899
|
def generate_session_filename(user: str, agent: str = "claude") -> str:
|
|
@@ -694,6 +975,46 @@ def get_git_user() -> str:
|
|
|
694
975
|
return os.getenv("USER", "unknown")
|
|
695
976
|
|
|
696
977
|
|
|
978
|
+
def get_username(session_relpath: str = "") -> str:
|
|
979
|
+
"""
|
|
980
|
+
Get username for commit message.
|
|
981
|
+
|
|
982
|
+
Tries to get from git config first, then falls back to extracting
|
|
983
|
+
from session filename.
|
|
984
|
+
|
|
985
|
+
Args:
|
|
986
|
+
session_relpath: Relative path to session file (used for fallback)
|
|
987
|
+
|
|
988
|
+
Returns:
|
|
989
|
+
Username string
|
|
990
|
+
"""
|
|
991
|
+
# Try git config first
|
|
992
|
+
try:
|
|
993
|
+
result = subprocess.run(
|
|
994
|
+
["git", "config", "user.name"],
|
|
995
|
+
capture_output=True,
|
|
996
|
+
text=True,
|
|
997
|
+
check=True,
|
|
998
|
+
)
|
|
999
|
+
username = result.stdout.strip()
|
|
1000
|
+
if username:
|
|
1001
|
+
return username
|
|
1002
|
+
except subprocess.CalledProcessError:
|
|
1003
|
+
pass
|
|
1004
|
+
|
|
1005
|
+
# Fallback: extract from session filename
|
|
1006
|
+
# Format: username_agent_hash.jsonl
|
|
1007
|
+
if session_relpath:
|
|
1008
|
+
filename = Path(session_relpath).name
|
|
1009
|
+
parts = filename.split("_")
|
|
1010
|
+
if len(parts) >= 3:
|
|
1011
|
+
# First part is username
|
|
1012
|
+
return parts[0]
|
|
1013
|
+
|
|
1014
|
+
# Final fallback
|
|
1015
|
+
return os.getenv("USER", "unknown")
|
|
1016
|
+
|
|
1017
|
+
|
|
697
1018
|
def copy_session_to_repo(
|
|
698
1019
|
session_file: Path,
|
|
699
1020
|
repo_root: Path,
|
|
@@ -701,7 +1022,7 @@ def copy_session_to_repo(
|
|
|
701
1022
|
config: Optional[ReAlignConfig] = None
|
|
702
1023
|
) -> Tuple[Path, str, bool, int]:
|
|
703
1024
|
"""
|
|
704
|
-
Copy session file to repository
|
|
1025
|
+
Copy session file to repository sessions/ directory (in ~/.aline/{project_name}/).
|
|
705
1026
|
Optionally redacts sensitive information if configured.
|
|
706
1027
|
If the source filename is in UUID format, renames it to include username for better identification.
|
|
707
1028
|
Returns (absolute_path, relative_path, was_redacted, content_size).
|
|
@@ -709,7 +1030,9 @@ def copy_session_to_repo(
|
|
|
709
1030
|
logger.info(f"Copying session to repo: {session_file.name}")
|
|
710
1031
|
logger.debug(f"Source: {session_file}, Repo root: {repo_root}, User: {user}")
|
|
711
1032
|
|
|
712
|
-
|
|
1033
|
+
from realign import get_realign_dir
|
|
1034
|
+
realign_dir = get_realign_dir(repo_root)
|
|
1035
|
+
sessions_dir = realign_dir / "sessions"
|
|
713
1036
|
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
714
1037
|
|
|
715
1038
|
original_filename = session_file.name
|
|
@@ -873,7 +1196,9 @@ def save_session_metadata(repo_root: Path, session_relpath: str, content_size: i
|
|
|
873
1196
|
session_relpath: Relative path to session file
|
|
874
1197
|
content_size: Size of session content when processed
|
|
875
1198
|
"""
|
|
876
|
-
|
|
1199
|
+
from realign import get_realign_dir
|
|
1200
|
+
realign_dir = get_realign_dir(repo_root)
|
|
1201
|
+
metadata_dir = realign_dir / ".metadata"
|
|
877
1202
|
metadata_dir.mkdir(parents=True, exist_ok=True)
|
|
878
1203
|
|
|
879
1204
|
# Use session filename as metadata key
|
|
@@ -905,7 +1230,9 @@ def get_session_metadata(repo_root: Path, session_relpath: str) -> Optional[Dict
|
|
|
905
1230
|
Returns:
|
|
906
1231
|
Metadata dictionary or None if not found
|
|
907
1232
|
"""
|
|
908
|
-
|
|
1233
|
+
from realign import get_realign_dir
|
|
1234
|
+
realign_dir = get_realign_dir(repo_root)
|
|
1235
|
+
metadata_dir = realign_dir / ".metadata"
|
|
909
1236
|
session_name = Path(session_relpath).name
|
|
910
1237
|
metadata_file = metadata_dir / f"{session_name}.meta"
|
|
911
1238
|
|
|
@@ -922,346 +1249,3 @@ def get_session_metadata(repo_root: Path, session_relpath: str) -> Optional[Dict
|
|
|
922
1249
|
return None
|
|
923
1250
|
|
|
924
1251
|
|
|
925
|
-
def process_sessions(
|
|
926
|
-
pre_commit_mode: bool = False,
|
|
927
|
-
session_path: Optional[str] = None,
|
|
928
|
-
user: Optional[str] = None
|
|
929
|
-
) -> Dict[str, Any]:
|
|
930
|
-
"""
|
|
931
|
-
Core logic for processing agent sessions.
|
|
932
|
-
Used by both pre-commit and prepare-commit-msg hooks.
|
|
933
|
-
|
|
934
|
-
Args:
|
|
935
|
-
pre_commit_mode: If True, only return session paths without generating summaries
|
|
936
|
-
session_path: Explicit path to a session file (optional)
|
|
937
|
-
user: User name override (optional)
|
|
938
|
-
|
|
939
|
-
Returns:
|
|
940
|
-
Dictionary with keys: summary, session_relpaths, redacted, summary_entries, summary_model
|
|
941
|
-
"""
|
|
942
|
-
import time
|
|
943
|
-
start_time = time.time()
|
|
944
|
-
|
|
945
|
-
hook_type = "pre-commit" if pre_commit_mode else "prepare-commit-msg"
|
|
946
|
-
logger.info(f"======== Hook started: {hook_type} ========")
|
|
947
|
-
|
|
948
|
-
# Load configuration
|
|
949
|
-
config = ReAlignConfig.load()
|
|
950
|
-
logger.debug(f"Config loaded: use_LLM={config.use_LLM}, redact_on_match={config.redact_on_match}")
|
|
951
|
-
|
|
952
|
-
# Find repository root
|
|
953
|
-
try:
|
|
954
|
-
result = subprocess.run(
|
|
955
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
956
|
-
capture_output=True,
|
|
957
|
-
text=True,
|
|
958
|
-
check=True,
|
|
959
|
-
)
|
|
960
|
-
repo_root = Path(result.stdout.strip())
|
|
961
|
-
logger.debug(f"Repository root: {repo_root}")
|
|
962
|
-
except subprocess.CalledProcessError as e:
|
|
963
|
-
logger.error(f"Not in a git repository: {e}")
|
|
964
|
-
print(json.dumps({"error": "Not in a git repository"}), file=sys.stderr)
|
|
965
|
-
sys.exit(1)
|
|
966
|
-
|
|
967
|
-
# Find all active session files
|
|
968
|
-
session_path_env = session_path or os.getenv("REALIGN_SESSION_PATH")
|
|
969
|
-
|
|
970
|
-
if session_path_env:
|
|
971
|
-
# Explicit session path provided
|
|
972
|
-
logger.info(f"Using explicit session path: {session_path_env}")
|
|
973
|
-
session_file = Path(session_path_env)
|
|
974
|
-
session_files = [session_file] if session_file.exists() else []
|
|
975
|
-
if not session_files:
|
|
976
|
-
logger.warning(f"Explicit session path not found: {session_path_env}")
|
|
977
|
-
else:
|
|
978
|
-
# Auto-detect all enabled sessions
|
|
979
|
-
session_files = find_all_active_sessions(config, repo_root)
|
|
980
|
-
|
|
981
|
-
if not session_files:
|
|
982
|
-
logger.info("No session files found, returning empty result")
|
|
983
|
-
# Return empty result (don't block commit)
|
|
984
|
-
return {"summary": "", "session_relpaths": [], "redacted": False}
|
|
985
|
-
|
|
986
|
-
# Get user
|
|
987
|
-
user = user or get_git_user()
|
|
988
|
-
logger.debug(f"Git user: {user}")
|
|
989
|
-
|
|
990
|
-
# Pre-commit mode: Copy sessions to repo (with optional redaction)
|
|
991
|
-
# Prepare-commit-msg mode: Reuse already copied sessions from .realign/sessions/
|
|
992
|
-
session_relpaths = []
|
|
993
|
-
session_metadata_map = {} # Map session_relpath -> content_size
|
|
994
|
-
any_redacted = False
|
|
995
|
-
|
|
996
|
-
if pre_commit_mode:
|
|
997
|
-
# Pre-commit: Copy and redact sessions (heavy work done here)
|
|
998
|
-
logger.info("Pre-commit mode: Copying and processing sessions")
|
|
999
|
-
for session_file in session_files:
|
|
1000
|
-
try:
|
|
1001
|
-
_, session_relpath, was_redacted, content_size = copy_session_to_repo(
|
|
1002
|
-
session_file, repo_root, user, config
|
|
1003
|
-
)
|
|
1004
|
-
session_relpaths.append(session_relpath)
|
|
1005
|
-
session_metadata_map[session_relpath] = content_size
|
|
1006
|
-
if was_redacted:
|
|
1007
|
-
any_redacted = True
|
|
1008
|
-
except Exception as e:
|
|
1009
|
-
logger.error(f"Failed to copy session file {session_file}: {e}", exc_info=True)
|
|
1010
|
-
print(f"Warning: Could not copy session file {session_file}: {e}", file=sys.stderr)
|
|
1011
|
-
continue
|
|
1012
|
-
|
|
1013
|
-
if not session_relpaths:
|
|
1014
|
-
logger.warning("No session files copied successfully")
|
|
1015
|
-
return {
|
|
1016
|
-
"summary": "",
|
|
1017
|
-
"session_relpaths": [],
|
|
1018
|
-
"redacted": False,
|
|
1019
|
-
"summary_entries": [],
|
|
1020
|
-
"summary_model": "",
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
logger.info(f"Copied {len(session_relpaths)} session(s): {session_relpaths}")
|
|
1024
|
-
else:
|
|
1025
|
-
# Prepare-commit-msg: Just find existing sessions in .realign/sessions/
|
|
1026
|
-
# No need to copy again - pre-commit already did this
|
|
1027
|
-
logger.info("Prepare-commit-msg mode: Using existing sessions from .realign/sessions/")
|
|
1028
|
-
sessions_dir = repo_root / ".realign" / "sessions"
|
|
1029
|
-
|
|
1030
|
-
if sessions_dir.exists():
|
|
1031
|
-
# Find all session files that were processed by pre-commit
|
|
1032
|
-
for session_file in sessions_dir.glob("*.jsonl"):
|
|
1033
|
-
# Skip agent sessions (these are sub-tasks)
|
|
1034
|
-
if session_file.name.startswith("agent-"):
|
|
1035
|
-
continue
|
|
1036
|
-
|
|
1037
|
-
session_relpath = str(session_file.relative_to(repo_root))
|
|
1038
|
-
session_relpaths.append(session_relpath)
|
|
1039
|
-
|
|
1040
|
-
# Get file size
|
|
1041
|
-
try:
|
|
1042
|
-
content_size = session_file.stat().st_size
|
|
1043
|
-
session_metadata_map[session_relpath] = content_size
|
|
1044
|
-
except Exception as e:
|
|
1045
|
-
logger.warning(f"Could not get size for {session_relpath}: {e}")
|
|
1046
|
-
session_metadata_map[session_relpath] = 0
|
|
1047
|
-
|
|
1048
|
-
if not session_relpaths:
|
|
1049
|
-
logger.warning("No existing session files found in .realign/sessions/")
|
|
1050
|
-
return {
|
|
1051
|
-
"summary": "",
|
|
1052
|
-
"session_relpaths": [],
|
|
1053
|
-
"redacted": False,
|
|
1054
|
-
"summary_entries": [],
|
|
1055
|
-
"summary_model": "",
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
logger.info(f"Found {len(session_relpaths)} existing session(s): {session_relpaths}")
|
|
1059
|
-
|
|
1060
|
-
# If pre-commit mode, save metadata and return session paths (summary will be generated later)
|
|
1061
|
-
if pre_commit_mode:
|
|
1062
|
-
# Save metadata for each session to prevent reprocessing
|
|
1063
|
-
for session_relpath, content_size in session_metadata_map.items():
|
|
1064
|
-
save_session_metadata(repo_root, session_relpath, content_size)
|
|
1065
|
-
logger.debug(f"Saved metadata for {session_relpath} in pre-commit")
|
|
1066
|
-
|
|
1067
|
-
elapsed = time.time() - start_time
|
|
1068
|
-
logger.info(f"======== Hook completed: {hook_type} in {elapsed:.2f}s ========")
|
|
1069
|
-
return {
|
|
1070
|
-
"summary": "",
|
|
1071
|
-
"session_relpaths": session_relpaths,
|
|
1072
|
-
"redacted": any_redacted,
|
|
1073
|
-
"summary_entries": [],
|
|
1074
|
-
"summary_model": "",
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
# For prepare-commit-msg mode, we need to stage files first to get accurate diff
|
|
1078
|
-
# This ensures git diff --cached works correctly
|
|
1079
|
-
try:
|
|
1080
|
-
for session_relpath in session_relpaths:
|
|
1081
|
-
subprocess.run(
|
|
1082
|
-
["git", "add", session_relpath],
|
|
1083
|
-
cwd=repo_root,
|
|
1084
|
-
check=True,
|
|
1085
|
-
)
|
|
1086
|
-
logger.debug("Session files staged successfully")
|
|
1087
|
-
except subprocess.CalledProcessError as e:
|
|
1088
|
-
logger.error(f"Failed to stage session files: {e}", exc_info=True)
|
|
1089
|
-
print(f"Warning: Could not stage session files: {e}", file=sys.stderr)
|
|
1090
|
-
|
|
1091
|
-
# For prepare-commit-msg mode, generate summary from all sessions
|
|
1092
|
-
logger.info("Generating summaries for sessions")
|
|
1093
|
-
summary_entries: List[Dict[str, str]] = []
|
|
1094
|
-
legacy_summary_chunks: List[str] = []
|
|
1095
|
-
summary_model_label: Optional[str] = None
|
|
1096
|
-
|
|
1097
|
-
for session_relpath in session_relpaths:
|
|
1098
|
-
# Extract NEW content using git diff (compares staged content with HEAD)
|
|
1099
|
-
# This correctly detects new content even if the file hasn't grown since pre-commit
|
|
1100
|
-
# (which happens in auto-commit scenarios where the AI has finished responding)
|
|
1101
|
-
current_size = session_metadata_map.get(session_relpath, 0)
|
|
1102
|
-
new_content = get_new_content_from_git_diff(repo_root, session_relpath)
|
|
1103
|
-
|
|
1104
|
-
if not new_content or not new_content.strip():
|
|
1105
|
-
logger.debug(f"No new content for {session_relpath}, skipping summary")
|
|
1106
|
-
continue
|
|
1107
|
-
|
|
1108
|
-
# Generate summary for NEW content only
|
|
1109
|
-
summary_text: Optional[str] = None
|
|
1110
|
-
is_llm_summary = False
|
|
1111
|
-
llm_model_name: Optional[str] = None
|
|
1112
|
-
if config.use_LLM:
|
|
1113
|
-
print(f"🤖 Attempting to generate LLM summary (provider: {config.llm_provider})...", file=sys.stderr)
|
|
1114
|
-
summary_text, llm_model_name = generate_summary_with_llm(
|
|
1115
|
-
new_content,
|
|
1116
|
-
config.summary_max_chars,
|
|
1117
|
-
config.llm_provider
|
|
1118
|
-
)
|
|
1119
|
-
|
|
1120
|
-
if summary_text:
|
|
1121
|
-
print("✅ LLM summary generated successfully", file=sys.stderr)
|
|
1122
|
-
is_llm_summary = True
|
|
1123
|
-
if summary_model_label is None:
|
|
1124
|
-
summary_model_label = llm_model_name or config.llm_provider
|
|
1125
|
-
else:
|
|
1126
|
-
print("⚠️ LLM summary failed - falling back to local summarization", file=sys.stderr)
|
|
1127
|
-
print(" Check your API keys: ANTHROPIC_API_KEY or OPENAI_API_KEY", file=sys.stderr)
|
|
1128
|
-
|
|
1129
|
-
if not summary_text:
|
|
1130
|
-
# Fallback to simple summarize
|
|
1131
|
-
logger.info("Using local summarization (no LLM)")
|
|
1132
|
-
print("📝 Using local summarization (no LLM)", file=sys.stderr)
|
|
1133
|
-
summary_text = simple_summarize(new_content, config.summary_max_chars)
|
|
1134
|
-
|
|
1135
|
-
# Identify agent type from filename
|
|
1136
|
-
agent_name = detect_agent_from_session_path(session_relpath)
|
|
1137
|
-
|
|
1138
|
-
summary_text = summary_text.strip()
|
|
1139
|
-
logger.debug(f"Summary for {session_relpath} ({agent_name}): {summary_text[:100]}...")
|
|
1140
|
-
summary_entries.append({
|
|
1141
|
-
"agent": agent_name,
|
|
1142
|
-
"text": summary_text,
|
|
1143
|
-
"source": "llm" if is_llm_summary else "local",
|
|
1144
|
-
})
|
|
1145
|
-
legacy_summary_chunks.append(f"[{agent_name}] {summary_text}")
|
|
1146
|
-
|
|
1147
|
-
# Update metadata after successfully generating summary
|
|
1148
|
-
save_session_metadata(repo_root, session_relpath, current_size)
|
|
1149
|
-
logger.debug(f"Updated metadata for {session_relpath} in prepare-commit-msg")
|
|
1150
|
-
|
|
1151
|
-
# Combine all summaries
|
|
1152
|
-
if summary_entries:
|
|
1153
|
-
if summary_model_label is None:
|
|
1154
|
-
summary_model_label = "Local summarizer"
|
|
1155
|
-
combined_summary = " | ".join(legacy_summary_chunks)
|
|
1156
|
-
logger.info(f"Generated {len(summary_entries)} summary(ies)")
|
|
1157
|
-
else:
|
|
1158
|
-
combined_summary = "No new content in sessions"
|
|
1159
|
-
logger.info("No summaries generated (no new content)")
|
|
1160
|
-
|
|
1161
|
-
elapsed = time.time() - start_time
|
|
1162
|
-
logger.info(f"======== Hook completed: {hook_type} in {elapsed:.2f}s ========")
|
|
1163
|
-
|
|
1164
|
-
return {
|
|
1165
|
-
"summary": combined_summary,
|
|
1166
|
-
"session_relpaths": session_relpaths,
|
|
1167
|
-
"redacted": any_redacted,
|
|
1168
|
-
"summary_entries": summary_entries,
|
|
1169
|
-
"summary_model": summary_model_label or "",
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
def pre_commit_hook():
|
|
1174
|
-
"""
|
|
1175
|
-
Entry point for pre-commit hook.
|
|
1176
|
-
Finds and stages session files.
|
|
1177
|
-
"""
|
|
1178
|
-
result = process_sessions(pre_commit_mode=True)
|
|
1179
|
-
print(json.dumps(result, ensure_ascii=False))
|
|
1180
|
-
|
|
1181
|
-
# Stage the session files
|
|
1182
|
-
if result["session_relpaths"]:
|
|
1183
|
-
try:
|
|
1184
|
-
subprocess.run(
|
|
1185
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
1186
|
-
capture_output=True,
|
|
1187
|
-
text=True,
|
|
1188
|
-
check=True,
|
|
1189
|
-
)
|
|
1190
|
-
repo_root_result = subprocess.run(
|
|
1191
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
1192
|
-
capture_output=True,
|
|
1193
|
-
text=True,
|
|
1194
|
-
check=True,
|
|
1195
|
-
)
|
|
1196
|
-
repo_root = repo_root_result.stdout.strip()
|
|
1197
|
-
|
|
1198
|
-
for session_path in result["session_relpaths"]:
|
|
1199
|
-
subprocess.run(
|
|
1200
|
-
["git", "add", session_path],
|
|
1201
|
-
cwd=repo_root,
|
|
1202
|
-
check=True,
|
|
1203
|
-
)
|
|
1204
|
-
except subprocess.CalledProcessError as e:
|
|
1205
|
-
print(f"Warning: Could not stage session files: {e}", file=sys.stderr)
|
|
1206
|
-
|
|
1207
|
-
sys.exit(0)
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
def prepare_commit_msg_hook():
|
|
1211
|
-
"""
|
|
1212
|
-
Entry point for prepare-commit-msg hook.
|
|
1213
|
-
Generates session summary and appends to commit message.
|
|
1214
|
-
"""
|
|
1215
|
-
# Get commit message file path from command line arguments
|
|
1216
|
-
# When called via __main__ with --prepare-commit-msg flag, the file is at index 2
|
|
1217
|
-
# When called directly as a hook entry point, the file is at index 1
|
|
1218
|
-
if sys.argv[1] == "--prepare-commit-msg":
|
|
1219
|
-
# Called via: python -m realign.hooks --prepare-commit-msg <msg-file> <source>
|
|
1220
|
-
if len(sys.argv) < 3:
|
|
1221
|
-
print("Error: Commit message file path not provided", file=sys.stderr)
|
|
1222
|
-
sys.exit(1)
|
|
1223
|
-
msg_file = sys.argv[2]
|
|
1224
|
-
else:
|
|
1225
|
-
# Called via: realign-hook-prepare-commit-msg <msg-file> <source>
|
|
1226
|
-
msg_file = sys.argv[1]
|
|
1227
|
-
|
|
1228
|
-
# Process sessions and generate summary
|
|
1229
|
-
result = process_sessions(pre_commit_mode=False)
|
|
1230
|
-
|
|
1231
|
-
# Append summary to commit message
|
|
1232
|
-
summary_entries = result.get("summary_entries") or []
|
|
1233
|
-
if summary_entries:
|
|
1234
|
-
try:
|
|
1235
|
-
with open(msg_file, "a", encoding="utf-8") as f:
|
|
1236
|
-
summary_model = result.get("summary_model") or "Local summarizer"
|
|
1237
|
-
f.write("\n\n")
|
|
1238
|
-
f.write(f"--- LLM-Summary ({summary_model}) ---\n")
|
|
1239
|
-
for entry in summary_entries:
|
|
1240
|
-
agent_label = entry.get("agent", "Agent")
|
|
1241
|
-
text = (entry.get("text") or "").strip()
|
|
1242
|
-
if not text:
|
|
1243
|
-
continue
|
|
1244
|
-
f.write(f"* [{agent_label}] {text}\n")
|
|
1245
|
-
f.write("\n")
|
|
1246
|
-
if result.get("redacted"):
|
|
1247
|
-
f.write("Agent-Redacted: true\n")
|
|
1248
|
-
except Exception as e:
|
|
1249
|
-
print(f"Warning: Could not append to commit message: {e}", file=sys.stderr)
|
|
1250
|
-
|
|
1251
|
-
sys.exit(0)
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
if __name__ == "__main__":
|
|
1255
|
-
# This allows the module to be run directly for testing
|
|
1256
|
-
import sys
|
|
1257
|
-
if len(sys.argv) > 1:
|
|
1258
|
-
if sys.argv[1] == "--pre-commit":
|
|
1259
|
-
pre_commit_hook()
|
|
1260
|
-
elif sys.argv[1] == "--prepare-commit-msg":
|
|
1261
|
-
prepare_commit_msg_hook()
|
|
1262
|
-
else:
|
|
1263
|
-
print("Usage: python -m realign.hooks [--pre-commit|--prepare-commit-msg]")
|
|
1264
|
-
sys.exit(1)
|
|
1265
|
-
else:
|
|
1266
|
-
print("Usage: python -m realign.hooks [--pre-commit|--prepare-commit-msg]")
|
|
1267
|
-
sys.exit(1)
|