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
@@ -0,0 +1,873 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Import History command - Discover and display historical Claude Code sessions.
4
+
5
+ This command finds all unprocessed historical sessions for the current project,
6
+ parses them turn-by-turn, and displays them in chronological order. This is a
7
+ dry-run/preview mode that prepares for future auto-commit functionality.
8
+ """
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import List, Optional
15
+
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+
19
+ from ..hooks import get_claude_project_name, clean_user_message
20
+ from ..logging_config import setup_logger
21
+ from .. import get_realign_dir
22
+
23
+ # Initialize logger and console
24
+ logger = setup_logger('realign.import_history', 'import_history.log')
25
+ console = Console()
26
+
27
+
28
+ # ============================================================================
29
+ # Data Structures
30
+ # ============================================================================
31
+
32
+ @dataclass
33
+ class HistoricalTurn:
34
+ """Represents a single turn in a historical session."""
35
+ turn_number: int # 1-indexed turn number within session
36
+ session_id: str # Session ID (UUID)
37
+ session_file: Path # Path to original session file
38
+ timestamp: datetime # When the user message was sent
39
+ user_message: str # Full user message text
40
+ user_message_preview: str # First 80 chars for display
41
+
42
+
43
+ @dataclass
44
+ class HistoricalSession:
45
+ """Represents a discovered historical session."""
46
+ session_id: str # UUID from filename
47
+ session_file: Path # Full path to session file
48
+ created_at: datetime # Session creation time
49
+ modified_at: datetime # Last modification time
50
+ turns: List[HistoricalTurn] # All turns in chronological order
51
+ total_turns: int # Cached turn count
52
+
53
+
54
+ # ============================================================================
55
+ # Session Discovery Functions
56
+ # ============================================================================
57
+
58
+ def discover_historical_sessions(project_path: Path) -> List[Path]:
59
+ """
60
+ Find all Claude Code sessions for the current project.
61
+
62
+ Args:
63
+ project_path: The project directory path
64
+
65
+ Returns:
66
+ List of session file paths (unfiltered)
67
+ """
68
+ logger.info(f"Discovering Claude Code sessions for project: {project_path}")
69
+
70
+ # Get Claude project directory
71
+ encoded_name = get_claude_project_name(project_path)
72
+ claude_project_dir = Path.home() / ".claude" / "projects" / encoded_name
73
+
74
+ logger.debug(f"Claude project directory: {claude_project_dir}")
75
+
76
+ if not claude_project_dir.exists():
77
+ logger.info(f"Claude project directory not found: {claude_project_dir}")
78
+ return []
79
+
80
+ # Find all session files (exclude agent files)
81
+ all_sessions = []
82
+ for session_file in claude_project_dir.glob("*.jsonl"):
83
+ # Only include files starting with "session-" or UUID pattern
84
+ # Exclude "agent-*.jsonl" files
85
+ if not session_file.name.startswith("agent-"):
86
+ all_sessions.append(session_file)
87
+ logger.debug(f"Found session: {session_file.name}")
88
+
89
+ logger.info(f"Discovered {len(all_sessions)} total sessions")
90
+ return all_sessions
91
+
92
+
93
+ def filter_already_processed(sessions: List[Path], realign_dir: Path) -> List[Path]:
94
+ """
95
+ Filter out sessions that have already been processed.
96
+
97
+ Checks:
98
+ 1. Session files in .realign/sessions/ (by stem matching)
99
+ 2. Metadata files in .realign/.metadata/ (by session ID)
100
+
101
+ Args:
102
+ sessions: List of session file paths
103
+ realign_dir: Path to .realign directory
104
+
105
+ Returns:
106
+ List of unprocessed session file paths
107
+ """
108
+ sessions_dir = realign_dir / "sessions"
109
+ metadata_dir = realign_dir / ".metadata"
110
+
111
+ # Build set of processed session IDs
112
+ processed_ids = set()
113
+
114
+ # Check sessions directory
115
+ if sessions_dir.exists():
116
+ for processed_file in sessions_dir.glob("*.jsonl"):
117
+ # Extract session ID from filename (format: username_agent_sessionid.jsonl)
118
+ # Examples: minhao_claude_6e3d8ad3.jsonl, minhao_codex_019a7374.jsonl
119
+ parts = processed_file.stem.split('_')
120
+ if len(parts) >= 3:
121
+ session_id = parts[-1] # Last part is the session hash
122
+ processed_ids.add(session_id)
123
+ logger.debug(f"Marking session {session_id} as processed (in sessions/)")
124
+
125
+ # Check metadata directory
126
+ if metadata_dir.exists():
127
+ for meta_file in metadata_dir.glob("*.meta"):
128
+ # Metadata files are named: username_agent_sessionid.meta
129
+ parts = meta_file.stem.split('_')
130
+ if len(parts) >= 3:
131
+ session_id = parts[-1]
132
+ processed_ids.add(session_id)
133
+ logger.debug(f"Marking session {session_id} as processed (in .metadata/)")
134
+
135
+ # Filter sessions
136
+ unprocessed = []
137
+ for session_file in sessions:
138
+ # Extract session ID - could be UUID or hash
139
+ # Pattern 1: session-{UUID}.jsonl (Claude format)
140
+ # Pattern 2: {UUID}.jsonl (plain UUID)
141
+ session_name = session_file.stem
142
+ if session_name.startswith("session-"):
143
+ session_id = session_name.replace("session-", "")
144
+ else:
145
+ session_id = session_name
146
+
147
+ # Check if this session has been processed
148
+ # We need to check both full UUID and potential short hash
149
+ is_processed = False
150
+
151
+ # Check full ID
152
+ if session_id in processed_ids:
153
+ is_processed = True
154
+
155
+ # Check if any processed ID is a prefix of this session (short hash matching)
156
+ if not is_processed:
157
+ for proc_id in processed_ids:
158
+ if session_id.startswith(proc_id) or proc_id.startswith(session_id[:8]):
159
+ is_processed = True
160
+ break
161
+
162
+ if not is_processed:
163
+ unprocessed.append(session_file)
164
+ logger.debug(f"Session {session_id} is unprocessed")
165
+ else:
166
+ logger.debug(f"Session {session_id} already processed, skipping")
167
+
168
+ logger.info(f"Filtered to {len(unprocessed)} unprocessed sessions")
169
+ return unprocessed
170
+
171
+
172
+ # ============================================================================
173
+ # Turn Extraction Functions
174
+ # ============================================================================
175
+
176
+ def extract_text_from_content(content) -> str:
177
+ """
178
+ Extract plain text from Claude message content blocks.
179
+
180
+ Args:
181
+ content: Message content (can be string, list, or dict)
182
+
183
+ Returns:
184
+ Extracted text string
185
+ """
186
+ if isinstance(content, str):
187
+ return content
188
+
189
+ if isinstance(content, list):
190
+ text_parts = []
191
+ for item in content:
192
+ if isinstance(item, dict):
193
+ if item.get("type") == "text":
194
+ text_parts.append(item.get("text", ""))
195
+ elif isinstance(item, str):
196
+ text_parts.append(item)
197
+ return " ".join(text_parts)
198
+
199
+ return ""
200
+
201
+
202
+ def extract_turns_from_session(session_file: Path) -> List[HistoricalTurn]:
203
+ """
204
+ Parse JSONL session file and extract all turns with user messages.
205
+
206
+ Algorithm:
207
+ 1. Read line-by-line (JSONL format)
208
+ 2. For each line with type="user":
209
+ - Skip if it's a tool_result (check content blocks)
210
+ - Skip if it's continuation message ("This session is being continued")
211
+ - Extract timestamp and message text
212
+ - Use timestamp for deduplication (Claude 2.0 splits messages)
213
+ 3. Build list of unique turns ordered by timestamp
214
+
215
+ Args:
216
+ session_file: Path to session JSONL file
217
+
218
+ Returns:
219
+ List of HistoricalTurn objects
220
+ """
221
+ turns = []
222
+ user_messages_by_timestamp = {} # timestamp -> message text
223
+
224
+ try:
225
+ with open(session_file, 'r', encoding='utf-8') as f:
226
+ for line_num, line in enumerate(f, 1):
227
+ if not line.strip():
228
+ continue
229
+
230
+ try:
231
+ data = json.loads(line)
232
+ except json.JSONDecodeError as e:
233
+ logger.warning(f"Skipping malformed JSON on line {line_num} in {session_file.name}: {e}")
234
+ continue
235
+
236
+ # Only process user messages
237
+ if data.get("type") != "user":
238
+ continue
239
+
240
+ message = data.get("message", {})
241
+ content = message.get("content", [])
242
+
243
+ # Filter out tool results
244
+ is_tool_result = False
245
+ if isinstance(content, list):
246
+ for item in content:
247
+ if isinstance(item, dict) and item.get("type") == "tool_result":
248
+ is_tool_result = True
249
+ break
250
+
251
+ if is_tool_result:
252
+ continue
253
+
254
+ # Extract text from content
255
+ message_text = extract_text_from_content(content)
256
+
257
+ # Clean the message text (remove IDE tags, etc.)
258
+ message_text = clean_user_message(message_text)
259
+
260
+ # Skip empty messages
261
+ if not message_text.strip():
262
+ continue
263
+
264
+ # Skip continuation messages
265
+ if "This session is being continued" in message_text:
266
+ continue
267
+
268
+ # Extract timestamp
269
+ timestamp_str = data.get("timestamp")
270
+ if timestamp_str:
271
+ try:
272
+ # Handle ISO format with timezone
273
+ timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
274
+ user_messages_by_timestamp[timestamp] = message_text
275
+ except (ValueError, AttributeError) as e:
276
+ logger.warning(f"Failed to parse timestamp '{timestamp_str}': {e}")
277
+ continue
278
+
279
+ except FileNotFoundError:
280
+ logger.error(f"Session file not found: {session_file}")
281
+ return []
282
+ except Exception as e:
283
+ logger.error(f"Error reading session file {session_file}: {e}", exc_info=True)
284
+ return []
285
+
286
+ # Extract session ID from filename
287
+ session_id = session_file.stem.replace("session-", "") if session_file.name.startswith("session-") else session_file.stem
288
+
289
+ # Sort by timestamp and create Turn objects
290
+ for idx, (timestamp, text) in enumerate(sorted(user_messages_by_timestamp.items()), 1):
291
+ preview = text[:80] + "..." if len(text) > 80 else text
292
+ turns.append(HistoricalTurn(
293
+ turn_number=idx,
294
+ session_id=session_id,
295
+ session_file=session_file,
296
+ timestamp=timestamp,
297
+ user_message=text,
298
+ user_message_preview=preview
299
+ ))
300
+
301
+ logger.debug(f"Extracted {len(turns)} turns from {session_file.name}")
302
+ return turns
303
+
304
+
305
+ # ============================================================================
306
+ # Display Functions
307
+ # ============================================================================
308
+
309
+ def format_time_with_relative(dt: datetime) -> str:
310
+ """
311
+ Format datetime with both absolute and relative time.
312
+
313
+ Args:
314
+ dt: Datetime to format
315
+
316
+ Returns:
317
+ str: Formatted time string (e.g., "2025-11-29 14:23:45 (2 mins ago)")
318
+ """
319
+ absolute = dt.strftime('%Y-%m-%d %H:%M:%S')
320
+
321
+ # Calculate relative time
322
+ now = datetime.now()
323
+ diff = now - dt
324
+ total_seconds = diff.total_seconds()
325
+
326
+ if total_seconds < 60:
327
+ relative = "just now"
328
+ elif total_seconds < 3600:
329
+ mins = int(total_seconds // 60)
330
+ relative = f"{mins} min{'s' if mins != 1 else ''} ago"
331
+ elif diff.days == 0:
332
+ hours = int(total_seconds // 3600)
333
+ relative = f"{hours} hour{'s' if hours != 1 else ''} ago"
334
+ elif diff.days < 7:
335
+ relative = f"{diff.days} day{'s' if diff.days != 1 else ''} ago"
336
+ else:
337
+ weeks = diff.days // 7
338
+ relative = f"{weeks} week{'s' if weeks != 1 else ''} ago"
339
+
340
+ return f"{absolute} ({relative})"
341
+
342
+
343
+ def display_historical_sessions(
344
+ sessions: List[HistoricalSession],
345
+ verbose: bool = False,
346
+ limit: Optional[int] = None
347
+ ) -> None:
348
+ """
349
+ Display discovered sessions in formatted output.
350
+
351
+ Args:
352
+ sessions: List of HistoricalSession objects
353
+ verbose: Show full messages instead of previews
354
+ limit: Maximum number of sessions to display
355
+ """
356
+ if not sessions:
357
+ console.print("\n[yellow]No historical sessions found to import.[/yellow]")
358
+ console.print("[dim]All sessions have already been processed, or no Claude Code sessions exist for this project.[/dim]\n")
359
+ return
360
+
361
+ # Calculate statistics
362
+ total_turns = sum(s.total_turns for s in sessions)
363
+ date_range = f"{sessions[0].created_at.date()} to {sessions[-1].created_at.date()}" if len(sessions) > 1 else str(sessions[0].created_at.date())
364
+
365
+ # Header
366
+ console.print()
367
+ console.print(Panel.fit(
368
+ f"[bold cyan]ReAlign Historical Session Import[/bold cyan]\n\n"
369
+ f"Project: [green]{Path.cwd()}[/green]\n"
370
+ f"Discovered: [yellow]{len(sessions)}[/yellow] session{'s' if len(sessions) != 1 else ''} | "
371
+ f"[yellow]{total_turns}[/yellow] turn{'s' if total_turns != 1 else ''} | {date_range}",
372
+ border_style="cyan"
373
+ ))
374
+
375
+ # Display sessions
376
+ displayed = 0
377
+ for idx, session in enumerate(sessions, 1):
378
+ if limit and displayed >= limit:
379
+ remaining = len(sessions) - displayed
380
+ console.print(f"\n[dim]... and {remaining} more session{'s' if remaining != 1 else ''} (use --limit to see more)[/dim]\n")
381
+ break
382
+
383
+ # Session header
384
+ console.print(f"\n{'─' * 70}")
385
+ console.print(f"[bold cyan]Session #{idx}:[/bold cyan] [yellow]{session.session_id}[/yellow]")
386
+ time_str = format_time_with_relative(session.created_at)
387
+ console.print(f"[dim]Created: {time_str} | {session.total_turns} turn{'s' if session.total_turns != 1 else ''}[/dim]")
388
+ console.print(f"{'─' * 70}")
389
+
390
+ # Display turns
391
+ for turn in session.turns:
392
+ time_str = turn.timestamp.strftime('%Y-%m-%d %H:%M:%S')
393
+ message = turn.user_message if verbose else turn.user_message_preview
394
+ console.print(f" [cyan]Turn {turn.turn_number:2d}[/cyan] [{time_str}]")
395
+ console.print(f" [dim]User:[/dim] \"{message}\"")
396
+ console.print() # Blank line between turns
397
+
398
+ displayed += 1
399
+
400
+ console.print()
401
+
402
+
403
+ # ============================================================================
404
+ # Commit Functions
405
+ # ============================================================================
406
+
407
+ def is_turn_processed(realign_dir: Path, session_id: str, turn_number: int) -> bool:
408
+ """
409
+ Check if a turn has already been processed and committed.
410
+
411
+ Args:
412
+ realign_dir: Path to .realign directory
413
+ session_id: Session ID (UUID)
414
+ turn_number: Turn number within session
415
+
416
+ Returns:
417
+ True if turn has been processed, False otherwise
418
+ """
419
+ metadata_dir = realign_dir / ".metadata"
420
+ metadata_file = metadata_dir / f"{session_id}_turn_{turn_number}.meta"
421
+ return metadata_file.exists()
422
+
423
+
424
+ def save_turn_metadata(
425
+ realign_dir: Path,
426
+ session_id: str,
427
+ turn_number: int,
428
+ commit_hash: str
429
+ ):
430
+ """
431
+ Save metadata for a processed turn to enable resume functionality.
432
+
433
+ Args:
434
+ realign_dir: Path to .realign directory
435
+ session_id: Session ID (UUID)
436
+ turn_number: Turn number within session
437
+ commit_hash: Git commit hash
438
+ """
439
+ import time
440
+
441
+ metadata_dir = realign_dir / ".metadata"
442
+ metadata_dir.mkdir(parents=True, exist_ok=True)
443
+
444
+ metadata_file = metadata_dir / f"{session_id}_turn_{turn_number}.meta"
445
+ metadata = {
446
+ "processed_at": time.time(),
447
+ "commit_hash": commit_hash,
448
+ "turn_number": turn_number,
449
+ "session_id": session_id
450
+ }
451
+
452
+ with open(metadata_file, 'w', encoding='utf-8') as f:
453
+ json.dump(metadata, f, indent=2)
454
+
455
+ logger.debug(f"Saved metadata for turn {turn_number} of session {session_id}")
456
+
457
+
458
+ def generate_commit_message_for_turn(
459
+ partial_session_file: Path,
460
+ user_message: str,
461
+ turn_number: int,
462
+ use_llm: bool = False
463
+ ) -> tuple[str, str, str]:
464
+ """
465
+ Generate commit message for a turn, optionally using LLM.
466
+
467
+ Args:
468
+ partial_session_file: Path to partial session file (JSONL format)
469
+ user_message: User's message text (for fallback only)
470
+ turn_number: Turn number
471
+ use_llm: Whether to use LLM for summary generation
472
+
473
+ Returns:
474
+ Tuple of (title, description, model_name)
475
+ """
476
+ if not use_llm:
477
+ # Simple commit message without LLM
478
+ title_preview = user_message[:50]
479
+ if len(user_message) > 50:
480
+ title_preview += "..."
481
+ title = f"Turn #{turn_number}: {title_preview}"
482
+ description = "Historical session import - no summary"
483
+ model_name = "historical-import"
484
+ return title, description, model_name
485
+
486
+ # Use LLM to generate summary by passing the partial session file content
487
+ # to the existing generate_summary_with_llm function from hooks.py
488
+ from ..hooks import generate_summary_with_llm
489
+
490
+ try:
491
+ # Read the partial session file content (JSONL format)
492
+ with open(partial_session_file, 'r', encoding='utf-8') as f:
493
+ session_content = f.read()
494
+
495
+ # Call the existing LLM summary function
496
+ llm_title, model_name, llm_description = generate_summary_with_llm(
497
+ content=session_content,
498
+ provider="auto"
499
+ )
500
+
501
+ # If LLM failed, fall back to simple message
502
+ if not llm_title or not model_name:
503
+ title_preview = user_message[:50]
504
+ if len(user_message) > 50:
505
+ title_preview += "..."
506
+ title = f"Turn #{turn_number}: {title_preview}"
507
+ description = "Historical session import - LLM unavailable"
508
+ model_name = "historical-import"
509
+ return title, description, model_name
510
+
511
+ return llm_title, llm_description or "", model_name
512
+
513
+ except Exception as e:
514
+ logger.error(f"Error generating LLM summary: {e}", exc_info=True)
515
+ # Fallback to simple message on error
516
+ title_preview = user_message[:50]
517
+ if len(user_message) > 50:
518
+ title_preview += "..."
519
+ title = f"Turn #{turn_number}: {title_preview}"
520
+ description = f"Historical session import - LLM error: {str(e)}"
521
+ model_name = "historical-import"
522
+ return title, description, model_name
523
+
524
+
525
+ def create_partial_session_file(
526
+ original_session_file: Path,
527
+ realign_dir: Path,
528
+ session_id: str,
529
+ up_to_turn: int
530
+ ) -> Path:
531
+ """
532
+ Create a partial session file containing only the first N turns.
533
+
534
+ This ensures each turn's commit has meaningful session file changes.
535
+
536
+ Args:
537
+ original_session_file: Path to the full original session file
538
+ realign_dir: Path to .realign directory
539
+ session_id: Session ID (UUID)
540
+ up_to_turn: Number of turns to include (1-indexed)
541
+
542
+ Returns:
543
+ Path to the created partial session file
544
+ """
545
+ import json
546
+
547
+ sessions_dir = realign_dir / "sessions"
548
+ sessions_dir.mkdir(parents=True, exist_ok=True)
549
+
550
+ # Read the original session file and extract lines up to the target turn
551
+ session_lines = []
552
+ turn_count = 0
553
+
554
+ with open(original_session_file, 'r', encoding='utf-8') as f:
555
+ for line in f:
556
+ if not line.strip():
557
+ continue
558
+
559
+ data = json.loads(line)
560
+
561
+ # Count user messages (excluding tool results and continuations)
562
+ if data.get("type") == "user":
563
+ message = data.get("message", {})
564
+ content = message.get("content", [])
565
+
566
+ # Check if this is a tool result
567
+ is_tool_result = False
568
+ if isinstance(content, list):
569
+ for item in content:
570
+ if isinstance(item, dict) and item.get("type") == "tool_result":
571
+ is_tool_result = True
572
+ break
573
+
574
+ if not is_tool_result:
575
+ # Extract text to check for continuation messages
576
+ message_text = extract_text_from_content(content)
577
+ message_text = clean_user_message(message_text)
578
+
579
+ if message_text.strip() and "This session is being continued" not in message_text:
580
+ turn_count += 1
581
+
582
+ # Add this line to the partial session
583
+ session_lines.append(line)
584
+
585
+ # Stop when we've collected enough turns
586
+ if turn_count >= up_to_turn:
587
+ break
588
+
589
+ # Write partial session to a temporary file in sessions directory
590
+ partial_file = sessions_dir / f"{session_id}.jsonl"
591
+ with open(partial_file, 'w', encoding='utf-8') as f:
592
+ f.writelines(session_lines)
593
+
594
+ logger.debug(f"Created partial session file with {up_to_turn} turns: {partial_file}")
595
+ return partial_file
596
+
597
+
598
+ def commit_historical_turns(
599
+ sessions: List[HistoricalSession],
600
+ realign_dir: Path,
601
+ limit: Optional[int],
602
+ no_llm: bool
603
+ ) -> int:
604
+ """
605
+ Commit historical turns to git.
606
+
607
+ Args:
608
+ sessions: List of historical sessions
609
+ realign_dir: Path to .realign directory
610
+ limit: Maximum number of turns to process
611
+ no_llm: Skip LLM summary generation (currently always True)
612
+
613
+ Returns:
614
+ Exit code (0 = success, 1 = error)
615
+ """
616
+ import os
617
+ import getpass
618
+ from ..tracker.git_tracker import ReAlignGitTracker
619
+
620
+ # Step 1: Flatten sessions into list of turns
621
+ all_turns = []
622
+ for session in sessions:
623
+ all_turns.extend(session.turns)
624
+
625
+ # Step 2: Sort by timestamp globally
626
+ all_turns.sort(key=lambda t: t.timestamp)
627
+
628
+ # Step 3: Apply limit if specified
629
+ if limit:
630
+ all_turns = all_turns[:limit]
631
+ total_turns = limit
632
+ else:
633
+ total_turns = len(all_turns)
634
+
635
+ if total_turns == 0:
636
+ console.print("\n[yellow]No turns to process.[/yellow]\n")
637
+ return 0
638
+
639
+ # Display header
640
+ console.print()
641
+ console.print(Panel.fit(
642
+ f"[bold cyan]ReAlign Historical Session Import (Commit Mode)[/bold cyan]\n\n"
643
+ f"Project: [green]{Path.cwd()}[/green]\n"
644
+ f"Discovered: [yellow]{len(sessions)}[/yellow] session{'s' if len(sessions) != 1 else ''} | "
645
+ f"[yellow]{sum(len(s.turns) for s in sessions)}[/yellow] total turn{'s' if sum(len(s.turns) for s in sessions) != 1 else ''}\n"
646
+ f"Processing: [yellow]{total_turns}[/yellow] turn{'s' if total_turns != 1 else ''}" +
647
+ (f" (limited)" if limit else ""),
648
+ border_style="cyan"
649
+ ))
650
+ console.print()
651
+
652
+ # Step 4: Initialize git tracker
653
+ try:
654
+ git_tracker = ReAlignGitTracker(realign_dir)
655
+ except Exception as e:
656
+ logger.error(f"Failed to initialize git tracker: {e}", exc_info=True)
657
+ console.print(f"[red]Error:[/red] Failed to initialize git tracker: {e}")
658
+ return 1
659
+
660
+ # Get username for session file naming
661
+ try:
662
+ username = os.getenv('USER') or getpass.getuser()
663
+ except Exception:
664
+ username = "unknown"
665
+
666
+ # Step 5: Process each turn
667
+ committed_count = 0
668
+ skipped_count = 0
669
+ failed_count = 0
670
+
671
+ for idx, turn in enumerate(all_turns, 1):
672
+ # Check if already processed
673
+ if is_turn_processed(realign_dir, turn.session_id, turn.turn_number):
674
+ console.print(f"[{idx}/{total_turns}] Turn {turn.turn_number} of session {turn.session_id[:8]}...")
675
+ console.print(f" [yellow]Skipped:[/yellow] Already processed")
676
+ console.print()
677
+ skipped_count += 1
678
+ continue
679
+
680
+ # Display progress
681
+ console.print(f"[{idx}/{total_turns}] Turn {turn.turn_number} of session {turn.session_id[:8]}...")
682
+
683
+ try:
684
+ # Create a partial session file containing only turns up to this point
685
+ # This ensures each commit has meaningful session file changes
686
+ partial_session_file = create_partial_session_file(
687
+ original_session_file=turn.session_file,
688
+ realign_dir=realign_dir,
689
+ session_id=turn.session_id,
690
+ up_to_turn=turn.turn_number
691
+ )
692
+
693
+ # Generate commit message (with or without LLM)
694
+ # Pass the partial session file so LLM can analyze the full session context
695
+ use_llm = not no_llm
696
+ title, description, model_name = generate_commit_message_for_turn(
697
+ partial_session_file=partial_session_file,
698
+ user_message=turn.user_message,
699
+ turn_number=turn.turn_number,
700
+ use_llm=use_llm
701
+ )
702
+
703
+ # Prepare session ID for commit
704
+ formatted_session_id = f"{username}_claude_{turn.session_id[:8]}"
705
+
706
+ # Commit the turn with the partial session file
707
+ commit_hash = git_tracker.commit_turn(
708
+ session_id=formatted_session_id,
709
+ turn_number=turn.turn_number,
710
+ user_message=turn.user_message,
711
+ llm_title=title,
712
+ llm_description=description,
713
+ model_name=model_name,
714
+ modified_files=[], # No modified files for historical imports
715
+ session_file=partial_session_file
716
+ )
717
+
718
+ if commit_hash:
719
+ console.print(f" [green]Committed:[/green] {commit_hash[:7]}")
720
+ committed_count += 1
721
+
722
+ # Save metadata
723
+ save_turn_metadata(realign_dir, turn.session_id, turn.turn_number, commit_hash)
724
+ else:
725
+ console.print(f" [red]Failed:[/red] No commit hash returned")
726
+ failed_count += 1
727
+
728
+ except Exception as e:
729
+ logger.error(f"Failed to commit turn {turn.turn_number}: {e}", exc_info=True)
730
+ console.print(f" [red]Failed:[/red] {str(e)}")
731
+ failed_count += 1
732
+
733
+ console.print()
734
+
735
+ # Step 6: Display summary
736
+ console.print(Panel.fit(
737
+ f"[bold cyan]Summary[/bold cyan]\n\n"
738
+ f"Total processed: [yellow]{total_turns}[/yellow] turn{'s' if total_turns != 1 else ''}\n"
739
+ f"Committed: [green]{committed_count}[/green] turn{'s' if committed_count != 1 else ''}\n"
740
+ f"Skipped: [yellow]{skipped_count}[/yellow] turn{'s' if skipped_count != 1 else ''} (already processed)\n"
741
+ f"Failed: [red]{failed_count}[/red] turn{'s' if failed_count != 1 else ''}\n\n"
742
+ f"[dim]Next steps:[/dim]\n"
743
+ f"- Run [cyan]'aline review'[/cyan] to see commits\n" +
744
+ (f"- Run [cyan]'aline import-history --commit'[/cyan] again to continue" if limit else ""),
745
+ border_style="cyan"
746
+ ))
747
+ console.print()
748
+
749
+ return 0 if failed_count == 0 else 1
750
+
751
+
752
+ # ============================================================================
753
+ # Main Command Function
754
+ # ============================================================================
755
+
756
+ def import_history_command(
757
+ verbose: bool = False,
758
+ limit: Optional[int] = None,
759
+ commit: bool = False,
760
+ no_llm: bool = False,
761
+ ) -> int:
762
+ """
763
+ Discover and display historical sessions for import.
764
+
765
+ This command finds all unprocessed Claude Code sessions for the current project,
766
+ parses them turn-by-turn, and displays them in chronological order.
767
+
768
+ Args:
769
+ verbose: Show full user messages instead of previews
770
+ limit: Maximum number of sessions to display (or turns to commit if commit=True)
771
+ commit: Actually commit historical turns to git
772
+ no_llm: Skip LLM summary generation (default behavior for now)
773
+
774
+ Returns:
775
+ int: Exit code (0 = success, 1 = error)
776
+ """
777
+ try:
778
+ # Step 1: Validate initialization
779
+ project_path = Path.cwd()
780
+ realign_dir = get_realign_dir(project_path)
781
+
782
+ if not realign_dir.exists():
783
+ console.print("[red]Error:[/red] ReAlign is not initialized in this directory.")
784
+ console.print("[dim]Run 'aline init' to set up session tracking.[/dim]")
785
+ return 1
786
+
787
+ logger.info(f"Starting import-history command for project: {project_path}")
788
+ logger.debug(f"ReAlign directory: {realign_dir}")
789
+
790
+ # Step 2: Discover sessions
791
+ all_sessions = discover_historical_sessions(project_path)
792
+
793
+ if not all_sessions:
794
+ console.print("\n[yellow]No Claude Code sessions found for this project.[/yellow]")
795
+ console.print("[dim]Make sure you have used Claude Code in this project directory.[/dim]\n")
796
+ return 0
797
+
798
+ # Step 3: Filter already processed sessions
799
+ unprocessed_sessions = filter_already_processed(all_sessions, realign_dir)
800
+
801
+ if not unprocessed_sessions:
802
+ console.print("\n[green]✓[/green] All Claude Code sessions have already been imported!")
803
+ console.print(f"[dim]Found {len(all_sessions)} total session(s), all processed.[/dim]\n")
804
+ return 0
805
+
806
+ # Step 4: Parse sessions and extract turns
807
+ historical_sessions = []
808
+
809
+ for session_file in unprocessed_sessions:
810
+ # Extract session ID from filename
811
+ session_name = session_file.stem
812
+ if session_name.startswith("session-"):
813
+ session_id = session_name.replace("session-", "")
814
+ else:
815
+ session_id = session_name
816
+
817
+ # Get file timestamps
818
+ try:
819
+ stat = session_file.stat()
820
+ created_at = datetime.fromtimestamp(stat.st_ctime)
821
+ modified_at = datetime.fromtimestamp(stat.st_mtime)
822
+ except Exception as e:
823
+ logger.warning(f"Failed to get timestamps for {session_file}: {e}")
824
+ # Use current time as fallback
825
+ created_at = datetime.now()
826
+ modified_at = datetime.now()
827
+
828
+ # Extract turns
829
+ turns = extract_turns_from_session(session_file)
830
+
831
+ # Skip sessions with no turns
832
+ if not turns:
833
+ logger.debug(f"Skipping session {session_id} with no turns")
834
+ continue
835
+
836
+ # Create HistoricalSession object
837
+ historical_sessions.append(HistoricalSession(
838
+ session_id=session_id,
839
+ session_file=session_file,
840
+ created_at=created_at,
841
+ modified_at=modified_at,
842
+ turns=turns,
843
+ total_turns=len(turns)
844
+ ))
845
+
846
+ # Step 5: Sort sessions chronologically (oldest first)
847
+ historical_sessions.sort(key=lambda s: s.created_at)
848
+
849
+ # Step 6: Display or commit based on mode
850
+ if commit:
851
+ # Commit mode: actually create git commits
852
+ total_commits = commit_historical_turns(
853
+ sessions=historical_sessions,
854
+ realign_dir=realign_dir,
855
+ limit=limit,
856
+ no_llm=no_llm
857
+ )
858
+ logger.info(f"Successfully committed {total_commits} historical turns")
859
+ return 0
860
+ else:
861
+ # Display mode: show preview only
862
+ display_historical_sessions(historical_sessions, verbose=verbose, limit=limit)
863
+ logger.info(f"Successfully processed {len(historical_sessions)} historical sessions")
864
+ return 0
865
+
866
+ except KeyboardInterrupt:
867
+ console.print("\n[yellow]Import cancelled by user[/yellow]")
868
+ return 0
869
+ except Exception as e:
870
+ logger.error(f"Error in import-history command: {e}", exc_info=True)
871
+ console.print(f"\n[red]Error:[/red] {e}")
872
+ console.print("[dim]Check logs for more details: ~/.aline/.logs/import_history.log[/dim]\n")
873
+ return 1