aline-ai 0.2.6__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
- aline_ai-0.3.0.dist-info/RECORD +41 -0
- aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
- realign/__init__.py +32 -1
- realign/cli.py +203 -19
- realign/commands/__init__.py +2 -2
- realign/commands/clean.py +149 -0
- realign/commands/config.py +1 -1
- realign/commands/export_shares.py +1785 -0
- realign/commands/hide.py +112 -24
- realign/commands/import_history.py +873 -0
- realign/commands/init.py +104 -217
- realign/commands/mirror.py +131 -0
- realign/commands/pull.py +101 -0
- realign/commands/push.py +155 -245
- realign/commands/review.py +216 -54
- realign/commands/session_utils.py +139 -4
- realign/commands/share.py +965 -0
- realign/commands/status.py +559 -0
- realign/commands/sync.py +91 -0
- realign/commands/undo.py +423 -0
- realign/commands/watcher.py +805 -0
- realign/config.py +21 -10
- realign/file_lock.py +3 -1
- realign/hash_registry.py +310 -0
- realign/hooks.py +115 -411
- realign/logging_config.py +2 -2
- realign/mcp_server.py +263 -549
- realign/mcp_watcher.py +997 -139
- realign/mirror_utils.py +322 -0
- realign/prompts/__init__.py +21 -0
- realign/prompts/presets.py +238 -0
- realign/redactor.py +168 -16
- realign/tracker/__init__.py +9 -0
- realign/tracker/git_tracker.py +1123 -0
- realign/watcher_daemon.py +115 -0
- aline_ai-0.2.6.dist-info/RECORD +0 -28
- aline_ai-0.2.6.dist-info/entry_points.txt +0 -5
- realign/commands/auto_commit.py +0 -242
- realign/commands/commit.py +0 -379
- realign/commands/search.py +0 -449
- realign/commands/show.py +0 -416
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|