aline-ai 0.2.5__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
  2. aline_ai-0.3.0.dist-info/RECORD +41 -0
  3. aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
  4. realign/__init__.py +32 -1
  5. realign/cli.py +203 -19
  6. realign/commands/__init__.py +2 -2
  7. realign/commands/clean.py +149 -0
  8. realign/commands/config.py +1 -1
  9. realign/commands/export_shares.py +1785 -0
  10. realign/commands/hide.py +112 -24
  11. realign/commands/import_history.py +873 -0
  12. realign/commands/init.py +104 -217
  13. realign/commands/mirror.py +131 -0
  14. realign/commands/pull.py +101 -0
  15. realign/commands/push.py +155 -245
  16. realign/commands/review.py +216 -54
  17. realign/commands/session_utils.py +139 -4
  18. realign/commands/share.py +965 -0
  19. realign/commands/status.py +559 -0
  20. realign/commands/sync.py +91 -0
  21. realign/commands/undo.py +423 -0
  22. realign/commands/watcher.py +805 -0
  23. realign/config.py +21 -10
  24. realign/file_lock.py +3 -1
  25. realign/hash_registry.py +310 -0
  26. realign/hooks.py +368 -384
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +999 -142
  30. realign/mirror_utils.py +322 -0
  31. realign/prompts/__init__.py +21 -0
  32. realign/prompts/presets.py +238 -0
  33. realign/redactor.py +168 -16
  34. realign/tracker/__init__.py +9 -0
  35. realign/tracker/git_tracker.py +1123 -0
  36. realign/watcher_daemon.py +115 -0
  37. aline_ai-0.2.5.dist-info/RECORD +0 -28
  38. aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -231
  40. realign/commands/commit.py +0 -379
  41. realign/commands/search.py +0 -449
  42. realign/commands/show.py +0 -416
  43. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
@@ -15,6 +15,22 @@ from typing import List, Dict, Tuple, Optional
15
15
 
16
16
  from ..logging_config import setup_logger
17
17
 
18
+ # ANSI color codes for terminal output
19
+ class Colors:
20
+ RESET = '\033[0m'
21
+ BOLD = '\033[1m'
22
+ DIM = '\033[2m'
23
+
24
+ # Foreground colors
25
+ RED = '\033[91m'
26
+ GREEN = '\033[92m'
27
+ YELLOW = '\033[93m'
28
+ BLUE = '\033[94m'
29
+ MAGENTA = '\033[95m'
30
+ CYAN = '\033[96m'
31
+ WHITE = '\033[97m'
32
+ GRAY = '\033[90m'
33
+
18
34
  logger = setup_logger('realign.commands.review', 'review.log')
19
35
 
20
36
 
@@ -27,6 +43,7 @@ class UnpushedCommit:
27
43
  message: str # First line of commit message
28
44
  timestamp: datetime # Commit timestamp
29
45
  llm_summary: str # Extracted LLM summary
46
+ user_request: Optional[str] # User's request text
30
47
  session_files: List[str] # Session files modified
31
48
  session_additions: Dict[str, List[Tuple[int, int]]] # {file: [(start, end), ...]}
32
49
  has_sensitive: bool = False # Whether sensitive content detected
@@ -36,10 +53,14 @@ def get_unpushed_commits(repo_root: Path) -> List[UnpushedCommit]:
36
53
  """
37
54
  Get all unpushed commits from the current branch.
38
55
 
39
- Strategy (方案 C):
40
- 1. Try to use upstream branch (@{u})
41
- 2. Fallback to origin/main or origin/master
42
- 3. If no remote exists, show all commits on current branch
56
+ Strategy (Updated):
57
+ 1. Get current branch name
58
+ 2. If on main/master branch:
59
+ - Use upstream (@{u}) if exists, otherwise origin/main
60
+ 3. If on feature branch:
61
+ - Always compare against origin/main or origin/master
62
+ - This shows all commits that will be in the PR
63
+ 4. If no remote exists, show all commits on current branch
43
64
 
44
65
  Args:
45
66
  repo_root: Path to repository root
@@ -49,35 +70,58 @@ def get_unpushed_commits(repo_root: Path) -> List[UnpushedCommit]:
49
70
  """
