aline-ai 0.2.6__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.6.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 +115 -411
- realign/logging_config.py +2 -2
- realign/mcp_server.py +263 -549
- realign/mcp_watcher.py +997 -139
- 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.6.dist-info/RECORD +0 -28
- aline_ai-0.2.6.dist-info/entry_points.txt +0 -5
- realign/commands/auto_commit.py +0 -242
- realign/commands/commit.py +0 -379
- realign/commands/search.py +0 -449
- realign/commands/show.py +0 -416
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.2.6.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.
|
|
@@ -588,19 +636,26 @@ def generate_summary_with_llm(
|
|
|
588
636
|
system_prompt = """You are a git commit message generator for AI chat sessions.
|
|
589
637
|
Analyze the conversation and code changes, then generate a summary in JSON format:
|
|
590
638
|
{
|
|
591
|
-
"title": "One-line summary (
|
|
592
|
-
"description": "Detailed description of what happened in this session
|
|
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."
|
|
593
641
|
}
|
|
594
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
|
+
|
|
595
648
|
IMPORTANT for description:
|
|
596
|
-
- Be
|
|
649
|
+
- Be thorough and complete (300-600 words)
|
|
597
650
|
- Focus on WHAT was accomplished and WHY, not HOW
|
|
598
651
|
- Include technical details: function names, module names, specific features
|
|
599
652
|
- Mention key decisions or discussions
|
|
600
|
-
-
|
|
653
|
+
- Write in clear, complete sentences - grammar matters for readability
|
|
654
|
+
- NEVER truncate mid-sentence - always finish your thought
|
|
601
655
|
- Avoid mentioning tool names like 'Edit', 'Write', 'Read'
|
|
602
656
|
- For discussions without code: summarize the topics and conclusions
|
|
603
657
|
- For code changes: describe what was changed and the purpose
|
|
658
|
+
- If there were multiple related changes, group them logically
|
|
604
659
|
|
|
605
660
|
Return JSON only, no other text."""
|
|
606
661
|
|
|
@@ -639,7 +694,7 @@ Return JSON only, no other text."""
|
|
|
639
694
|
|
|
640
695
|
response = client.messages.create(
|
|
641
696
|
model="claude-3-5-haiku-20241022", # Fast and cost-effective
|
|
642
|
-
max_tokens=
|
|
697
|
+
max_tokens=1000, # Increased to allow complete descriptions (300-600 words)
|
|
643
698
|
temperature=0.7,
|
|
644
699
|
system=system_prompt,
|
|
645
700
|
messages=[
|
|
@@ -658,20 +713,28 @@ Return JSON only, no other text."""
|
|
|
658
713
|
# Parse JSON response
|
|
659
714
|
try:
|
|
660
715
|
# Try to extract JSON if wrapped in markdown code blocks
|
|
716
|
+
json_str = response_text
|
|
661
717
|
if "```json" in response_text:
|
|
662
718
|
json_start = response_text.find("```json") + 7
|
|
663
719
|
json_end = response_text.find("```", json_start)
|
|
664
|
-
|
|
720
|
+
# Only extract if closing ``` was found
|
|
721
|
+
if json_end != -1:
|
|
722
|
+
json_str = response_text[json_start:json_end].strip()
|
|
665
723
|
elif "```" in response_text:
|
|
666
724
|
json_start = response_text.find("```") + 3
|
|
667
725
|
json_end = response_text.find("```", json_start)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
726
|
+
# Only extract if closing ``` was found
|
|
727
|
+
if json_end != -1:
|
|
728
|
+
json_str = response_text[json_start:json_end].strip()
|
|
671
729
|
|
|
672
730
|
summary_data = json.loads(json_str)
|
|
673
|
-
title = summary_data.get("title", "")
|
|
674
|
-
description = summary_data.get("description", "")
|
|
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)
|
|
675
738
|
|
|
676
739
|
print(" ✅ Anthropic (Claude) summary successful", file=sys.stderr)
|
|
677
740
|
return title, "claude-3-5-haiku-20241022", description
|
|
@@ -680,9 +743,14 @@ Return JSON only, no other text."""
|
|
|
680
743
|
logger.warning(f"Failed to parse JSON from Claude response: {e}")
|
|
681
744
|
logger.debug(f"Raw response: {response_text}")
|
|
682
745
|
# Fallback: use first line as title, empty description
|
|
683
|
-
first_line = response_text.split("\n")[0][:150]
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
686
754
|
|
|
687
755
|
except ImportError:
|
|
688
756
|
logger.warning("Anthropic package not installed")
|
|
@@ -749,7 +817,7 @@ Return JSON only, no other text."""
|
|
|
749
817
|
"content": user_prompt
|
|
750
818
|
}
|
|
751
819
|
],
|
|
752
|
-
max_tokens=
|
|
820
|
+
max_tokens=1000, # Increased to allow complete descriptions (300-600 words)
|
|
753
821
|
temperature=0.7,
|
|
754
822
|
)
|
|
755
823
|
|
|
@@ -761,20 +829,28 @@ Return JSON only, no other text."""
|
|
|
761
829
|
# Parse JSON response
|
|
762
830
|
try:
|
|
763
831
|
# Try to extract JSON if wrapped in markdown code blocks
|
|
832
|
+
json_str = response_text
|
|
764
833
|
if "```json" in response_text:
|
|
765
834
|
json_start = response_text.find("```json") + 7
|
|
766
835
|
json_end = response_text.find("```", json_start)
|
|
767
|
-
|
|
836
|
+
# Only extract if closing ``` was found
|
|
837
|
+
if json_end != -1:
|
|
838
|
+
json_str = response_text[json_start:json_end].strip()
|
|
768
839
|
elif "```" in response_text:
|
|
769
840
|
json_start = response_text.find("```") + 3
|
|
770
841
|
json_end = response_text.find("```", json_start)
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
842
|
+
# Only extract if closing ``` was found
|
|
843
|
+
if json_end != -1:
|
|
844
|
+
json_str = response_text[json_start:json_end].strip()
|
|
774
845
|
|
|
775
846
|
summary_data = json.loads(json_str)
|
|
776
|
-
title = summary_data.get("title", "")
|
|
777
|
-
description = summary_data.get("description", "")
|
|
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)
|
|
778
854
|
|
|
779
855
|
print(" ✅ OpenAI (GPT) summary successful", file=sys.stderr)
|
|
780
856
|
return title, "gpt-3.5-turbo", description
|
|
@@ -783,9 +859,14 @@ Return JSON only, no other text."""
|
|
|
783
859
|
logger.warning(f"Failed to parse JSON from OpenAI response: {e}")
|
|
784
860
|
logger.debug(f"Raw response: {response_text}")
|
|
785
861
|
# Fallback: use first line as title, empty description
|
|
786
|
-
first_line = response_text.split("\n")[0][:150]
|
|
787
|
-
|
|
788
|
-
|
|
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
|
|
789
870
|
|
|
790
871
|
except ImportError:
|
|
791
872
|
logger.warning("OpenAI package not installed")
|
|
@@ -941,7 +1022,7 @@ def copy_session_to_repo(
|
|
|
941
1022
|
config: Optional[ReAlignConfig] = None
|
|
942
1023
|
) -> Tuple[Path, str, bool, int]:
|
|
943
1024
|
"""
|
|
944
|
-
Copy session file to repository
|
|
1025
|
+
Copy session file to repository sessions/ directory (in ~/.aline/{project_name}/).
|
|
945
1026
|
Optionally redacts sensitive information if configured.
|
|
946
1027
|
If the source filename is in UUID format, renames it to include username for better identification.
|
|
947
1028
|
Returns (absolute_path, relative_path, was_redacted, content_size).
|
|
@@ -949,7 +1030,9 @@ def copy_session_to_repo(
|
|
|
949
1030
|
logger.info(f"Copying session to repo: {session_file.name}")
|
|
950
1031
|
logger.debug(f"Source: {session_file}, Repo root: {repo_root}, User: {user}")
|
|
951
1032
|
|
|
952
|
-
|
|
1033
|
+
from realign import get_realign_dir
|
|
1034
|
+
realign_dir = get_realign_dir(repo_root)
|
|
1035
|
+
sessions_dir = realign_dir / "sessions"
|
|
953
1036
|
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
954
1037
|
|
|
955
1038
|
original_filename = session_file.name
|
|
@@ -1113,7 +1196,9 @@ def save_session_metadata(repo_root: Path, session_relpath: str, content_size: i
|
|
|
1113
1196
|
session_relpath: Relative path to session file
|
|
1114
1197
|
content_size: Size of session content when processed
|
|
1115
1198
|
"""
|
|
1116
|
-
|
|
1199
|
+
from realign import get_realign_dir
|
|
1200
|
+
realign_dir = get_realign_dir(repo_root)
|
|
1201
|
+
metadata_dir = realign_dir / ".metadata"
|
|
1117
1202
|
metadata_dir.mkdir(parents=True, exist_ok=True)
|
|
1118
1203
|
|
|
1119
1204
|
# Use session filename as metadata key
|
|
@@ -1145,7 +1230,9 @@ def get_session_metadata(repo_root: Path, session_relpath: str) -> Optional[Dict
|
|
|
1145
1230
|
Returns:
|
|
1146
1231
|
Metadata dictionary or None if not found
|
|
1147
1232
|
"""
|
|
1148
|
-
|
|
1233
|
+
from realign import get_realign_dir
|
|
1234
|
+
realign_dir = get_realign_dir(repo_root)
|
|
1235
|
+
metadata_dir = realign_dir / ".metadata"
|
|
1149
1236
|
session_name = Path(session_relpath).name
|
|
1150
1237
|
metadata_file = metadata_dir / f"{session_name}.meta"
|
|
1151
1238
|
|
|
@@ -1162,386 +1249,3 @@ def get_session_metadata(repo_root: Path, session_relpath: str) -> Optional[Dict
|
|
|
1162
1249
|
return None
|
|
1163
1250
|
|
|
1164
1251
|
|
|
1165
|
-
def process_sessions(
|
|
1166
|
-
pre_commit_mode: bool = False,
|
|
1167
|
-
session_path: Optional[str] = None,
|
|
1168
|
-
user: Optional[str] = None
|
|
1169
|
-
) -> Dict[str, Any]:
|
|
1170
|
-
"""
|
|
1171
|
-
Core logic for processing agent sessions.
|
|
1172
|
-
Used by both pre-commit and prepare-commit-msg hooks.
|
|
1173
|
-
|
|
1174
|
-
Args:
|
|
1175
|
-
pre_commit_mode: If True, only return session paths without generating summaries
|
|
1176
|
-
session_path: Explicit path to a session file (optional)
|
|
1177
|
-
user: User name override (optional)
|
|
1178
|
-
|
|
1179
|
-
Returns:
|
|
1180
|
-
Dictionary with keys: summary, session_relpaths, redacted, summary_entries, summary_model
|
|
1181
|
-
"""
|
|
1182
|
-
import time
|
|
1183
|
-
start_time = time.time()
|
|
1184
|
-
|
|
1185
|
-
hook_type = "pre-commit" if pre_commit_mode else "prepare-commit-msg"
|
|
1186
|
-
logger.info(f"======== Hook started: {hook_type} ========")
|
|
1187
|
-
|
|
1188
|
-
# Load configuration
|
|
1189
|
-
config = ReAlignConfig.load()
|
|
1190
|
-
logger.debug(f"Config loaded: use_LLM={config.use_LLM}, redact_on_match={config.redact_on_match}")
|
|
1191
|
-
|
|
1192
|
-
# Find repository root
|
|
1193
|
-
try:
|
|
1194
|
-
result = subprocess.run(
|
|
1195
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
1196
|
-
capture_output=True,
|
|
1197
|
-
text=True,
|
|
1198
|
-
check=True,
|
|
1199
|
-
)
|
|
1200
|
-
repo_root = Path(result.stdout.strip())
|
|
1201
|
-
logger.debug(f"Repository root: {repo_root}")
|
|
1202
|
-
except subprocess.CalledProcessError as e:
|
|
1203
|
-
logger.error(f"Not in a git repository: {e}")
|
|
1204
|
-
print(json.dumps({"error": "Not in a git repository"}), file=sys.stderr)
|
|
1205
|
-
sys.exit(1)
|
|
1206
|
-
|
|
1207
|
-
# Find all active session files
|
|
1208
|
-
session_path_env = session_path or os.getenv("REALIGN_SESSION_PATH")
|
|
1209
|
-
|
|
1210
|
-
if session_path_env:
|
|
1211
|
-
# Explicit session path provided
|
|
1212
|
-
logger.info(f"Using explicit session path: {session_path_env}")
|
|
1213
|
-
session_file = Path(session_path_env)
|
|
1214
|
-
session_files = [session_file] if session_file.exists() else []
|
|
1215
|
-
if not session_files:
|
|
1216
|
-
logger.warning(f"Explicit session path not found: {session_path_env}")
|
|
1217
|
-
else:
|
|
1218
|
-
# Auto-detect all enabled sessions
|
|
1219
|
-
session_files = find_all_active_sessions(config, repo_root)
|
|
1220
|
-
|
|
1221
|
-
if not session_files:
|
|
1222
|
-
logger.info("No session files found, returning empty result")
|
|
1223
|
-
# Return empty result (don't block commit)
|
|
1224
|
-
return {"summary": "", "session_relpaths": [], "redacted": False}
|
|
1225
|
-
|
|
1226
|
-
# Get user
|
|
1227
|
-
user = user or get_git_user()
|
|
1228
|
-
logger.debug(f"Git user: {user}")
|
|
1229
|
-
|
|
1230
|
-
# Pre-commit mode: Copy sessions to repo (with optional redaction)
|
|
1231
|
-
# Prepare-commit-msg mode: Reuse already copied sessions from .realign/sessions/
|
|
1232
|
-
session_relpaths = []
|
|
1233
|
-
session_metadata_map = {} # Map session_relpath -> content_size
|
|
1234
|
-
any_redacted = False
|
|
1235
|
-
|
|
1236
|
-
if pre_commit_mode:
|
|
1237
|
-
# Pre-commit: Copy and redact sessions (heavy work done here)
|
|
1238
|
-
logger.info("Pre-commit mode: Copying and processing sessions")
|
|
1239
|
-
for session_file in session_files:
|
|
1240
|
-
try:
|
|
1241
|
-
_, session_relpath, was_redacted, content_size = copy_session_to_repo(
|
|
1242
|
-
session_file, repo_root, user, config
|
|
1243
|
-
)
|
|
1244
|
-
session_relpaths.append(session_relpath)
|
|
1245
|
-
session_metadata_map[session_relpath] = content_size
|
|
1246
|
-
if was_redacted:
|
|
1247
|
-
any_redacted = True
|
|
1248
|
-
except Exception as e:
|
|
1249
|
-
logger.error(f"Failed to copy session file {session_file}: {e}", exc_info=True)
|
|
1250
|
-
print(f"Warning: Could not copy session file {session_file}: {e}", file=sys.stderr)
|
|
1251
|
-
continue
|
|
1252
|
-
|
|
1253
|
-
if not session_relpaths:
|
|
1254
|
-
logger.warning("No session files copied successfully")
|
|
1255
|
-
return {
|
|
1256
|
-
"summary": "",
|
|
1257
|
-
"session_relpaths": [],
|
|
1258
|
-
"redacted": False,
|
|
1259
|
-
"summary_entries": [],
|
|
1260
|
-
"summary_model": "",
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
logger.info(f"Copied {len(session_relpaths)} session(s): {session_relpaths}")
|
|
1264
|
-
else:
|
|
1265
|
-
# Prepare-commit-msg: Just find existing sessions in .realign/sessions/
|
|
1266
|
-
# No need to copy again - pre-commit already did this
|
|
1267
|
-
logger.info("Prepare-commit-msg mode: Using existing sessions from .realign/sessions/")
|
|
1268
|
-
sessions_dir = repo_root / ".realign" / "sessions"
|
|
1269
|
-
|
|
1270
|
-
if sessions_dir.exists():
|
|
1271
|
-
# Find all session files that were processed by pre-commit
|
|
1272
|
-
for session_file in sessions_dir.glob("*.jsonl"):
|
|
1273
|
-
# Skip agent sessions (these are sub-tasks)
|
|
1274
|
-
if session_file.name.startswith("agent-"):
|
|
1275
|
-
continue
|
|
1276
|
-
|
|
1277
|
-
session_relpath = str(session_file.relative_to(repo_root))
|
|
1278
|
-
session_relpaths.append(session_relpath)
|
|
1279
|
-
|
|
1280
|
-
# Get file size
|
|
1281
|
-
try:
|
|
1282
|
-
content_size = session_file.stat().st_size
|
|
1283
|
-
session_metadata_map[session_relpath] = content_size
|
|
1284
|
-
except Exception as e:
|
|
1285
|
-
logger.warning(f"Could not get size for {session_relpath}: {e}")
|
|
1286
|
-
session_metadata_map[session_relpath] = 0
|
|
1287
|
-
|
|
1288
|
-
if not session_relpaths:
|
|
1289
|
-
logger.warning("No existing session files found in .realign/sessions/")
|
|
1290
|
-
return {
|
|
1291
|
-
"summary": "",
|
|
1292
|
-
"session_relpaths": [],
|
|
1293
|
-
"redacted": False,
|
|
1294
|
-
"summary_entries": [],
|
|
1295
|
-
"summary_model": "",
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
logger.info(f"Found {len(session_relpaths)} existing session(s): {session_relpaths}")
|
|
1299
|
-
|
|
1300
|
-
# If pre-commit mode, save metadata and return session paths (summary will be generated later)
|
|
1301
|
-
if pre_commit_mode:
|
|
1302
|
-
# Save metadata for each session to prevent reprocessing
|
|
1303
|
-
for session_relpath, content_size in session_metadata_map.items():
|
|
1304
|
-
save_session_metadata(repo_root, session_relpath, content_size)
|
|
1305
|
-
logger.debug(f"Saved metadata for {session_relpath} in pre-commit")
|
|
1306
|
-
|
|
1307
|
-
elapsed = time.time() - start_time
|
|
1308
|
-
logger.info(f"======== Hook completed: {hook_type} in {elapsed:.2f}s ========")
|
|
1309
|
-
return {
|
|
1310
|
-
"summary": "",
|
|
1311
|
-
"session_relpaths": session_relpaths,
|
|
1312
|
-
"redacted": any_redacted,
|
|
1313
|
-
"summary_entries": [],
|
|
1314
|
-
"summary_model": "",
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
# For prepare-commit-msg mode, we need to stage files first to get accurate diff
|
|
1318
|
-
# This ensures git diff --cached works correctly
|
|
1319
|
-
try:
|
|
1320
|
-
for session_relpath in session_relpaths:
|
|
1321
|
-
subprocess.run(
|
|
1322
|
-
["git", "add", session_relpath],
|
|
1323
|
-
cwd=repo_root,
|
|
1324
|
-
check=True,
|
|
1325
|
-
)
|
|
1326
|
-
logger.debug("Session files staged successfully")
|
|
1327
|
-
except subprocess.CalledProcessError as e:
|
|
1328
|
-
logger.error(f"Failed to stage session files: {e}", exc_info=True)
|
|
1329
|
-
print(f"Warning: Could not stage session files: {e}", file=sys.stderr)
|
|
1330
|
-
|
|
1331
|
-
# For prepare-commit-msg mode, generate summary from all sessions
|
|
1332
|
-
logger.info("Generating summaries for sessions")
|
|
1333
|
-
summary_entries: List[Dict[str, str]] = []
|
|
1334
|
-
legacy_summary_chunks: List[str] = []
|
|
1335
|
-
summary_model_label: Optional[str] = None
|
|
1336
|
-
|
|
1337
|
-
for session_relpath in session_relpaths:
|
|
1338
|
-
# Extract NEW content using git diff (compares staged content with HEAD)
|
|
1339
|
-
# This correctly detects new content even if the file hasn't grown since pre-commit
|
|
1340
|
-
# (which happens in auto-commit scenarios where the AI has finished responding)
|
|
1341
|
-
current_size = session_metadata_map.get(session_relpath, 0)
|
|
1342
|
-
new_content = get_new_content_from_git_diff(repo_root, session_relpath)
|
|
1343
|
-
|
|
1344
|
-
if not new_content or not new_content.strip():
|
|
1345
|
-
logger.debug(f"No new content for {session_relpath}, skipping summary")
|
|
1346
|
-
continue
|
|
1347
|
-
|
|
1348
|
-
# Generate summary for NEW content only
|
|
1349
|
-
summary_title: Optional[str] = None
|
|
1350
|
-
summary_description: str = ""
|
|
1351
|
-
is_llm_summary = False
|
|
1352
|
-
llm_model_name: Optional[str] = None
|
|
1353
|
-
if config.use_LLM:
|
|
1354
|
-
print(f"🤖 Attempting to generate LLM summary (provider: {config.llm_provider})...", file=sys.stderr)
|
|
1355
|
-
summary_title, llm_model_name, summary_description = generate_summary_with_llm(
|
|
1356
|
-
new_content,
|
|
1357
|
-
config.summary_max_chars,
|
|
1358
|
-
config.llm_provider
|
|
1359
|
-
)
|
|
1360
|
-
|
|
1361
|
-
if summary_title:
|
|
1362
|
-
print("✅ LLM summary generated successfully", file=sys.stderr)
|
|
1363
|
-
is_llm_summary = True
|
|
1364
|
-
if summary_model_label is None:
|
|
1365
|
-
summary_model_label = llm_model_name or config.llm_provider
|
|
1366
|
-
else:
|
|
1367
|
-
print("⚠️ LLM summary failed - falling back to local summarization", file=sys.stderr)
|
|
1368
|
-
print(" Check your API keys: ANTHROPIC_API_KEY or OPENAI_API_KEY", file=sys.stderr)
|
|
1369
|
-
|
|
1370
|
-
if not summary_title:
|
|
1371
|
-
# Fallback to simple summarize
|
|
1372
|
-
logger.info("Using local summarization (no LLM)")
|
|
1373
|
-
print("📝 Using local summarization (no LLM)", file=sys.stderr)
|
|
1374
|
-
summary_title = simple_summarize(new_content, config.summary_max_chars)
|
|
1375
|
-
summary_description = ""
|
|
1376
|
-
|
|
1377
|
-
# Identify agent type from filename
|
|
1378
|
-
agent_name = detect_agent_from_session_path(session_relpath)
|
|
1379
|
-
|
|
1380
|
-
summary_title = summary_title.strip()
|
|
1381
|
-
logger.debug(f"Summary for {session_relpath} ({agent_name}): {summary_title[:100]}...")
|
|
1382
|
-
logger.debug(f"Description: {summary_description[:100]}...")
|
|
1383
|
-
summary_entries.append({
|
|
1384
|
-
"agent": agent_name,
|
|
1385
|
-
"title": summary_title,
|
|
1386
|
-
"description": summary_description,
|
|
1387
|
-
"source": "llm" if is_llm_summary else "local",
|
|
1388
|
-
})
|
|
1389
|
-
legacy_summary_chunks.append(f"[{agent_name}] {summary_title}")
|
|
1390
|
-
|
|
1391
|
-
# Update metadata after successfully generating summary
|
|
1392
|
-
save_session_metadata(repo_root, session_relpath, current_size)
|
|
1393
|
-
logger.debug(f"Updated metadata for {session_relpath} in prepare-commit-msg")
|
|
1394
|
-
|
|
1395
|
-
# Combine all summaries
|
|
1396
|
-
if summary_entries:
|
|
1397
|
-
if summary_model_label is None:
|
|
1398
|
-
summary_model_label = "Local summarizer"
|
|
1399
|
-
combined_summary = " | ".join(legacy_summary_chunks)
|
|
1400
|
-
logger.info(f"Generated {len(summary_entries)} summary(ies)")
|
|
1401
|
-
else:
|
|
1402
|
-
combined_summary = "No new content in sessions"
|
|
1403
|
-
logger.info("No summaries generated (no new content)")
|
|
1404
|
-
|
|
1405
|
-
elapsed = time.time() - start_time
|
|
1406
|
-
logger.info(f"======== Hook completed: {hook_type} in {elapsed:.2f}s ========")
|
|
1407
|
-
|
|
1408
|
-
return {
|
|
1409
|
-
"summary": combined_summary,
|
|
1410
|
-
"session_relpaths": session_relpaths,
|
|
1411
|
-
"redacted": any_redacted,
|
|
1412
|
-
"summary_entries": summary_entries,
|
|
1413
|
-
"summary_model": summary_model_label or "",
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
def pre_commit_hook():
|
|
1418
|
-
"""
|
|
1419
|
-
Entry point for pre-commit hook.
|
|
1420
|
-
Finds and stages session files.
|
|
1421
|
-
"""
|
|
1422
|
-
result = process_sessions(pre_commit_mode=True)
|
|
1423
|
-
print(json.dumps(result, ensure_ascii=False))
|
|
1424
|
-
|
|
1425
|
-
# Stage the session files
|
|
1426
|
-
if result["session_relpaths"]:
|
|
1427
|
-
try:
|
|
1428
|
-
subprocess.run(
|
|
1429
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
1430
|
-
capture_output=True,
|
|
1431
|
-
text=True,
|
|
1432
|
-
check=True,
|
|
1433
|
-
)
|
|
1434
|
-
repo_root_result = subprocess.run(
|
|
1435
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
1436
|
-
capture_output=True,
|
|
1437
|
-
text=True,
|
|
1438
|
-
check=True,
|
|
1439
|
-
)
|
|
1440
|
-
repo_root = repo_root_result.stdout.strip()
|
|
1441
|
-
|
|
1442
|
-
for session_path in result["session_relpaths"]:
|
|
1443
|
-
subprocess.run(
|
|
1444
|
-
["git", "add", session_path],
|
|
1445
|
-
cwd=repo_root,
|
|
1446
|
-
check=True,
|
|
1447
|
-
)
|
|
1448
|
-
except subprocess.CalledProcessError as e:
|
|
1449
|
-
print(f"Warning: Could not stage session files: {e}", file=sys.stderr)
|
|
1450
|
-
|
|
1451
|
-
sys.exit(0)
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
def prepare_commit_msg_hook():
|
|
1455
|
-
"""
|
|
1456
|
-
Entry point for prepare-commit-msg hook.
|
|
1457
|
-
Generates session summary and appends to commit message.
|
|
1458
|
-
"""
|
|
1459
|
-
# Get commit message file path from command line arguments
|
|
1460
|
-
# When called via __main__ with --prepare-commit-msg flag, the file is at index 2
|
|
1461
|
-
# When called directly as a hook entry point, the file is at index 1
|
|
1462
|
-
if sys.argv[1] == "--prepare-commit-msg":
|
|
1463
|
-
# Called via: python -m realign.hooks --prepare-commit-msg <msg-file> <source>
|
|
1464
|
-
if len(sys.argv) < 3:
|
|
1465
|
-
print("Error: Commit message file path not provided", file=sys.stderr)
|
|
1466
|
-
sys.exit(1)
|
|
1467
|
-
msg_file = sys.argv[2]
|
|
1468
|
-
else:
|
|
1469
|
-
# Called via: realign-hook-prepare-commit-msg <msg-file> <source>
|
|
1470
|
-
msg_file = sys.argv[1]
|
|
1471
|
-
|
|
1472
|
-
# Process sessions and generate summary
|
|
1473
|
-
result = process_sessions(pre_commit_mode=False)
|
|
1474
|
-
|
|
1475
|
-
# Append summary to commit message
|
|
1476
|
-
summary_entries = result.get("summary_entries") or []
|
|
1477
|
-
session_relpaths = result.get("session_relpaths") or []
|
|
1478
|
-
|
|
1479
|
-
if summary_entries:
|
|
1480
|
-
try:
|
|
1481
|
-
# Read existing commit message to check if user provided one
|
|
1482
|
-
with open(msg_file, "r", encoding="utf-8") as f:
|
|
1483
|
-
existing_msg = f.read().strip()
|
|
1484
|
-
|
|
1485
|
-
# Check if this is an auto-commit (empty message or only comments)
|
|
1486
|
-
is_auto_commit = not existing_msg or all(
|
|
1487
|
-
line.startswith("#") for line in existing_msg.split("\n") if line.strip()
|
|
1488
|
-
)
|
|
1489
|
-
|
|
1490
|
-
# Get username from first session path
|
|
1491
|
-
username = get_username(session_relpaths[0] if session_relpaths else "")
|
|
1492
|
-
|
|
1493
|
-
# Get timestamp
|
|
1494
|
-
from datetime import datetime
|
|
1495
|
-
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
1496
|
-
|
|
1497
|
-
with open(msg_file, "a", encoding="utf-8") as f:
|
|
1498
|
-
# Combine all summaries
|
|
1499
|
-
first_entry = summary_entries[0]
|
|
1500
|
-
title = first_entry.get("title", "Session update").strip()
|
|
1501
|
-
agent_label = first_entry.get("agent", "Unknown")
|
|
1502
|
-
description = first_entry.get("description", "").strip()
|
|
1503
|
-
|
|
1504
|
-
if is_auto_commit:
|
|
1505
|
-
# Auto-commit format: use aline summary as the main message
|
|
1506
|
-
f.write(f"aline: {title}\n\n")
|
|
1507
|
-
if description:
|
|
1508
|
-
f.write(f"{description}\n\n")
|
|
1509
|
-
f.write(f"Agent: {agent_label}\n")
|
|
1510
|
-
f.write(f"User: {username}\n")
|
|
1511
|
-
f.write(f"Timestamp: {timestamp}\n")
|
|
1512
|
-
else:
|
|
1513
|
-
# User-provided commit message format: append aline summary after separator
|
|
1514
|
-
f.write("\n\n---\n")
|
|
1515
|
-
f.write(f"aline: {title}\n\n")
|
|
1516
|
-
if description:
|
|
1517
|
-
f.write(f"{description}\n\n")
|
|
1518
|
-
f.write(f"Agent: {agent_label}\n")
|
|
1519
|
-
f.write(f"User: {username}\n")
|
|
1520
|
-
f.write(f"Timestamp: {timestamp}\n")
|
|
1521
|
-
|
|
1522
|
-
# Add redaction marker if applicable
|
|
1523
|
-
if result.get("redacted"):
|
|
1524
|
-
f.write("Redacted: true\n")
|
|
1525
|
-
|
|
1526
|
-
except Exception as e:
|
|
1527
|
-
print(f"Warning: Could not append to commit message: {e}", file=sys.stderr)
|
|
1528
|
-
import traceback
|
|
1529
|
-
logger.error(f"Commit message formatting error: {traceback.format_exc()}")
|
|
1530
|
-
|
|
1531
|
-
sys.exit(0)
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
if __name__ == "__main__":
|
|
1535
|
-
# This allows the module to be run directly for testing
|
|
1536
|
-
import sys
|
|
1537
|
-
if len(sys.argv) > 1:
|
|
1538
|
-
if sys.argv[1] == "--pre-commit":
|
|
1539
|
-
pre_commit_hook()
|
|
1540
|
-
elif sys.argv[1] == "--prepare-commit-msg":
|
|
1541
|
-
prepare_commit_msg_hook()
|
|
1542
|
-
else:
|
|
1543
|
-
print("Usage: python -m realign.hooks [--pre-commit|--prepare-commit-msg]")
|
|
1544
|
-
sys.exit(1)
|
|
1545
|
-
else:
|
|
1546
|
-
print("Usage: python -m realign.hooks [--pre-commit|--prepare-commit-msg]")
|
|
1547
|
-
sys.exit(1)
|
realign/logging_config.py
CHANGED
|
@@ -40,7 +40,7 @@ def get_log_directory() -> Path:
|
|
|
40
40
|
"""
|
|
41
41
|
Get the log directory path.
|
|
42
42
|
|
|
43
|
-
Default: ~/.
|
|
43
|
+
Default: ~/.aline/.logs/
|
|
44
44
|
Can be overridden with REALIGN_LOG_DIR environment variable.
|
|
45
45
|
|
|
46
46
|
Returns:
|
|
@@ -51,7 +51,7 @@ def get_log_directory() -> Path:
|
|
|
51
51
|
if log_dir_str:
|
|
52
52
|
log_dir = Path(log_dir_str).expanduser()
|
|
53
53
|
else:
|
|
54
|
-
log_dir = Path.home() / '.
|
|
54
|
+
log_dir = Path.home() / '.aline' / '.logs'
|
|
55
55
|
|
|
56
56
|
# Create directory if it doesn't exist
|
|
57
57
|
log_dir.mkdir(parents=True, exist_ok=True)
|