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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
  2. aline_ai-0.3.0.dist-info/RECORD +41 -0
  3. aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
  4. realign/__init__.py +32 -1
  5. realign/cli.py +203 -19
  6. realign/commands/__init__.py +2 -2
  7. realign/commands/clean.py +149 -0
  8. realign/commands/config.py +1 -1
  9. realign/commands/export_shares.py +1785 -0
  10. realign/commands/hide.py +112 -24
  11. realign/commands/import_history.py +873 -0
  12. realign/commands/init.py +104 -217
  13. realign/commands/mirror.py +131 -0
  14. realign/commands/pull.py +101 -0
  15. realign/commands/push.py +155 -245
  16. realign/commands/review.py +216 -54
  17. realign/commands/session_utils.py +139 -4
  18. realign/commands/share.py +965 -0
  19. realign/commands/status.py +559 -0
  20. realign/commands/sync.py +91 -0
  21. realign/commands/undo.py +423 -0
  22. realign/commands/watcher.py +805 -0
  23. realign/config.py +21 -10
  24. realign/file_lock.py +3 -1
  25. realign/hash_registry.py +310 -0
  26. realign/hooks.py +115 -411
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +997 -139
  30. realign/mirror_utils.py +322 -0
  31. realign/prompts/__init__.py +21 -0
  32. realign/prompts/presets.py +238 -0
  33. realign/redactor.py +168 -16
  34. realign/tracker/__init__.py +9 -0
  35. realign/tracker/git_tracker.py +1123 -0
  36. realign/watcher_daemon.py +115 -0
  37. aline_ai-0.2.6.dist-info/RECORD +0 -28
  38. aline_ai-0.2.6.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -242
  40. realign/commands/commit.py +0 -379
  41. realign/commands/search.py +0 -449
  42. realign/commands/show.py +0 -416
  43. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
