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.
Files changed (45) hide show
  1. {aline_ai-0.2.5.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 +368 -384
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +999 -142
  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.5.dist-info/RECORD +0 -28
  38. aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -231
  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.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {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 (summary, model_name) tuple, or (None, None) if LLM is unavailable.
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}, max_chars: {max_chars})")
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
- # Truncate content for API (to avoid token limits)
465
- # Approximately 4000 chars = ~1000 tokens
466
- truncated_content = content[:4000]
467
- if len(content) > 4000:
468
- logger.debug(f"Content truncated from {len(content)} to 4000 chars for LLM API")
469
-
470
- # System prompt for summarization
471
- system_prompt = (
472
- "You are a helpful assistant that summarizes NEW content added to AI agent chat sessions. "
473
- "Provide a concise summary in English focusing on the main topics and actions in the NEW content."
474
- )
475
- user_prompt = f"Summarize this NEW content from an AI chat session in one or two sentences:\n\n{truncated_content}"
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=150,
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
- summary = response.content[0].text.strip()
509
- logger.info(f"Claude API success: {len(summary)} chars in {elapsed:.2f}s")
510
- logger.debug(f"Claude response: {summary[:100]}...")
511
- print(" ✅ Anthropic (Claude) summary successful", file=sys.stderr)
512
- return summary[:max_chars], "claude-3-5-haiku-20241022"
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=150,
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
- summary = response.choices[0].message.content.strip()
585
- logger.info(f"OpenAI API success: {len(summary)} chars in {elapsed:.2f}s")
586
- logger.debug(f"OpenAI response: {summary[:100]}...")
587
- print(" ✅ OpenAI (GPT) summary successful", file=sys.stderr)
588
- return summary[:max_chars], "gpt-3.5-turbo"
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 .realign/sessions/ directory.
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
- 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"
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
- 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"
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
- 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"
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)