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.
Files changed (45) hide show
  1. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
  2. aline_ai-0.3.0.dist-info/RECORD +41 -0
  3. aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
  4. realign/__init__.py +32 -1
  5. realign/cli.py +203 -19
  6. realign/commands/__init__.py +2 -2
  7. realign/commands/clean.py +149 -0
  8. realign/commands/config.py +1 -1
  9. realign/commands/export_shares.py +1785 -0
  10. realign/commands/hide.py +112 -24
  11. realign/commands/import_history.py +873 -0
  12. realign/commands/init.py +104 -217
  13. realign/commands/mirror.py +131 -0
  14. realign/commands/pull.py +101 -0
  15. realign/commands/push.py +155 -245
  16. realign/commands/review.py +216 -54
  17. realign/commands/session_utils.py +139 -4
  18. realign/commands/share.py +965 -0
  19. realign/commands/status.py +559 -0
  20. realign/commands/sync.py +91 -0
  21. realign/commands/undo.py +423 -0
  22. realign/commands/watcher.py +805 -0
  23. realign/config.py +21 -10
  24. realign/file_lock.py +3 -1
  25. realign/hash_registry.py +310 -0
  26. realign/hooks.py +115 -411
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +997 -139
  30. realign/mirror_utils.py +322 -0
  31. realign/prompts/__init__.py +21 -0
  32. realign/prompts/presets.py +238 -0
  33. realign/redactor.py +168 -16
  34. realign/tracker/__init__.py +9 -0
  35. realign/tracker/git_tracker.py +1123 -0
  36. realign/watcher_daemon.py +115 -0
  37. aline_ai-0.2.6.dist-info/RECORD +0 -28
  38. aline_ai-0.2.6.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -242
  40. realign/commands/commit.py +0 -379
  41. realign/commands/search.py +0 -449
  42. realign/commands/show.py +0 -416
  43. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {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 (max 150 chars, imperative mood, like 'Add feature X' or 'Fix bug in Y'. NEVER truncate words - keep whole words only. Omit articles like 'the', 'a' when possible to save space)",
592
- "description": "Detailed description of what happened in this session (200-400 chars). Focus on key actions, decisions, and outcomes. Don't worry about perfect grammar - clarity and completeness matter more. Include specific details like function names, features discussed, bugs fixed, etc."
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 specific and informative (200-400 characters)
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
- - Don't worry about perfect grammar - focus on conveying the right meaning clearly
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=300, # Increased for keywords
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
- json_str = response_text[json_start:json_end].strip()
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
- json_str = response_text[json_start:json_end].strip()
669
- else:
670
- json_str = response_text
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", "")[:150] # Enforce 150 char limit
674
- description = summary_data.get("description", "")[:400] # Enforce 400 char limit
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
- print(" ⚠️ Claude response was not valid JSON, using fallback", file=sys.stderr)
685
- return first_line, "claude-3-5-haiku-20241022", ""
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=300, # Increased for keywords
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
- json_str = response_text[json_start:json_end].strip()
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
- json_str = response_text[json_start:json_end].strip()
772
- else:
773
- json_str = response_text
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", "")[:150] # Enforce 150 char limit
777
- description = summary_data.get("description", "")[:400] # Enforce 400 char limit
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
- print(" ⚠️ OpenAI response was not valid JSON, using fallback", file=sys.stderr)
788
- return first_line, "gpt-3.5-turbo", ""
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 .realign/sessions/ directory.
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
- sessions_dir = repo_root / ".realign" / "sessions"
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
- metadata_dir = repo_root / ".realign" / ".metadata"
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
- metadata_dir = repo_root / ".realign" / ".metadata"
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: ~/.realign/.logs/
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() / '.realign' / '.logs'
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)