realign/commands/show.py DELETED
@@ -1,416 +0,0 @@
1
- """ReAlign show command - Display agent sessions from commits or files."""
2
-
3
- import subprocess
4
- import json
5
- from pathlib import Path
6
- from typing import Optional
7
- import typer
8
- from rich.console import Console
9
- from rich.syntax import Syntax
10
- from rich.panel import Panel
11
- from rich.markdown import Markdown
12
-
13
- console = Console()
14
- from .session_utils import find_session_paths_for_commit
15
-
16
-
17
- def calculate_line_range(
18
- from_line: Optional[int],
19
- to_line: Optional[int],
20
- around_line: Optional[int],
21
- context: int,
22
- first: Optional[int],
23
- last: Optional[int],
24
- total_lines: int,
25
- ) -> Optional[tuple[int, int]]:
26
- """
27
- Calculate the line range to display based on various parameters.
28
-
29
- Returns (start_line, end_line) tuple (1-indexed, inclusive) or None for all lines.
30
- """
31
- # Priority: around > from/to > first/last
32
-
33
- if around_line is not None:
34
- # Show lines around a specific line
35
- start = max(1, around_line - context)
36
- end = min(total_lines, around_line + context)
37
- return (start, end)
38
-
39
- if from_line is not None or to_line is not None:
40
- # Show range from start to end
41
- start = from_line if from_line is not None else 1
42
- end = to_line if to_line is not None else total_lines
43
- return (start, end)
44
-
45
- if first is not None:
46
- # Show first N lines
47
- return (1, min(first, total_lines))
48
-
49
- if last is not None:
50
- # Show last N lines
51
- return (max(1, total_lines - last + 1), total_lines)
52
-
53
- # No range specified, show all
54
- return None
55
-
56
-
57
- def show_command(
58
- commit: Optional[str] = typer.Argument(None, help="Commit hash to show session from"),
59
- session: Optional[str] = typer.Option(None, "--session", "-s", help="Direct path to session file"),
60
- format_output: str = typer.Option("pretty", "--format", "-f", help="Output format: pretty, json, raw"),
61
- pager: bool = typer.Option(False, "--pager", "-p", help="Use pager (less) for output"),
62
- from_line: Optional[int] = typer.Option(None, "--from", help="Start from line number (inclusive)"),
63
- to_line: Optional[int] = typer.Option(None, "--to", help="End at line number (inclusive)"),
64
- around_line: Optional[int] = typer.Option(None, "--around", help="Show lines around this line number"),
65
- context: int = typer.Option(5, "--context", "-C", help="Number of lines before/after when using --around (default: 5)"),
66
- first: Optional[int] = typer.Option(None, "--first", help="Show only first N lines"),
67
- last: Optional[int] = typer.Option(None, "--last", help="Show only last N lines"),
68
- ):
69
- """Display agent sessions from commits or files."""
70
- if not commit and not session:
71
- console.print("[red]Error: Must specify either a commit hash or --session path[/red]")
72
- raise typer.Exit(1)
73
-
74
- # Check if we're in a git repository
75
- try:
76
- subprocess.run(
77
- ["git", "rev-parse", "--git-dir"],
78
- check=True,
79
- capture_output=True,
80
- text=True,
81
- )
82
- except subprocess.CalledProcessError:
83
- console.print("[red]Error: Not in a git repository.[/red]")
84
- raise typer.Exit(1)
85
-
86
- repo_root = Path(
87
- subprocess.run(
88
- ["git", "rev-parse", "--show-toplevel"],
89
- check=True,
90
- capture_output=True,
91
- text=True,
92
- ).stdout.strip()
93
- )
94
-
95
- session_path = None
96
- session_content = None
97
-
98
- # Get session from commit
99
- if commit:
100
- console.print(f"[blue]Fetching session for commit:[/blue] {commit}")
101
-
102
- session_paths = find_session_paths_for_commit(repo_root, commit)
103
- if not session_paths:
104
- console.print("[yellow]No agent session files tracked in this commit.[/yellow]")
105
- raise typer.Exit(0)
106
-
107
- session_path = session_paths[0]
108
- if len(session_paths) > 1:
109
- other_paths = ", ".join(session_paths[1:])
110
- console.print(f"[yellow]Multiple session files found; showing first. Others: {other_paths}[/yellow]")
111
- console.print(f"[green]Found session:[/green] {session_path}")
112
-
113
- # Try to read from working tree first
114
- full_session_path = repo_root / session_path
115
- if full_session_path.exists():
116
- with open(full_session_path, "r", encoding="utf-8") as f:
117
- session_content = f.read()
118
- else:
119
- # Try to get from git
120
- result = subprocess.run(
121
- ["git", "show", f"{commit}:{session_path}"],
122
- capture_output=True,
123
- text=True,
124
- check=False,
125
- cwd=repo_root,
126
- )
127
- if result.returncode == 0:
128
- session_content = result.stdout
129
- else:
130
- console.print(f"[red]Could not find session file:[/red] {session_path}")
131
- raise typer.Exit(1)
132
-
133
- # Get session from direct path
134
- elif session:
135
- session_path = session
136
- full_path = Path(session) if Path(session).is_absolute() else repo_root / session
137
-
138
- if not full_path.exists():
139
- console.print(f"[red]Session file not found:[/red] {session}")
140
- raise typer.Exit(1)
141
-
142
- console.print(f"[blue]Reading session:[/blue] {session}")
143
- with open(full_path, "r", encoding="utf-8") as f:
144
- session_content = f.read()
145
-
146
- # Calculate line range
147
- line_range = calculate_line_range(
148
- from_line=from_line,
149
- to_line=to_line,
150
- around_line=around_line,
151
- context=context,
152
- first=first,
153
- last=last,
154
- total_lines=len(session_content.strip().split("\n")) if session_content else 0,
155
- )
156
-
157
- # Display session content
158
- if session_content:
159
- display_session(session_content, format_output, pager, session_path, line_range)
160
-
161
-
162
- def display_session(content: str, format_type: str, use_pager: bool, session_path: Optional[str], line_range: Optional[tuple[int, int]] = None):
163
- """Display session content in specified format."""
164
- # Filter content by line range if specified
165
- if line_range is not None:
166
- start_line, end_line = line_range
167
- all_lines = content.strip().split("\n")
168
- filtered_lines = all_lines[start_line - 1:end_line] # Convert to 0-indexed
169
- content = "\n".join(filtered_lines)
170
-
171
- # Show range info
172
- total_lines = len(all_lines)
173
- console.print(f"[dim]Showing lines {start_line}-{end_line} of {total_lines}[/dim]\n")
174
-
175
- if format_type == "raw":
176
- if use_pager:
177
- try:
178
- subprocess.run(["less", "-R"], input=content, text=True, check=True)
179
- except (subprocess.CalledProcessError, FileNotFoundError):
180
- console.print(content)
181
- else:
182
- console.print(content)
183
- elif format_type == "json":
184
- try:
185
- # Try to parse and pretty-print as JSON/JSONL
186
- lines = content.strip().split("\n")
187
- formatted_lines = []
188
- for line in lines:
189
- try:
190
- obj = json.loads(line)
191
- formatted_lines.append(json.dumps(obj, indent=2, ensure_ascii=False))
192
- except json.JSONDecodeError:
193
- formatted_lines.append(line)
194
- output = "\n".join(formatted_lines)
195
- if use_pager:
196
- try:
197
- subprocess.run(["less", "-R"], input=output, text=True, check=True)
198
- except (subprocess.CalledProcessError, FileNotFoundError):
199
- console.print(output)
200
- else:
201
- console.print(output)
202
- except Exception:
203
- console.print(content)
204
- else: # pretty format
205
- # For pretty format, render directly (can't use pager with rich objects)
206
- format_session_pretty_direct(content, session_path, line_range)
207
-
208
-
209
- def extract_username_from_filename(file_path: str) -> Optional[str]:
210
- """
211
- Extract username from session filename.
212
- Supports two formats:
213
- - New: username_agent_shortid.jsonl (e.g., alice_claude_a1b2c3d4.jsonl)
214
- - Old: timestamp_username_agent_shortid.jsonl (e.g., 1234567890_alice_claude_a1b2c3d4.jsonl)
215
- """
216
- try:
217
- from pathlib import Path
218
- filename = Path(file_path).stem # Get filename without extension
219
- parts = filename.split('_')
220
-
221
- if len(parts) >= 3:
222
- if parts[0].isdigit():
223
- # Old format: timestamp_username_agent_shortid
224
- return parts[1]
225
- else:
226
- # New format: username_agent_shortid
227
- return parts[0]
228
- except Exception:
229
- pass
230
- # Return None if username cannot be extracted (e.g., UUID format)
231
- return None
232
-
233
-
234
- def extract_agent_from_filename(file_path: str) -> Optional[str]:
235
- """
236
- Extract agent type from session filename.
237
- Supports two formats:
238
- - New: username_agent_shortid.jsonl (e.g., alice_claude_a1b2c3d4.jsonl)
239
- - Old: timestamp_username_agent_shortid.jsonl (e.g., 1234567890_alice_claude_a1b2c3d4.jsonl)
240
- Returns 'claude', 'codex', or None if not extractable.
241
- """
242
- try:
243
- from pathlib import Path
244
- filename = Path(file_path).stem # Get filename without extension
245
- parts = filename.split('_')
246
-
247
- if len(parts) >= 3:
248
- if parts[0].isdigit():
249
- # Old format: timestamp_username_agent_shortid
250
- agent = parts[2]
251
- else:
252
- # New format: username_agent_shortid
253
- agent = parts[1]
254
-
255
- # Normalize agent name
256
- agent_lower = agent.lower()
257
- if agent_lower in ('claude', 'codex', 'unknown'):
258
- return agent_lower
259
- except Exception:
260
- pass
261
- return None
262
-
263
-
264
- def extract_text_from_content(content):
265
- """Extract text from various content formats."""
266
- if isinstance(content, str):
267
- return content
268
- if isinstance(content, list):
269
- texts = []
270
- for item in content:
271
- if isinstance(item, dict):
272
- if item.get("type") == "text":
273
- texts.append(item.get("text", ""))
274
- elif "text" in item:
275
- texts.append(item.get("text", ""))
276
- elif isinstance(item, str):
277
- texts.append(item)
278
- return "\n".join(texts) if texts else ""
279
- if isinstance(content, dict):
280
- # Handle nested content structure
281
- if "content" in content:
282
- return extract_text_from_content(content["content"])
283
- elif "text" in content:
284
- return content["text"]
285
- return str(content)
286
-
287
-
288
- def format_session_pretty_direct(content: str, session_path: Optional[str], line_range: Optional[tuple[int, int]] = None):
289
- """Format and display session content in a pretty, readable way."""
290
- lines = content.strip().split("\n")
291
-
292
- # Extract username and agent from filename if available
293
- username = None
294
- agent_from_filename = None
295
- if session_path:
296
- username = extract_username_from_filename(session_path)
297
- agent_from_filename = extract_agent_from_filename(session_path)
298
- console.print(f"\n[bold cyan]Session: {session_path}[/bold cyan]\n")
299
-
300
- # Calculate starting line number for display
301
- start_line_num = line_range[0] if line_range else 1
302
-
303
- for i, line in enumerate(lines, start_line_num):
304
- try:
305
- # Try to parse as JSON (for JSONL format)
306
- obj = json.loads(line)
307
-
308
- role = None
309
- content_text = ""
310
- timestamp = obj.get("timestamp", "")
311
- model = None
312
-
313
- # Handle different message formats
314
- # Format 1: Claude Code format with type and message
315
- if obj.get("type") in ("user", "assistant"):
316
- role = obj.get("type")
317
- message = obj.get("message", {})
318
- if isinstance(message, dict):
319
- content_text = extract_text_from_content(message.get("content", ""))
320
- if role == "assistant":
321
- model = message.get("model", "")
322
- # Format 2: Codex format
323
- elif obj.get("type") == "response_item":
324
- payload = obj.get("payload", {})
325
- if payload.get("type") == "message":
326
- role = payload.get("role")
327
- content = payload.get("content", [])
328
- # Extract text from Codex content format
329
- texts = []
330
- for item in content if isinstance(content, list) else []:
331
- if isinstance(item, dict):
332
- # Codex uses "input_text" and "output_text" types
333
- if item.get("type") in ("input_text", "output_text"):
334
- texts.append(item.get("text", ""))
335
- content_text = "\n".join(texts)
336
- # Codex doesn't store model info in session files
337
- model = None
338
- else:
339
- # Skip non-message response_items (reasoning, session_meta, etc.)
340
- continue
341
- # Format 3: Simple format with role and content
342
- elif "role" in obj and "content" in obj:
343
- role = obj.get("role")
344
- content_text = extract_text_from_content(obj.get("content"))
345
- if role == "assistant":
346
- model = obj.get("model", "")
347
- else:
348
- # Skip non-message types (session_meta, etc.)
349
- obj_type = obj.get("type")
350
- if obj_type in ("session_meta", "reasoning", "session_start", "session_end"):
351
- continue
352
- role = obj.get("role", "unknown")
353
- content_text = extract_text_from_content(obj.get("content", ""))
354
-
355
- # Skip if no role extracted or no content
356
- if not role or not content_text or not content_text.strip():
357
- continue
358
-
359
- # Build title with username/model info and line number
360
- if role == "user":
361
- display_username = username or "unknown"
362
- title = f"[bold blue]User ({display_username})[/bold blue] [dim]line {i}[/dim] {timestamp}"
363
- console.print(
364
- Panel(
365
- content_text,
366
- title=title,
367
- border_style="blue",
368
- padding=(1, 2),
369
- )
370
- )
371
- elif role == "assistant":
372
- # Try to get model from content first, fallback to agent from filename
373
- if model:
374
- # Check if it's just an agent name or full model name
375
- if model.lower() in ('codex', 'claude', 'unknown'):
376
- display_model = model
377
- else:
378
- # Full model name - extract short version
379
- display_model = model.split('-2024')[0].split('-2025')[0]
380
- elif agent_from_filename:
381
- # Use agent type from filename
382
- display_model = agent_from_filename
383
- else:
384
- display_model = "unknown"
385
- title = f"[bold green]Assistant ({display_model})[/bold green] [dim]line {i}[/dim] {timestamp}"
386
- console.print(
387
- Panel(
388
- content_text,
389
- title=title,
390
- border_style="green",
391
- padding=(1, 2),
392
- )
393
- )
394
- else:
395
- console.print(
396
- Panel(
397
- content_text,
398
- title=f"[bold yellow]{role.title()}[/bold yellow] [dim]line {i}[/dim] {timestamp}",
399
- border_style="yellow",
400
- padding=(1, 2),
401
- )
402
- )
403
-
404
- except json.JSONDecodeError:
405
- # Not JSON, display as plain text
406
- console.print(f"[dim]{i:4d}:[/dim] {line}")
407
-
408
-
409
- def format_session_pretty(content: str, session_path: Optional[str]) -> str:
410
- """Format session content in a pretty, readable way (deprecated - use format_session_pretty_direct)."""
411
- # This is kept for compatibility but not used
412
- return content
413
-
414
-
415
- if __name__ == "__main__":
416
- typer.run(show_command)