50
71
  logger.info("Getting unpushed commits")
51
72
 
52
- # Try to get upstream branch
53
- upstream_result = subprocess.run(
54
- ["git", "rev-parse", "--abbrev-ref", "@{u}"],
73
+ # Get current branch name
74
+ current_branch_result = subprocess.run(
75
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
55
76
  cwd=repo_root,
56
77
  capture_output=True,
57
78
  text=True
58
79
  )
59
80
 
60
- if upstream_result.returncode == 0:
61
- base = "@{u}"
62
- logger.debug(f"Using upstream branch: {upstream_result.stdout.strip()}")
63
- else:
64
- # Fallback to origin/main or origin/master
65
- main_branch = detect_main_branch(repo_root)
66
- base = f"origin/{main_branch}"
67
- logger.debug(f"No upstream found, using fallback: {base}")
81
+ if current_branch_result.returncode != 0:
82
+ logger.error("Failed to get current branch name")
83
+ return []
84
+
85
+ current_branch = current_branch_result.stdout.strip()
86
+ logger.debug(f"Current branch: {current_branch}")
68
87
 
69
- # Verify that the remote branch exists
70
- verify_result = subprocess.run(
71
- ["git", "rev-parse", "--verify", base],
88
+ # Detect main branch name
89
+ main_branch = detect_main_branch(repo_root)
90
+
91
+ # Determine base branch based on current branch
92
+ if current_branch in ['main', 'master']:
93
+ # On main branch: show commits not pushed to upstream
94
+ upstream_result = subprocess.run(
95
+ ["git", "rev-parse", "--abbrev-ref", "@{u}"],
72
96
  cwd=repo_root,
73
97
  capture_output=True,
74
98
  text=True
75
99
  )
76
100
 
77
- if verify_result.returncode != 0:
78
- # Remote branch doesn't exist, show all commits on current branch
79
- base = None
80
- logger.info("No remote branch found, will show all commits on current branch")
101
+ if upstream_result.returncode == 0:
102
+ base = "@{u}"
103
+ logger.debug(f"On main branch, using upstream: {upstream_result.stdout.strip()}")
104
+ else:
105
+ base = f"origin/{main_branch}"
106
+ logger.debug(f"On main branch but no upstream, using: {base}")
107
+ else:
108
+ # On feature branch: show all commits relative to main
109
+ # This shows what would be in a PR
110
+ base = f"origin/{main_branch}"
111
+ logger.debug(f"On feature branch '{current_branch}', comparing against: {base}")
112
+
113
+ # Verify that the base branch exists
114
+ verify_result = subprocess.run(
115
+ ["git", "rev-parse", "--verify", base],
116
+ cwd=repo_root,
117
+ capture_output=True,
118
+ text=True
119
+ )
120
+
121
+ if verify_result.returncode != 0:
122
+ # Remote branch doesn't exist, show all commits on current branch
123
+ base = None
124
+ logger.info(f"Base branch '{base}' not found, will show all commits on current branch")
81
125
 
82
126
  # Get commit list
83
127
  # Format: full_hash|short_hash|subject|timestamp
@@ -125,8 +169,9 @@ def get_unpushed_commits(repo_root: Path) -> List[UnpushedCommit]:
125
169
  text=True
126
170
  ).stdout
127
171
 
128
- # Extract LLM summary
172
+ # Extract LLM summary and user request
129
173
  llm_summary = extract_llm_summary(full_message)
174
+ user_request = extract_user_request(full_message)
130
175
 
131
176
  # Get session file additions
132
177
  session_files, session_additions = get_session_additions(full_hash, repo_root)
@@ -138,6 +183,7 @@ def get_unpushed_commits(repo_root: Path) -> List[UnpushedCommit]:
138
183
  message=subject,
139
184
  timestamp=timestamp,
140
185
  llm_summary=llm_summary,
186
+ user_request=user_request,
141
187
  session_files=session_files,
142
188
  session_additions=session_additions,
143
189
  has_sensitive=False # Will be set by --detect-secrets flag
@@ -199,27 +245,85 @@ def detect_main_branch(repo_root: Path) -> str:
199
245
  return "main"
200
246
 
201
247
 
248
+ def extract_user_request(commit_message: str) -> Optional[str]:
249
+ """
250
+ Extract user request from commit message.
251
+
252
+ New format (v0.3+):
253
+ ---
254
+ Session: ... | Turn: ... | Model: ...
255
+ Request: {user_message}
256
+
257
+ Args:
258
+ commit_message: Full commit message
259
+
260
+ Returns:
261
+ User request text, or None if not found
262
+ """
263
+ lines = commit_message.split('\n')
264
+
265
+ # Look for "Request:" line
266
+ for i, line in enumerate(lines):
267
+ if line.strip().startswith('Request:'):
268
+ # Extract text after "Request:"
269
+ request = line.strip()[8:].strip() # Remove "Request:" prefix
270
+ # Filter out placeholder messages
271
+ if request and request != "No user message found":
272
+ return request
273
+
274
+ return None
275
+
276
+
202
277
  def extract_llm_summary(commit_message: str) -> str:
203
278
  """
204
279
  Extract LLM summary from commit message.
205
280
 
206
- Expected format:
281
+ Supports two formats:
282
+
283
+ New format (v0.3+):
284
+ {llm_title}
285
+
286
+ {llm_description}
287
+
288
+ ---
289
+ Turn: #{turn_number} | Model: {model_name}
290
+ Request: {user_message}
291
+
292
+ Legacy format:
207
293
  chore: Auto-commit MCP session (2025-11-22 19:24:29)
208
294
 
209
295
  --- LLM-Summary (claude-3-5-haiku) ---
210
296
  * [Claude] Discussed implementing JWT authentication
211
- * [Codex] Fixed bug in payment module
212
-
213
- Agent-Redacted: false
214
297
 
215
298
  Args:
216
299
  commit_message: Full commit message
217
300
 
218
301
  Returns:
219
- Extracted summary text (without * and [Agent] prefix), or "(No summary)"
302
+ Extracted summary text, or "(No summary)"
220
303
  """
221
304
  lines = commit_message.split('\n')
222
305
 
306
+ # Try new format first: title is first line, description is in the middle
307
+ if lines and not lines[0].startswith('chore:') and '---' in commit_message:
308
+ # New format: {title}\n\n{description}\n\n---
309
+ title = lines[0].strip()
310
+
311
+ # Find description (between title and ---)
312
+ description_lines = []
313
+ for i, line in enumerate(lines[1:], start=1):
314
+ if line.strip() == '---':
315
+ break
316
+ if line.strip(): # Skip empty lines
317
+ description_lines.append(line.strip())
318
+
319
+ if title:
320
+ summary = title
321
+ if description_lines:
322
+ # Use first line of description if available
323
+ summary += f": {description_lines[0]}"
324
+ return summary
325
+
326
+ # Try legacy format
223
327
  in_summary = False
224
328
  summary_lines = []
225
329
 
@@ -282,9 +386,11 @@ def get_session_additions(commit_hash: str, repo_root: Path) -> Tuple[List[str],
282
386
  all_files = files_result.stdout.strip().split('\n')
283
387
 
284
388
  # Filter session files
389
+ # Note: In shadow git, session files are in 'sessions/' directory
390
+ # In user project, they would be in '.realign/sessions/' directory
285
391
  session_files = [
286
392
  f for f in all_files
287
- if f.startswith('.realign/sessions/') and f.endswith('.jsonl')
393
+ if (f.startswith('sessions/') or f.startswith('.realign/sessions/')) and f.endswith('.jsonl')
288
394
  ]
289
395
 
290
396
  if not session_files:
@@ -388,32 +494,61 @@ def display_unpushed_commits(commits: List[UnpushedCommit], verbose: bool = Fals
388
494
  verbose: Whether to show detailed information
389
495
  """
390
496
  if not commits:
391
- print("\n✓ No unpushed commits found.\n")
497
+ print(f"\n{Colors.GREEN}✓ No unpushed commits found.{Colors.RESET}\n")
392
498
  return
393
499
 
394
- print(f"\n📋 Unpushed commits ({len(commits)}):\n")
500
+ # Header
501
+ print(f"\n{Colors.BOLD}{Colors.CYAN}📋 Unpushed commits ({len(commits)}){Colors.RESET}\n")
395
502
 
396
503
  for commit in commits:
397
- # Basic info
398
- print(f" [{commit.index}] {commit.hash} - {commit.message}")
399
- print(f" Time: {commit.timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
400
-
401
- # LLM Summary
402
- if commit.llm_summary and commit.llm_summary != "(No summary)":
403
- print(f" Summary: {commit.llm_summary}")
404
-
405
- # Session files
406
- if commit.session_files:
407
- for session_file in commit.session_files:
408
- additions = commit.session_additions.get(session_file, [])
409
- total_lines = sum(end - start + 1 for start, end in additions)
410
- print(f" Session: {session_file} (+{total_lines} lines)")
411
-
412
- # Sensitive content warning
413
- if commit.has_sensitive:
414
- print(f" ⚠️ WARNING: Potential sensitive content detected")
415
-
416
- print() # Blank line separator
504
+ # Index and hash in gray
505
+ index_str = f"{Colors.GRAY}[{commit.index}]{Colors.RESET}"
506
+ hash_str = f"{Colors.YELLOW}{commit.hash}{Colors.RESET}"
507
+
508
+ # Commit message (first line only) in bold white
509
+ message_str = f"{Colors.BOLD}{commit.message}{Colors.RESET}"
510
+
511
+ print(f"{index_str} {hash_str} {message_str}")
512
+
513
+ # User request (first 50 characters) in cyan
514
+ if commit.user_request:
515
+ # Truncate to 50 characters
516
+ request_display = commit.user_request[:50]
517
+ if len(commit.user_request) > 50:
518
+ request_display += "..."
519
+ print(f" {Colors.CYAN}└─ {request_display}{Colors.RESET}")
520
+
521
+ # Verbose mode: show additional details
522
+ if verbose:
523
+ # Timestamp in dim gray
524
+ time_str = commit.timestamp.strftime('%Y-%m-%d %H:%M:%S')
525
+ print(f" {Colors.DIM}Time: {time_str}{Colors.RESET}")
526
+
527
+ # LLM Summary
528
+ if commit.llm_summary and commit.llm_summary != "(No summary)":
529
+ print(f" {Colors.DIM}Summary: {commit.llm_summary}{Colors.RESET}")
530
+
531
+ # Session files (only show files with additions)
532
+ if commit.session_files:
533
+ files_with_additions = [
534
+ (f, commit.session_additions.get(f, []))
535
+ for f in commit.session_files
536
+ ]
537
+ # Filter to only files with actual additions
538
+ files_with_additions = [
539
+ (f, ranges) for f, ranges in files_with_additions
540
+ if ranges # Only show files with non-empty additions
541
+ ]
542
+
543
+ for session_file, additions in files_with_additions:
544
+ total_lines = sum(end - start + 1 for start, end in additions)
545
+ print(f" {Colors.DIM}Session: {session_file} (+{total_lines} lines){Colors.RESET}")
546
+
547
+ # Sensitive content warning
548
+ if commit.has_sensitive:
549
+ print(f" {Colors.RED}⚠️ WARNING: Potential sensitive content detected{Colors.RESET}")
550
+
551
+ print() # Blank line separator in verbose mode
417
552
 
418
553
 
419
554
  def review_command(
@@ -424,8 +559,11 @@ def review_command(
424
559
  """
425
560
  Main entry point for review command.
426
561
 
562
+ Reviews commits in the shadow git repository (~/.aline/{project}/.git)
563
+ rather than the user's project repository.
564
+
427
565
  Args:
428
- repo_root: Path to repository root (auto-detected if None)
566
+ repo_root: Path to user's project root (auto-detected if None)
429
567
  verbose: Show detailed information
430
568
  detect_secrets: Run sensitive content detection
431
569
 
@@ -434,7 +572,7 @@ def review_command(
434
572
  """
435
573
  logger.info("======== Review command started ========")
436
574
 
437
- # Auto-detect repo root if not provided
575
+ # Auto-detect user project root if not provided
438
576
  if repo_root is None:
439
577
  try:
440
578
  result = subprocess.run(
@@ -444,12 +582,36 @@ def review_command(
444
582
  check=True
445
583
  )
446
584
  repo_root = Path(result.stdout.strip())
447
- logger.debug(f"Detected repo root: {repo_root}")
585
+ logger.debug(f"Detected user project root: {repo_root}")
448
586
  except subprocess.CalledProcessError:
449
587
  print("Error: Not in a git repository", file=sys.stderr)
450
588
  logger.error("Not in a git repository")
451
589
  return 1
452
590
 
591
+ # Get shadow git repository path
592
+ from .. import get_realign_dir
593
+ shadow_dir = get_realign_dir(repo_root)
594
+ shadow_git = shadow_dir
595
+
596
+ # Verify shadow git exists
597
+ if not shadow_git.exists():
598
+ print(f"Error: Shadow git repository not found at {shadow_git}", file=sys.stderr)
599
+ print("Run 'aline init' first to initialize the repository.", file=sys.stderr)
600
+ logger.error(f"Shadow git not found at {shadow_git}")
601
+ return 1
602
+
603
+ # Check if it's a git repository
604
+ git_dir = shadow_git / '.git'
605
+ if not git_dir.exists():
606
+ print(f"Error: {shadow_git} is not a git repository", file=sys.stderr)
607
+ print("Run 'aline init' first to initialize the repository.", file=sys.stderr)
608
+ logger.error(f"No .git found in {shadow_git}")
609
+ return 1
610
+
611
+ logger.info(f"Using shadow git repository: {shadow_git}")
612
+ # Override repo_root to point to shadow git
613
+ repo_root = shadow_git
614
+
453
615
  # Get unpushed commits
454
616
  try:
455
617
  commits = get_unpushed_commits(repo_root)
@@ -7,15 +7,26 @@ from pathlib import Path
7
7
  from typing import List
8
8
 
9
9
 
10
- def find_session_paths_for_commit(repo_root: Path, commit_hash: str) -> List[str]:
11
- """Return relative session file paths tracked in a given commit."""
10
+ def find_session_paths_for_commit(repo_root: Path, commit_hash: str, git_dir: Path = None) -> List[str]:
11
+ """
12
+ Return relative session file paths tracked in a given commit.
13
+
14
+ Args:
15
+ repo_root: Root directory (used if git_dir not provided)
16
+ commit_hash: Commit hash to query
17
+ git_dir: Optional git directory to use (for .aline repos)
18
+
19
+ Returns:
20
+ List of relative session file paths
21
+ """
12
22
  try:
23
+ cwd = git_dir if git_dir else repo_root
13
24
  result = subprocess.run(
14
25
  ["git", "show", "--pretty=format:", "--name-only", commit_hash],
15
26
  capture_output=True,
16
27
  text=True,
17
28
  check=True,
18
- cwd=repo_root,
29
+ cwd=cwd,
19
30
  )
20
31
  except subprocess.CalledProcessError:
21
32
  return []
@@ -23,6 +34,130 @@ def find_session_paths_for_commit(repo_root: Path, commit_hash: str) -> List[str
23
34
  paths = []
24
35
  for line in result.stdout.splitlines():
25
36
  candidate = line.strip()
26
- if candidate.startswith(".realign/sessions/") and candidate.endswith(".jsonl"):
37
+ # Support both .realign/sessions/ and sessions/ patterns
38
+ if (candidate.startswith(".realign/sessions/") or candidate.startswith("sessions/")) and candidate.endswith(".jsonl"):
27
39
  paths.append(candidate)
28
40
  return paths
41
+
42
+
43
+ def detect_original_session_location(session_filename: str, project_root: Path, commit_timestamp: int) -> Path:
44
+ """
45
+ Detect the original location where a session file should be restored.
46
+
47
+ Args:
48
+ session_filename: Name of session file (e.g., "huminhao_claude_637f35af.jsonl" or "uuid.jsonl")
49
+ project_root: Root directory of the user's project
50
+ commit_timestamp: Unix timestamp of the commit
51
+
52
+ Returns:
53
+ Path where session should be restored, or None if cannot detect
54
+ """
55
+ from datetime import datetime
56
+ import re
57
+
58
+ # Check if this is a UUID format (Claude Code original format)
59
+ # UUID pattern: 8-4-4-4-12 hex digits
60
+ uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$'
61
+ if re.match(uuid_pattern, session_filename, re.IGNORECASE):
62
+ # This is a UUID format session file, likely from Claude Code
63
+ try:
64
+ from ..claude_detector import find_claude_sessions_dir
65
+ claude_dir = find_claude_sessions_dir(project_root)
66
+ if claude_dir and claude_dir.exists():
67
+ return claude_dir / session_filename
68
+ except Exception:
69
+ pass
70
+ # Fall through to return None if Claude dir not found
71
+
72
+ else:
73
+ # Parse filename to extract agent type
74
+ # Expected format: {user}_{agent}_{hash}.jsonl
75
+ parts = session_filename.replace('.jsonl', '').split('_')
76
+
77
+ if len(parts) >= 3:
78
+ agent_type = parts[1].lower() # e.g., "claude", "codex"
79
+
80
+ if agent_type == "claude":
81
+ # Try to find Claude sessions directory
82
+ try:
83
+ from ..claude_detector import find_claude_sessions_dir
84
+ claude_dir = find_claude_sessions_dir(project_root)
85
+ if claude_dir and claude_dir.exists():
86
+ return claude_dir / session_filename
87
+ except Exception:
88
+ pass
89
+
90
+ elif agent_type == "codex":
91
+ # Construct Codex path from timestamp
92
+ try:
93
+ dt = datetime.fromtimestamp(int(commit_timestamp))
94
+ codex_dir = Path.home() / ".codex" / "sessions" / dt.strftime("%Y") / dt.strftime("%m") / dt.strftime("%d")
95
+ if codex_dir.exists():
96
+ # Codex uses rollout-* naming, but we'll use our renamed format
97
+ return codex_dir / session_filename
98
+ except Exception:
99
+ pass
100
+
101
+ # Fall back to None if cannot detect
102
+ return None
103
+
104
+
105
+ def restore_session_from_commit(tracker, commit_hash: str, session_rel_path: str, dest_path: Path) -> bool:
106
+ """
107
+ Restore a session file from a commit to its original location.
108
+
109
+ Args:
110
+ tracker: ReAlignGitTracker instance
111
+ commit_hash: Commit hash to restore from
112
+ session_rel_path: Relative path in git (e.g., "sessions/user_claude_hash.jsonl")
113
+ dest_path: Destination path to restore to
114
+
115
+ Returns:
116
+ True if successful, False otherwise
117
+ """
118
+ import json
119
+ from datetime import datetime
120
+
121
+ try:
122
+ # Extract session content from commit
123
+ result = subprocess.run(
124
+ ["git", "show", f"{commit_hash}:{session_rel_path}"],
125
+ cwd=tracker.realign_dir,
126
+ capture_output=True,
127
+ check=False
128
+ )
129
+
130
+ if result.returncode != 0:
131
+ return False
132
+
133
+ content = result.stdout.decode('utf-8', errors='replace')
134
+
135
+ # Validate JSONL format (basic check)
136
+ try:
137
+ for line in content.splitlines():
138
+ line = line.strip()
139
+ if line: # Skip empty lines
140
+ json.loads(line) # Will raise if invalid JSON
141
+ except json.JSONDecodeError:
142
+ # Invalid JSONL, create .corrupted file
143
+ corrupted_path = dest_path.with_suffix('.jsonl.corrupted')
144
+ corrupted_path.write_text(content, encoding='utf-8')
145
+ return False
146
+
147
+ # If destination exists, create backup
148
+ if dest_path.exists():
149
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
150
+ backup_name = dest_path.stem + f".backup-{timestamp}" + dest_path.suffix
151
+ backup_path = dest_path.parent / backup_name
152
+ dest_path.rename(backup_path)
153
+
154
+ # Create parent directory if needed
155
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
156
+
157
+ # Write session content
158
+ dest_path.write_text(content, encoding='utf-8')
159
+
160
+ return True
161
+
162
+ except Exception:
163
+ return False