aline-ai 0.2.5__py3-none-any.whl → 0.2.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aline-ai
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Shared AI memory; everyone knows everything in teams
5
5
  Author: Sharemind
6
6
  License: MIT
@@ -1,17 +1,17 @@
1
- aline_ai-0.2.5.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
- realign/__init__.py,sha256=zx1q8jbbAtSFt2eDYbUqaqss8LohQnvmI4gRY_HWVWY,68
1
+ aline_ai-0.2.6.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
+ realign/__init__.py,sha256=kYl1kB-KlgNv4t7nc-BFE8PSabtXYIzbte8yU4ZaTvY,68
3
3
  realign/claude_detector.py,sha256=NLxI0zJWcqNxNha9jAy9AslTMwHKakCc9yPGdkrbiFE,3028
4
4
  realign/cli.py,sha256=WSdT9zpHLswBeDPjb5M_U2cvhIm7yrgnXPDyb7vD-jg,2798
5
5
  realign/codex_detector.py,sha256=RI3JbZgebrhoqpRfTBMfclYCAISN7hZAHVW3bgftJpU,4428
6
6
  realign/config.py,sha256=jarinbr0mA6e5DmgY19b_VpMnxk6SOYTwyvB9luq0ww,7207
7
7
  realign/file_lock.py,sha256=-9c3tMdMj_ZxmasK5y6hV9Gfo6KDsSO3Q7PXiTBhsu4,3369
8
- realign/hooks.py,sha256=GzkEbW35-ifwAilYbVxDpNURZ_7XrF68DTqDSQ8v7fE,50670
8
+ realign/hooks.py,sha256=ZckwB_W7nx5WO_JfyqXP78peL-HDiefoZWE43yBhRtI,63854
9
9
  realign/logging_config.py,sha256=KvkKktF-bkUu031y9vgUoHpsbnOw7ud25jhpzliNZwA,4929
10
10
  realign/mcp_server.py,sha256=Q82nuEm6LF17eKUZfHHt6exQjzOWbbBmGXLYwNIGnoo,21002
11
- realign/mcp_watcher.py,sha256=ffYOXDLuf9T6Kab3CdGNAOY3DBlAbjZrVrSjM5RdYGU,26828
11
+ realign/mcp_watcher.py,sha256=XmpaX5X8Dm91RCZKC5PoGPNaF1ssUcpRKOe4zWPJ-0A,26751
12
12
  realign/redactor.py,sha256=FizaGSdW-QTBAQl4h-gtmMpx2mFrfd2a5DoPEPyLfRg,9989
13
13
  realign/commands/__init__.py,sha256=caHulsUeguKyy2ZIIa9hVwzGwNHfIbeHwZIC67C8gnI,213
14
- realign/commands/auto_commit.py,sha256=jgjAYZHqN34NmQkncZg3Vtwsl3MyAlsvucxEBwUj7ko,7450
14
+ realign/commands/auto_commit.py,sha256=3QMlUerXlIJgiwN1BKH6U--qwEqZupzoLPgyAivLr1A,7899
15
15
  realign/commands/commit.py,sha256=mlwrv5nfTRY17WlcAdiJKKGh5uM7dGvT7sMxhdbsfkw,12605
16
16
  realign/commands/config.py,sha256=iiu7usqw00djKZja5bx0iDH8DB0vU2maUPMkXLdgXwI,6609
17
17
  realign/commands/hide.py,sha256=i_XLsmsB4duLKNIA-eRAUvniS4N6GdQUyfN92CGndEA,34138
@@ -21,8 +21,8 @@ realign/commands/review.py,sha256=3TY6F87RGNEbutxvUr_konr24-gCyj5fmD9usd3n4-U,15
21
21
  realign/commands/search.py,sha256=xTWuX0lpjQPX8cen0ewl-BNF0FeWgjMwN06bdeesED8,18770
22
22
  realign/commands/session_utils.py,sha256=L1DwZIGCOBirp6tkAswACJEeDa6i9aAAfsialAs4rRY,864
