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.
- {aline_ai-0.2.5.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 +368 -384
- realign/logging_config.py +2 -2
- realign/mcp_server.py +263 -549
- realign/mcp_watcher.py +999 -142
- 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.5.dist-info/RECORD +0 -28
- aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
- realign/commands/auto_commit.py +0 -231
- realign/commands/commit.py +0 -379
- realign/commands/search.py +0 -449
- realign/commands/show.py +0 -416
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
realign/mirror_utils.py
ADDED
|
@@ -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
|