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
@@ -0,0 +1,322 @@
1
+ """Shared utilities for mirroring project files to shadow git repository."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import List, Set, Optional
6
+ import fnmatch
7
+
8
+
9
+ # File size limit for mirroring (50MB)
10
+ # Files larger than this will be skipped to prevent performance issues
11
+ MAX_MIRROR_FILE_SIZE = 50 * 1024 * 1024 # 50MB in bytes
12
+
13
+
14
+ def get_gitignore_patterns(project_path: Path) -> Set[str]:
15
+ """
16
+ Load .gitignore patterns from project directory.
17
+
18
+ Args:
19
+ project_path: Path to the project directory
20
+
21
+ Returns:
22
+ Set of gitignore patterns
23
+ """
24
+ patterns = set()
25
+ gitignore_path = project_path / ".gitignore"
26
+
27
+ if not gitignore_path.exists():
28
+ return patterns
29
+
30
+ try:
31
+ with open(gitignore_path, 'r', encoding='utf-8') as f:
32
+ for line in f:
33
+ line = line.strip()
34
+ # Skip comments and empty lines
35
+ if line and not line.startswith('#'):
36
+ patterns.add(line)
37
+ except Exception:
38
+ pass # Silently ignore errors
39
+
40
+ return patterns
41
+
42
+
43
+ def is_file_too_large(file_path: Path, max_size: int = MAX_MIRROR_FILE_SIZE) -> bool:
44
+ """
45
+ Check if a file exceeds the maximum size limit for mirroring.
46
+
47
+ Args:
48
+ file_path: Path to the file to check
49
+ max_size: Maximum file size in bytes (default: 50MB)
50
+
51
+ Returns:
52
+ True if file is too large, False otherwise
53
+ """
54
+ try:
55
+ file_size = file_path.stat().st_size
56
+ return file_size > max_size
57
+ except (OSError, IOError):
58
+ # If we can't get file size (permission error, file doesn't exist, etc.),
59
+ # skip the file to be safe
60
+ return True
61
+
62
+
63
+ def should_ignore_file(file_path: Path, project_path: Path, gitignore_patterns: Set[str]) -> bool:
64
+ """
65
+ Check if a file should be ignored based on .gitignore patterns.
66
+
67
+ Args:
68
+ file_path: Absolute path to the file
69
+ project_path: Root path of the project
70
+ gitignore_patterns: Set of gitignore patterns
71
+
72
+ Returns:
73
+ True if file should be ignored, False otherwise
74
+ """
75
+ try:
76
+ rel_path = file_path.relative_to(project_path)
77
+ rel_path_str = str(rel_path)
78
+
79
+ # Check each pattern
80
+ for pattern in gitignore_patterns:
81
+ # Simple pattern matching (basic implementation)
82
+ if pattern.endswith('/'):
83
+ # Directory pattern
84
+ if rel_path_str.startswith(pattern.rstrip('/')):
85
+ return True
86
+ elif '*' in pattern:
87
+ # Wildcard pattern - simple implementation
88
+ if fnmatch.fnmatch(rel_path_str, pattern):
89
+ return True
90
+ else:
91
+ # Exact match or prefix match
92
+ if rel_path_str == pattern or rel_path_str.startswith(pattern + '/'):
93
+ return True
94
+
95
+ return False
96
+
97
+ except Exception:
98
+ return False
99
+
100
+
101
+ def collect_project_files(project_path: Path, logger=None) -> List[Path]:
102
+ """
103
+ Collect all project files that should be mirrored.
104
+
105
+ This is the core logic shared between the mirror command and watcher.
106
+ It walks through the project directory, respects .gitignore patterns,
107
+ and excludes .git, .aline, and .realign directories.
108
+
109
+ Args:
110
+ project_path: Root path of the project
111
+ logger: Optional logger for debug messages
112
+
113
+ Returns:
114
+ List of absolute paths to files that should be mirrored
115
+ """
116
+ all_files = []
117
+ gitignore_patterns = get_gitignore_patterns(project_path)
118
+
119
+ # Walk through project directory
120
+ for root, dirs, files in os.walk(project_path):
121
+ # Skip .git directory
122
+ if '.git' in dirs:
123
+ dirs.remove('.git')
124
+
125
+ # Skip .aline and .realign directories
126
+ if '.aline' in dirs:
127
+ dirs.remove('.aline')
128
+ if '.realign' in dirs:
129
+ dirs.remove('.realign')
130
+
131
+ for file in files:
132
+ file_path = Path(root) / file
133
+
134
+ # Check if file should be ignored
135
+ if should_ignore_file(file_path, project_path, gitignore_patterns):
136
+ continue
137
+
138
+ # Check file size limit (50MB)
139
+ if is_file_too_large(file_path):
140
+ if logger:
141
+ try:
142
+ file_size_mb = file_path.stat().st_size / (1024 * 1024)
143
+ logger.warning(
144
+ f"Skipping large file: {file_path.relative_to(project_path)} "
145
+ f"({file_size_mb:.1f}MB > 50MB limit)"
146
+ )
147
+ except (OSError, IOError):
148
+ logger.warning(
149
+ f"Skipping large file: {file_path.relative_to(project_path)} "
150
+ f"(unable to determine size)"
151
+ )
152
+ continue
153
+
154
+ # Add all non-ignored files
155
+ if file_path.exists():
156
+ all_files.append(file_path)
157
+ if logger:
158
+ logger.debug(f"Found project file: {file_path.relative_to(project_path)}")
159
+
160
+ if logger:
161
+ logger.info(f"Found {len(all_files)} project file(s) to mirror")
162
+
163
+ return all_files
164
+
165
+
166
+ def get_files_at_commit(tracker, commit_hash: str) -> List[Path]:
167
+ """
168
+ Get list of files that existed in the mirror at a specific commit.
169
+
170
+ Args:
171
+ tracker: ReAlignGitTracker instance
172
+ commit_hash: Commit hash to query
173
+
174
+ Returns:
175
+ List of relative paths (from project root) that existed at that commit
176
+ """
177
+ try:
178
+ import subprocess
179
+ result = subprocess.run(
180
+ ["git", "ls-tree", "-r", "--name-only", commit_hash, "mirror/"],
181
+ cwd=tracker.realign_dir,
182
+ capture_output=True,
183
+ text=True,
184
+ check=False
185
+ )
186
+
187
+ if result.returncode != 0:
188
+ return []
189
+
190
+ files = []
191
+ for line in result.stdout.splitlines():
192
+ line = line.strip()
193
+ if line.startswith("mirror/"):
194
+ # Strip "mirror/" prefix to get relative path from project root
195
+ rel_path = line[7:] # len("mirror/") == 7
196
+ files.append(Path(rel_path))
197
+
198
+ return files
199
+
200
+ except Exception:
201
+ return []
202
+
203
+
204
+ def reverse_mirror_file(source_content: bytes, dest_path: Path, project_root: Path) -> bool:
205
+ """
206
+ Write content to a destination file, creating parent directories as needed.
207
+
208
+ Args:
209
+ source_content: File content as bytes
210
+ dest_path: Absolute destination path
211
+ project_root: Project root directory (for logging)
212
+
213
+ Returns:
214
+ True on success, False on failure
215
+ """
216
+ try:
217
+ # Create parent directories if needed
218
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
219
+
220
+ # Write content
221
+ dest_path.write_bytes(source_content)
222
+
223
+ return True
224
+
225
+ except Exception:
226
+ return False
227
+
228
+
229
+ def reverse_mirror_from_commit(
230
+ tracker,
231
+ commit_hash: str,
232
+ project_root: Path,
233
+ gitignore_patterns: Set[str],
234
+ logger=None
235
+ ):
236
+ """
237
+ Restore files from mirror at a specific commit to the user's project directory.
238
+
239
+ Args:
240
+ tracker: ReAlignGitTracker instance
241
+ commit_hash: Commit hash to restore from
242
+ project_root: Root directory of the user's project
243
+ gitignore_patterns: Set of .gitignore patterns
244
+ logger: Optional logger for messages
245
+
246
+ Returns:
247
+ Dictionary with keys: 'restored', 'skipped', 'failed' (lists of file paths)
248
+ """
249
+ import subprocess
250
+
251
+ result = {
252
+ 'restored': [],
253
+ 'skipped': [],
254
+ 'failed': []
255
+ }
256
+
257
+ # Get list of files at target commit
258
+ files_at_commit = get_files_at_commit(tracker, commit_hash)
259
+
260
+ if logger:
261
+ logger.info(f"Found {len(files_at_commit)} file(s) in mirror at commit {commit_hash}")
262
+
263
+ for rel_path in files_at_commit:
264
+ dest_path = project_root / rel_path
265
+
266
+ # Check if should be ignored
267
+ if should_ignore_file(dest_path, project_root, gitignore_patterns):
268
+ if logger:
269
+ logger.debug(f"Skipping ignored file: {rel_path}")
270
+ result['skipped'].append(str(rel_path))
271
+ continue
272
+
273
+ # Extract content from git
274
+ try:
275
+ git_path = f"mirror/{rel_path}"
276
+ extract_result = subprocess.run(
277
+ ["git", "show", f"{commit_hash}:{git_path}"],
278
+ cwd=tracker.realign_dir,
279
+ capture_output=True,
280
+ check=False
281
+ )
282
+
283
+ if extract_result.returncode != 0:
284
+ if logger:
285
+ logger.warning(f"Failed to extract {rel_path} from commit")
286
+ result['failed'].append(str(rel_path))
287
+ continue
288
+
289
+ content = extract_result.stdout
290
+
291
+ # Check size limit
292
+ if len(content) > MAX_MIRROR_FILE_SIZE:
293
+ if logger:
294
+ size_mb = len(content) / (1024 * 1024)
295
+ logger.warning(
296
+ f"Skipping large file: {rel_path} ({size_mb:.1f}MB > 50MB limit)"
297
+ )
298
+ result['skipped'].append(str(rel_path))
299
+ continue
300
+
301
+ # Write to destination
302
+ if reverse_mirror_file(content, dest_path, project_root):
303
+ result['restored'].append(str(rel_path))
304
+ if logger:
305
+ logger.debug(f"Restored: {rel_path}")
306
+ else:
307
+ result['failed'].append(str(rel_path))
308
+ if logger:
309
+ logger.warning(f"Failed to write: {rel_path}")
310
+
311
+ except Exception as e:
312
+ if logger:
313
+ logger.warning(f"Error restoring {rel_path}: {e}")
314
+ result['failed'].append(str(rel_path))
315
+
316
+ if logger:
317
+ logger.info(
318
+ f"Reverse mirror complete: {len(result['restored'])} restored, "
319
+ f"{len(result['skipped'])} skipped, {len(result['failed'])} failed"
320
+ )
321
+
322
+ return result
@@ -0,0 +1,21 @@
1
+ """Prompt preset system for chat agents."""
2
+
3
+ from .presets import (
4
+ PromptPreset,
5
+ get_all_presets,
6
+ get_preset_by_id,
7
+ get_preset_by_index,
8
+ load_custom_presets,
9
+ display_preset_menu,
10
+ prompt_for_custom_instructions,
11
+ )
12
+
13
+ __all__ = [
14
+ 'PromptPreset',
15
+ 'get_all_presets',
16
+ 'get_preset_by_id',
17
+ 'get_preset_by_index',
18
+ 'load_custom_presets',
19
+ 'display_preset_menu',
20
+ 'prompt_for_custom_instructions',
21
+ ]
@@ -0,0 +1,238 @@
1
+ """
2
+ Prompt preset definitions and management.
3
+
4
+ This module defines different prompt presets for chat agents,
5
+ allowing users to customize the agent's behavior for different scenarios.
6
+ """
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import List, Optional, Dict
12
+ import yaml
13
+
14
+ from ..logging_config import setup_logger
15
+
16
+ logger = setup_logger('realign.prompts.presets', 'presets.log')
17
+
18
+
19
+ @dataclass
20
+ class PromptPreset:
21
+ """A prompt preset for chat agents."""
22
+
23
+ id: str
24
+ name: str
25
+ description: str
26
+ allow_custom_instructions: bool = True
27
+ custom_instructions_placeholder: str = ""
28
+ # Note: system_prompt_template is handled in TypeScript backend
29
+ # We only store metadata here for Python side selection UI
30
+
31
+
32
+ # Built-in presets
33
+ BUILTIN_PRESETS = [
34
+ PromptPreset(
35
+ id="default",
36
+ name="Default Assistant",
37
+ description="Help users explore conversation history with a general-purpose assistant",
38
+ allow_custom_instructions=False,
39
+ custom_instructions_placeholder="",
40
+ ),
41
+ PromptPreset(
42
+ id="work-report",
43
+ name="Work Report Agent",
44
+ description="Act as you to report work to colleagues/managers",
45
+ allow_custom_instructions=True,
46
+ custom_instructions_placeholder=(
47
+ "例如:突出我的工作贡献,弱化失误,注意礼貌\n"
48
+ "Example: Highlight my contributions, downplay mistakes, be professional"
49
+ ),
50
+ ),
51
+ PromptPreset(
52
+ id="knowledge-agent",
53
+ name="Knowledge Agent",
54
+ description="Share your deep thinking as founder/architect/author",
55
+ allow_custom_instructions=True,
56
+ custom_instructions_placeholder=(
57
+ "例如:分享技术决策背景,强调思考过程\n"
58
+ "Example: Share technical decision context, emphasize thought process"
59
+ ),
60
+ ),
61
+ PromptPreset(
62
+ id="personality-analyzer",
63
+ name="Personality Analyzer",
64
+ description="Analyze personality based on conversation",
65
+ allow_custom_instructions=False,
66
+ custom_instructions_placeholder="",
67
+ ),
68
+ ]
69
+
70
+
71
+ def get_all_presets(include_custom: bool = True) -> List[PromptPreset]:
72
+ """
73
+ Get all available presets (built-in + custom).
74
+
75
+ Args:
76
+ include_custom: Whether to include user custom presets
77
+
78
+ Returns:
79
+ List of PromptPreset objects
80
+ """
81
+ presets = BUILTIN_PRESETS.copy()
82
+
83
+ if include_custom:
84
+ try:
85
+ custom_presets = load_custom_presets()
86
+ presets.extend(custom_presets)
87
+ logger.info(f"Loaded {len(custom_presets)} custom presets")
88
+ except Exception as e:
89
+ logger.warning(f"Failed to load custom presets: {e}")
90
+
91
+ return presets
92
+
93
+
94
+ def get_preset_by_id(preset_id: str, include_custom: bool = True) -> Optional[PromptPreset]:
95
+ """
96
+ Get a preset by its ID.
97
+
98
+ Args:
99
+ preset_id: The preset ID to search for
100
+ include_custom: Whether to search in custom presets
101
+
102
+ Returns:
103
+ PromptPreset object or None if not found
104
+ """
105
+ all_presets = get_all_presets(include_custom=include_custom)
106
+
107
+ for preset in all_presets:
108
+ if preset.id == preset_id:
109
+ return preset
110
+
111
+ return None
112
+
113
+
114
+ def get_preset_by_index(index: int, include_custom: bool = True) -> Optional[PromptPreset]:
115
+ """
116
+ Get a preset by its index (1-based).
117
+
118
+ Args:
119
+ index: The preset index (1-based, as shown to users)
120
+ include_custom: Whether to include custom presets
121
+
122
+ Returns:
123
+ PromptPreset object or None if index is out of range
124
+ """
125
+ all_presets = get_all_presets(include_custom=include_custom)
126
+
127
+ if index < 1 or index > len(all_presets):
128
+ return None
129
+
130
+ return all_presets[index - 1]
131
+
132
+
133
+ def load_custom_presets() -> List[PromptPreset]:
134
+ """
135
+ Load custom presets from user configuration file.
136
+
137
+ Looks for ~/.aline/prompt_presets.yaml
138
+
139
+ Returns:
140
+ List of custom PromptPreset objects
141
+ """
142
+ config_path = Path.home() / '.aline' / 'prompt_presets.yaml'
143
+
144
+ if not config_path.exists():
145
+ logger.debug(f"No custom presets file found at {config_path}")
146
+ return []
147
+
148
+ try:
149
+ with open(config_path, 'r', encoding='utf-8') as f:
150
+ config = yaml.safe_load(f)
151
+
152
+ if not config or 'custom_presets' not in config:
153
+ logger.warning("Custom presets file exists but has no 'custom_presets' key")
154
+ return []
155
+
156
+ custom_presets = []
157
+ for preset_data in config['custom_presets']:
158
+ try:
159
+ preset = PromptPreset(
160
+ id=preset_data['id'],
161
+ name=preset_data['name'],
162
+ description=preset_data['description'],
163
+ allow_custom_instructions=preset_data.get('allow_custom_instructions', True),
164
+ custom_instructions_placeholder=preset_data.get('custom_instructions_placeholder', ''),
165
+ )
166
+ custom_presets.append(preset)
167
+ logger.debug(f"Loaded custom preset: {preset.id}")
168
+ except KeyError as e:
169
+ logger.warning(f"Invalid custom preset (missing key {e}): {preset_data}")
170
+ continue
171
+
172
+ return custom_presets
173
+
174
+ except yaml.YAMLError as e:
175
+ logger.error(f"Failed to parse custom presets YAML: {e}")
176
+ return []
177
+ except Exception as e:
178
+ logger.error(f"Unexpected error loading custom presets: {e}")
179
+ return []
180
+
181
+
182
+ def display_preset_menu(presets: Optional[List[PromptPreset]] = None) -> str:
183
+ """
184
+ Display preset selection menu and return formatted string.
185
+
186
+ Args:
187
+ presets: List of presets to display (defaults to all presets)
188
+
189
+ Returns:
190
+ Formatted menu string
191
+ """
192
+ if presets is None:
193
+ presets = get_all_presets()
194
+
195
+ lines = ["\n📋 Select a prompt preset for the chat agent:\n"]
196
+
197
+ for idx, preset in enumerate(presets, 1):
198
+ lines.append(f"[{idx}] {preset.name} ({preset.id})")
199
+ lines.append(f" {preset.description}")
200
+
201
+ # Add a visual indicator if custom instructions are allowed
202
+ if preset.allow_custom_instructions:
203
+ lines.append(" ✏️ Allows custom instructions")
204
+
205
+ lines.append("") # Empty line between presets
206
+
207
+ return "\n".join(lines)
208
+
209
+
210
+ def prompt_for_custom_instructions(preset: PromptPreset) -> str:
211
+ """
212
+ Prompt user for custom instructions if allowed by preset.
213
+
214
+ Args:
215
+ preset: The selected preset
216
+
217
+ Returns:
218
+ Custom instructions string (may be empty)
219
+ """
220
+ if not preset.allow_custom_instructions:
221
+ return ""
222
+
223
+ print("\n🔧 Custom instructions (optional):")
224
+
225
+ if preset.custom_instructions_placeholder:
226
+ print(f"提示:{preset.custom_instructions_placeholder}")
227
+ else:
228
+ print("提示:你可以告诉 agent 应该如何代表你")
229
+
230
+ print()
231
+ instructions = input("Instructions: ").strip()
232
+
233
+ if instructions:
234
+ print(f"\n✓ Custom instructions added: {instructions[:50]}{'...' if len(instructions) > 50 else ''}")
235
+ else:
236
+ print("\n✓ No custom instructions")
237
+
238
+ return instructions