23
23
  realign/commands/show.py,sha256=A9LvhOBcY6_HoI76irPB2rBOSgdftBuX2uZiO8IwNoU,16338
24
- aline_ai-0.2.5.dist-info/METADATA,sha256=Deqqt3Tjs66ZW_76UA7itCXB_YLnhcO-rGa6vN_6vys,1437
25
- aline_ai-0.2.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
- aline_ai-0.2.5.dist-info/entry_points.txt,sha256=h-NocHDzSueXfsepHTIdRPNQzhNZQPAztJfldd-mQTE,202
27
- aline_ai-0.2.5.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
28
- aline_ai-0.2.5.dist-info/RECORD,,
24
+ aline_ai-0.2.6.dist-info/METADATA,sha256=UqZkpvO5NlU8KpL2KN7DezSmaWMTiS_Lrbcs-_xHmtg,1437
25
+ aline_ai-0.2.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
+ aline_ai-0.2.6.dist-info/entry_points.txt,sha256=h-NocHDzSueXfsepHTIdRPNQzhNZQPAztJfldd-mQTE,202
27
+ aline_ai-0.2.6.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
28
+ aline_ai-0.2.6.dist-info/RECORD,,
realign/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Aline - AI Agent Chat Session Tracker."""
2
2
 
3
- __version__ = "0.2.5"
3
+ __version__ = "0.2.6"
@@ -85,10 +85,16 @@ def get_session_mtimes(repo_root: Path) -> dict:
85
85
 
86
86
 
87
87
  def generate_commit_message() -> str:
88
- """Generate an automatic commit message."""
89
- from datetime import datetime
90
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
91
- return f"chore: Auto-commit MCP session ({timestamp})"
88
+ """
89
+ Generate an automatic commit message.
90
+
91
+ DEPRECATED: This function is no longer used for auto-commits.
92
+ The commit message is now generated by the prepare-commit-msg hook
93
+ with LLM-based summaries and keywords.
94
+
95
+ Returns empty string to let the hook generate the message.
96
+ """
97
+ return ""
92
98
 
93
99
 
94
100
  def auto_commit_once(repo_root: Path, message: Optional[str] = None, silent: bool = False) -> bool:
@@ -97,12 +103,13 @@ def auto_commit_once(repo_root: Path, message: Optional[str] = None, silent: boo
97
103
 
98
104
  Args:
99
105
  repo_root: Path to the repository root
100
- message: Custom commit message (auto-generated if not provided)
106
+ message: Custom commit message (if provided, will be used as-is and aline summary appended)
101
107
  silent: If True, suppress console output (for watcher mode)
102
108
 
103
109
  Returns:
104
110
  True if commit was successful, False otherwise
105
111
  """
112
+ # If user provided a custom message, use it; otherwise let the hook generate it
106
113
  commit_message = message or generate_commit_message()
107
114
 
108
115
  try:
@@ -124,7 +131,11 @@ def auto_commit_once(repo_root: Path, message: Optional[str] = None, silent: boo
124
131
 
125
132
  if result.returncode == 0:
126
133
  if not silent:
127
- console.print(f"[green]✓[/green] Auto-committed: {commit_message}")
134
+ # If message was provided by user, show it; otherwise indicate auto-commit
135
+ if message:
136
+ console.print(f"[green]✓[/green] Auto-committed: {message}")
137
+ else:
138
+ console.print("[green]✓[/green] Auto-committed successfully")
128
139
  return True
129
140
  else:
130
141
  # Check if it's just "no changes" error
realign/hooks.py CHANGED
@@ -356,6 +356,115 @@ def find_latest_session(history_path: Path, explicit_path: Optional[str] = None)
356
356
  return max(session_files, key=lambda p: p.stat().st_mtime)
357
357
 
358
358
 
359
+ def filter_session_content(content: str) -> Tuple[str, str, str]:
360
+ """
361
+ Filter session content to extract meaningful information for LLM summarization.
362
+
363
+ Filters out exploratory operations (Read, Grep, Glob) and technical details,
364
+ keeping only user requests, AI responses, and code changes.
365
+
366
+ Args:
367
+ content: Raw text content of new session additions
368
+
369
+ Returns:
370
+ Tuple of (user_messages, assistant_replies, code_changes)
371
+ """
372
+ if not content or not content.strip():
373
+ return "", "", ""
374
+
375
+ user_messages = []
376
+ assistant_replies = []
377
+ code_changes = []
378
+
379
+ lines = content.strip().split("\n")
380
+
381
+ for line in lines:
382
+ line = line.strip()
383
+ if not line:
384
+ continue
385
+
386
+ try:
387
+ obj = json.loads(line)
388
+
389
+ # Extract user messages and tool results
390
+ if obj.get("type") == "user":
391
+ msg = obj.get("message", {})
392
+ if isinstance(msg, dict):
393
+ content_data = msg.get("content", "")
394
+ if isinstance(content_data, str) and content_data.strip():
395
+ user_messages.append(content_data.strip())
396
+ elif isinstance(content_data, list):
397
+ # Extract text from content list
398
+ for item in content_data:
399
+ if isinstance(item, dict):
400
+ if item.get("type") == "text":
401
+ text = item.get("text", "").strip()
402
+ if text:
403
+ user_messages.append(text)
404
+ # Extract code changes from tool results
405
+ elif item.get("type") == "tool_result":
406
+ tool_use_result = obj.get("toolUseResult", {})
407
+ if "oldString" in tool_use_result and "newString" in tool_use_result:
408
+ # This is an Edit operation
409
+ new_string = tool_use_result.get("newString", "")
410
+ if new_string:
411
+ code_changes.append(f"Edit: {new_string[:300]}")
412
+ elif "content" in tool_use_result and "filePath" in tool_use_result:
413
+ # This is a Write operation
414
+ new_content = tool_use_result.get("content", "")
415
+ if new_content:
416
+ code_changes.append(f"Write: {new_content[:300]}")
417
+
418
+ # Extract assistant text replies (not tool use)
419
+ elif obj.get("type") == "assistant":
420
+ msg = obj.get("message", {})
421
+ if isinstance(msg, dict):
422
+ content_data = msg.get("content", [])
423
+ if isinstance(content_data, list):
424
+ for item in content_data:
425
+ if isinstance(item, dict):
426
+ # Only extract text blocks, skip tool_use blocks
427
+ if item.get("type") == "text":
428
+ text = item.get("text", "").strip()
429
+ if text:
430
+ assistant_replies.append(text)
431
+ # Extract code changes from Edit/Write tool uses
432
+ elif item.get("type") == "tool_use":
433
+ tool_name = item.get("name", "")
434
+ if tool_name in ("Edit", "Write"):
435
+ params = item.get("input", {})
436
+ if tool_name == "Edit":
437
+ new_string = params.get("new_string", "")
438
+ if new_string:
439
+ code_changes.append(f"Edit: {new_string[:200]}")
440
+ elif tool_name == "Write":
441
+ new_content = params.get("content", "")
442
+ if new_content:
443
+ code_changes.append(f"Write: {new_content[:200]}")
444
+
445
+ # Also handle simple role/content format (for compatibility)
446
+ elif obj.get("role") == "user":
447
+ content_text = obj.get("content", "")
448
+ if isinstance(content_text, str) and content_text.strip():
449
+ user_messages.append(content_text.strip())
450
+
451
+ elif obj.get("role") == "assistant":
452
+ content_text = obj.get("content", "")
453
+ if isinstance(content_text, str) and content_text.strip():
454
+ assistant_replies.append(content_text.strip())
455
+
456
+ except (json.JSONDecodeError, KeyError, TypeError):
457
+ # Not JSON or doesn't have expected structure, skip
458
+ continue
459
+
460
+ # Join with newlines for better readability
461
+ user_str = "\n".join(user_messages) if user_messages else ""
462
+ assistant_str = "\n".join(assistant_replies) if assistant_replies else ""
463
+ code_str = "\n".join(code_changes) if code_changes else ""
464
+
465
+ return user_str, assistant_str, code_str
466
+
467
+
359
468
  def simple_summarize(content: str, max_chars: int = 500) -> str:
360
469
  """
361
470
  Generate a simple summary from new session content.
@@ -445,34 +554,71 @@ def generate_summary_with_llm(
445
554
  content: str,
446
555
  max_chars: int = 500,
447
556
  provider: str = "auto"
448
- ) -> Tuple[Optional[str], Optional[str]]:
557
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
449
558
  """
450
559
  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.
560
+ Returns (title, model_name, description) tuple, or (None, None, None) if LLM is unavailable.
452
561
 
453
562
  Args:
454
563
  content: Raw text content of new session additions
455
- max_chars: Maximum characters in summary
564
+ max_chars: Maximum characters in summary (not used, kept for compatibility)
456
565
  provider: LLM provider to use - "auto" (try Claude then OpenAI), "claude", or "openai"
566
+
567
+ Returns:
568
+ Tuple of (title, model_name, description) where:
569
+ - title: One-line summary (max 150 chars)
570
+ - model_name: Name of the model used
571
+ - description: Detailed description of what happened (200-400 chars)
457
572
  """
458
- logger.info(f"Attempting to generate LLM summary (provider: {provider}, max_chars: {max_chars})")
573
+ logger.info(f"Attempting to generate LLM summary (provider: {provider})")
459
574
 
460
575
  if not content or not content.strip():
461
576
  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}"
577
+ return "No new content in this session", None, ""
578
+
579
+ # Filter content to extract meaningful information
580
+ user_messages, assistant_replies, code_changes = filter_session_content(content)
581
+
582
+ # If no meaningful content after filtering, return early
583
+ if not user_messages and not assistant_replies and not code_changes:
584
+ logger.debug("No meaningful content after filtering")
585
+ return "Session update with no significant changes", None, "No significant changes detected in this session"
586
+
587
+ # System prompt for structured summarization
588
+ system_prompt = """You are a git commit message generator for AI chat sessions.
589
+ Analyze the conversation and code changes, then generate a summary in JSON format:
590
+ {
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."
593
+ }
594
+
595
+ IMPORTANT for description:
596
+ - Be specific and informative (200-400 characters)
597
+ - Focus on WHAT was accomplished and WHY, not HOW
598
+ - Include technical details: function names, module names, specific features
599
+ - Mention key decisions or discussions
600
+ - Don't worry about perfect grammar - focus on conveying the right meaning clearly
601
+ - Avoid mentioning tool names like 'Edit', 'Write', 'Read'
602
+ - For discussions without code: summarize the topics and conclusions
603
+ - For code changes: describe what was changed and the purpose
604
+
605
+ Return JSON only, no other text."""
606
+
607
+ # Build user prompt with filtered content
608
+ user_prompt_parts = ["Summarize this AI chat session:\n"]
609
+
610
+ if user_messages:
611
+ user_prompt_parts.append(f"User requests:\n{user_messages[:1500]}\n")
612
+
613
+ if assistant_replies:
614
+ user_prompt_parts.append(f"AI responses:\n{assistant_replies[:1500]}\n")
615
+
616
+ if code_changes:
617
+ user_prompt_parts.append(f"Code changes:\n{code_changes[:1500]}\n")
618
+
619
+ user_prompt_parts.append("\nReturn JSON only, no other text.")
620
+
621
+ user_prompt = "\n".join(user_prompt_parts)
476
622
 
477
623
  # Determine which providers to try based on the provider parameter
478
624
  try_claude = provider in ("auto", "claude")
@@ -493,7 +639,7 @@ def generate_summary_with_llm(
493
639
 
494
640
  response = client.messages.create(
495
641
  model="claude-3-5-haiku-20241022", # Fast and cost-effective
496
- max_tokens=150,
642
+ max_tokens=300, # Increased for keywords
497
643
  temperature=0.7,
498
644
  system=system_prompt,
499
645
  messages=[
@@ -505,17 +651,44 @@ def generate_summary_with_llm(
505
651
  )
506
652
 
507
653
  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"
654
+ response_text = response.content[0].text.strip()
655
+ logger.info(f"Claude API success: {len(response_text)} chars in {elapsed:.2f}s")
656
+ logger.debug(f"Claude response: {response_text[:200]}...")
657
+
658
+ # Parse JSON response
659
+ try:
660
+ # Try to extract JSON if wrapped in markdown code blocks
661
+ if "```json" in response_text:
662
+ json_start = response_text.find("```json") + 7
663
+ json_end = response_text.find("```", json_start)
664
+ json_str = response_text[json_start:json_end].strip()
665
+ elif "```" in response_text:
666
+ json_start = response_text.find("```") + 3
667
+ json_end = response_text.find("```", json_start)
668
+ json_str = response_text[json_start:json_end].strip()
669
+ else:
670
+ json_str = response_text
671
+
672
+ 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
675
+
676
+ print(" ✅ Anthropic (Claude) summary successful", file=sys.stderr)
677
+ return title, "claude-3-5-haiku-20241022", description
678
+
679
+ except json.JSONDecodeError as e:
680
+ logger.warning(f"Failed to parse JSON from Claude response: {e}")
681
+ logger.debug(f"Raw response: {response_text}")
682
+ # 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", ""
513
686
 
514
687
  except ImportError:
515
688
  logger.warning("Anthropic package not installed")
516
689
  if provider == "claude":
517
690
  print(" ❌ Anthropic package not installed", file=sys.stderr)
518
- return None, None
691
+ return None, None, None
519
692
  else:
520
693
  print(" ❌ Anthropic package not installed, trying OpenAI...", file=sys.stderr)
521
694
  except Exception as e:
@@ -531,7 +704,7 @@ def generate_summary_with_llm(
531
704
  print(f" ❌ Anthropic quota/credit issue", file=sys.stderr)
532
705
  else:
533
706
  print(f" ❌ Anthropic API error: {e}", file=sys.stderr)
534
- return None, None
707
+ return None, None, None
535
708
  else:
536
709
  # Auto mode: try falling back to OpenAI
537
710
  if "authentication" in error_msg.lower() or "invalid" in error_msg.lower():
@@ -547,7 +720,7 @@ def generate_summary_with_llm(
547
720
  logger.debug("ANTHROPIC_API_KEY not set")
548
721
  if provider == "claude":
549
722
  print(" ❌ ANTHROPIC_API_KEY not set", file=sys.stderr)
550
- return None, None
723
+ return None, None, None
551
724
  else:
552
725
  print(" ⓘ ANTHROPIC_API_KEY not set, trying OpenAI...", file=sys.stderr)
553
726
 
@@ -576,21 +749,48 @@ def generate_summary_with_llm(
576
749
  "content": user_prompt
577
750
  }
578
751
  ],
579
- max_tokens=150,
752
+ max_tokens=300, # Increased for keywords
580
753
  temperature=0.7,
581
754
  )
582
755
 
583
756
  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"
757
+ response_text = response.choices[0].message.content.strip()
758
+ logger.info(f"OpenAI API success: {len(response_text)} chars in {elapsed:.2f}s")
759
+ logger.debug(f"OpenAI response: {response_text[:200]}...")
760
+
761
+ # Parse JSON response
762
+ try:
763
+ # Try to extract JSON if wrapped in markdown code blocks
764
+ if "```json" in response_text:
765
+ json_start = response_text.find("```json") + 7
766
+ json_end = response_text.find("```", json_start)
767
+ json_str = response_text[json_start:json_end].strip()
768
+ elif "```" in response_text:
769
+ json_start = response_text.find("```") + 3
770
+ json_end = response_text.find("```", json_start)
771
+ json_str = response_text[json_start:json_end].strip()
772
+ else:
773
+ json_str = response_text
774
+
775
+ 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
778
+
779
+ print(" ✅ OpenAI (GPT) summary successful", file=sys.stderr)
780
+ return title, "gpt-3.5-turbo", description
781
+
782
+ except json.JSONDecodeError as e:
783
+ logger.warning(f"Failed to parse JSON from OpenAI response: {e}")
784
+ logger.debug(f"Raw response: {response_text}")
785
+ # 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", ""
589
789
 
590
790
  except ImportError:
591
791
  logger.warning("OpenAI package not installed")
592
792
  print(" ❌ OpenAI package not installed", file=sys.stderr)
593
- return None, None
793
+ return None, None, None
594
794
  except Exception as e:
595
795
  error_msg = str(e)
596
796
  logger.error(f"OpenAI API error: {error_msg}", exc_info=True)
@@ -602,17 +802,17 @@ def generate_summary_with_llm(
602
802
  print(f" ❌ OpenAI quota/billing issue", file=sys.stderr)
603
803
  else:
604
804
  print(f" ❌ OpenAI API error: {e}", file=sys.stderr)
605
- return None, None
805
+ return None, None, None
606
806
  elif try_openai:
607
807
  logger.debug("OPENAI_API_KEY not set")
608
808
  print(" ❌ OPENAI_API_KEY not set", file=sys.stderr)
609
- return None, None
809
+ return None, None, None
610
810
 
611
811
  # No API keys available or provider not configured
612
812
  logger.warning(f"No LLM API keys available (provider: {provider})")
613
813
  if provider == "auto":
614
814
  print(" ❌ No LLM API keys configured", file=sys.stderr)
615
- return None, None
815
+ return None, None, None
616
816
 
617
817
 
618
818
  def generate_session_filename(user: str, agent: str = "claude") -> str:
@@ -694,6 +894,46 @@ def get_git_user() -> str:
694
894
  return os.getenv("USER", "unknown")
695
895
 
696
896
 
897
+ def get_username(session_relpath: str = "") -> str:
898
+ """
899
+ Get username for commit message.
900
+
901
+ Tries to get from git config first, then falls back to extracting
902
+ from session filename.
903
+
904
+ Args:
905
+ session_relpath: Relative path to session file (used for fallback)
906
+
907
+ Returns:
908
+ Username string
909
+ """
910
+ # Try git config first
911
+ try:
912
+ result = subprocess.run(
913
+ ["git", "config", "user.name"],
914
+ capture_output=True,
915
+ text=True,
916
+ check=True,
917
+ )
918
+ username = result.stdout.strip()
919
+ if username:
920
+ return username
921
+ except subprocess.CalledProcessError:
922
+ pass
923
+
924
+ # Fallback: extract from session filename
925
+ # Format: username_agent_hash.jsonl
926
+ if session_relpath:
927
+ filename = Path(session_relpath).name
928
+ parts = filename.split("_")
929
+ if len(parts) >= 3:
930
+ # First part is username
931
+ return parts[0]
932
+
933
+ # Final fallback
934
+ return os.getenv("USER", "unknown")
935
+
936
+
697
937
  def copy_session_to_repo(
698
938
  session_file: Path,
699
939
  repo_root: Path,
@@ -1106,18 +1346,19 @@ def process_sessions(
1106
1346
  continue
1107
1347
 
1108
1348
  # Generate summary for NEW content only
1109
- summary_text: Optional[str] = None
1349
+ summary_title: Optional[str] = None
1350
+ summary_description: str = ""
1110
1351
  is_llm_summary = False
1111
1352
  llm_model_name: Optional[str] = None
1112
1353
  if config.use_LLM:
1113
1354
  print(f"🤖 Attempting to generate LLM summary (provider: {config.llm_provider})...", file=sys.stderr)
1114
- summary_text, llm_model_name = generate_summary_with_llm(
1355
+ summary_title, llm_model_name, summary_description = generate_summary_with_llm(
1115
1356
  new_content,
1116
1357
  config.summary_max_chars,
1117
1358
  config.llm_provider
1118
1359
  )
1119
1360
 
1120
- if summary_text:
1361
+ if summary_title:
1121
1362
  print("✅ LLM summary generated successfully", file=sys.stderr)
1122
1363
  is_llm_summary = True
1123
1364
  if summary_model_label is None:
@@ -1126,23 +1367,26 @@ def process_sessions(
1126
1367
  print("⚠️ LLM summary failed - falling back to local summarization", file=sys.stderr)
1127
1368
  print(" Check your API keys: ANTHROPIC_API_KEY or OPENAI_API_KEY", file=sys.stderr)
1128
1369
 
1129
- if not summary_text:
1370
+ if not summary_title:
1130
1371
  # Fallback to simple summarize
1131
1372
  logger.info("Using local summarization (no LLM)")
1132
1373
  print("📝 Using local summarization (no LLM)", file=sys.stderr)
1133
- summary_text = simple_summarize(new_content, config.summary_max_chars)
1374
+ summary_title = simple_summarize(new_content, config.summary_max_chars)
1375
+ summary_description = ""
1134
1376
 
1135
1377
  # Identify agent type from filename
1136
1378
  agent_name = detect_agent_from_session_path(session_relpath)
1137
1379
 
1138
- summary_text = summary_text.strip()
1139
- logger.debug(f"Summary for {session_relpath} ({agent_name}): {summary_text[:100]}...")
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]}...")
1140
1383
  summary_entries.append({
1141
1384
  "agent": agent_name,
1142
- "text": summary_text,
1385
+ "title": summary_title,
1386
+ "description": summary_description,
1143
1387
  "source": "llm" if is_llm_summary else "local",
1144
1388
  })
1145
- legacy_summary_chunks.append(f"[{agent_name}] {summary_text}")
1389
+ legacy_summary_chunks.append(f"[{agent_name}] {summary_title}")
1146
1390
 
1147
1391
  # Update metadata after successfully generating summary
1148
1392
  save_session_metadata(repo_root, session_relpath, current_size)
@@ -1230,23 +1474,59 @@ def prepare_commit_msg_hook():
1230
1474
 
1231
1475
  # Append summary to commit message
1232
1476
  summary_entries = result.get("summary_entries") or []
1477
+ session_relpaths = result.get("session_relpaths") or []
1478
+
1233
1479
  if summary_entries:
1234
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
+
1235
1497
  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")
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
1246
1523
  if result.get("redacted"):
1247
- f.write("Agent-Redacted: true\n")
1524
+ f.write("Redacted: true\n")
1525
+
1248
1526
  except Exception as e:
1249
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()}")
1250
1530
 
1251
1531
  sys.exit(0)
1252
1532
 
realign/mcp_watcher.py CHANGED
@@ -442,9 +442,8 @@ class DialogueWatcher:
442
442
  session_file: Session file that triggered the commit
443
443
  """
444
444
  try:
445
- # Generate commit message
446
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
447
- message = f"chore: Auto-commit MCP session ({timestamp})"
445
+ # Use empty message to let prepare-commit-msg hook generate it with LLM summary
446
+ message = ""
448
447
 
449
448
  # Use realign commit command
450
449
  result = await asyncio.get_event_loop().run_in_executor(
@@ -455,8 +454,8 @@ class DialogueWatcher:
455
454
  )
456
455
 
457
456
  if result:
458
- logger.info(f"✓ Committed to {project_path.name}: {message}")
459
- print(f"[MCP Watcher] ✓ Committed to {project_path.name}: {message}", file=sys.stderr)
457
+ logger.info(f"✓ Committed to {project_path.name}")
458
+ print(f"[MCP Watcher] ✓ Auto-committed to {project_path.name}", file=sys.stderr)
460
459
  # Update last commit time for this project
461
460
  self.last_commit_times[str(project_path)] = time.time()
462
461
  # Baseline counts already updated in _check_if_turn_complete()