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
@@ -1,449 +0,0 @@
1
- """ReAlign search command - Search through agent sessions and commit history."""
2
-
3
- import subprocess
4
- import re
5
- import json
6
- from pathlib import Path
7
- from typing import Optional, List, Dict, Any, Tuple
8
- import typer
9
- from rich.console import Console
10
- from rich.table import Table
11
- from .session_utils import find_session_paths_for_commit
12
-
13
- console = Console()
14
- SUMMARY_BLOCK_PATTERN = re.compile(
15
- r"\n*--- LLM-Summary \((?P<model>.+?)\) ---\n(?P<bullets>(?:\*.*\n?)+)",
16
- re.MULTILINE,
17
- )
18
-
19
-
20
- def search_command(
21
- keyword: str = typer.Argument(..., help="Keyword to search for"),
22
- show_session: bool = typer.Option(False, "--show-session", help="Show session content for matches"),
23
- max_results: int = typer.Option(20, "--max", "-n", help="Maximum number of results to show"),
24
- session_only: bool = typer.Option(False, "--session-only", help="Search only in session files, not commits"),
25
- commits_only: bool = typer.Option(False, "--commits-only", help="Search only in commits, not session files"),
26
- ):
27
- """Search through agent sessions and commit history."""
28
- # Check if we're in a git repository
29
- try:
30
- subprocess.run(
31
- ["git", "rev-parse", "--git-dir"],
32
- check=True,
33
- capture_output=True,
34
- text=True,
35
- )
36
- except subprocess.CalledProcessError:
37
- console.print("[red]Error: Not in a git repository.[/red]")
38
- raise typer.Exit(1)
39
-
40
- repo_root = Path(
41
- subprocess.run(
42
- ["git", "rev-parse", "--show-toplevel"],
43
- check=True,
44
- capture_output=True,
45
- text=True,
46
- ).stdout.strip()
47
- )
48
-
49
- console.print(f"[blue]Searching for:[/blue] '{keyword}'")
50
-
51
- # Determine what to search based on flags
52
- search_commits = not session_only
53
- search_sessions = not commits_only
54
-
55
- # Search in commit messages (including summary blocks)
56
- if search_commits:
57
- console.print("\n[bold]Commits with matching summaries:[/bold]")
58
-
59
- try:
60
- # Use a unique separator to split commits
61
- # Format: hash|author|subject|body
62
- result = subprocess.run(
63
- ["git", "log", f"--grep={keyword}", "-i", "--pretty=format:%H|%an|%s|%b%x00", f"-n{max_results}"],
64
- capture_output=True,
65
- text=True,
66
- check=True,
67
- cwd=repo_root,
68
- )
69
-
70
- if result.stdout:
71
- commits = []
72
- # Split by null byte to separate commits
73
- commit_entries = result.stdout.split("\x00")
74
-
75
- for entry in commit_entries:
76
- entry = entry.strip()
77
- if not entry:
78
- continue
79
-
80
- # Split only at the first three pipes to preserve body content
81
- parts = entry.split("|", 3)
82
- if len(parts) >= 3:
83
- commit_hash = parts[0][:8]
84
- author = parts[1]
85
- subject = parts[2]
86
- body = parts[3] if len(parts) > 3 else ""
87
- summary_model, summary_bullets, cleaned_body = extract_summary_from_body(body)
88
-
89
- commits.append({
90
- "hash": commit_hash,
91
- "author": author,
92
- "subject": subject,
93
- "body": body,
94
- "display_body": cleaned_body.strip(),
95
- "summary_model": summary_model,
96
- "summary_bullets": summary_bullets,
97
- })
98
-
99
- if commits:
100
- def highlight_text(text: str, keyword: str) -> str:
101
- """Highlight keyword in text (case-insensitive)."""
102
- if not keyword or not text:
103
- return text
104
- # Use regex to find and replace keyword (case-insensitive)
105
- pattern = re.compile(f'({re.escape(keyword)})', re.IGNORECASE)
106
- return pattern.sub(r'[black on yellow]\1[/black on yellow]', text)
107
-
108
- for i, commit in enumerate(commits, 1):
109
- console.print(f"\n[bold cyan]{i}. Commit {commit['hash']}[/bold cyan] by [green]{commit['author']}[/green]")
110
- console.print(f" [bold]{highlight_text(commit['subject'], keyword)}[/bold]")
111
-
112
- # Show body if it exists (excluding agent metadata)
113
- display_body = commit.get("display_body", "")
114
- if display_body:
115
- for line in display_body.split('\n'):
116
- console.print(f" {highlight_text(line, keyword)}")
117
-
118
- bullets = commit.get("summary_bullets") or []
119
- if bullets:
120
- header = f"LLM-Summary ({commit.get('summary_model') or 'Local summarizer'})"
121
- console.print(f" [yellow]{highlight_text(header, keyword)}[/yellow]")
122
- for bullet in bullets:
123
- console.print(f" [yellow]{highlight_text(bullet, keyword)}[/yellow]")
124
-
125
- # Show session content if requested
126
- if show_session and commits:
127
- console.print("\n[bold]Session content:[/bold]")
128
- for commit in commits[:5]: # Limit to first 5 for session display
129
- show_session_content(repo_root, commit["hash"])
130
- else:
131
- console.print("[yellow]No commits found matching the keyword.[/yellow]")
132
- else:
133
- console.print("[yellow]No commits found matching the keyword.[/yellow]")
134
-
135
- except subprocess.CalledProcessError as e:
136
- console.print(f"[red]Error searching commits:[/red] {e}")
137
-
138
- # Search in session files
139
- if search_sessions:
140
- sessions_dir = repo_root / ".realign" / "sessions"
141
- if sessions_dir.exists():
142
- console.print("\n[bold]Session file matches:[/bold]")
143
-
144
- try:
145
- result = subprocess.run(
146
- ["grep", "-r", "-i", "-l", keyword, str(sessions_dir)],
147
- capture_output=True,
148
- text=True,
149
- check=False,
150
- )
151
-
152
- if result.stdout:
153
- matching_files = result.stdout.strip().split("\n")
154
- for file_path in matching_files[:max_results]:
155
- rel_path = Path(file_path).relative_to(repo_root)
156
- console.print(f"\n[bold cyan]• {rel_path}[/bold cyan]")
157
-
158
- # Show parsed session content with context
159
- display_session_matches(Path(file_path), keyword, max_matches=5)
160
- else:
161
- console.print("[yellow]No session files found matching the keyword.[/yellow]")
162
-
163
- except subprocess.CalledProcessError:
164
- console.print("[yellow]Could not search session files.[/yellow]")
165
-
166
-
167
- def show_session_content(repo_root: Path, commit_hash: str):
168
- """Show content of a session file from a commit."""
169
- console.print(f"\n[bold cyan]Session for commit {commit_hash}:[/bold cyan]")
170
-
171
- session_paths = find_session_paths_for_commit(repo_root, commit_hash)
172
- if not session_paths:
173
- console.print(" [yellow]No session files recorded in this commit.[/yellow]")
174
- return
175
-
176
- session_path = session_paths[0]
177
- session_file = repo_root / session_path
178
- if session_file.exists():
179
- console.print(f" [dim]Path: {session_path}[/dim]")
180
- show_file_content(session_file)
181
- else:
182
- result = subprocess.run(
183
- ["git", "show", f"{commit_hash}:{session_path}"],
184
- capture_output=True,
185
- text=True,
186
- check=False,
187
- cwd=repo_root,
188
- )
189
- if result.returncode == 0:
190
- console.print(f" [dim]Path: {session_path} (from commit tree)[/dim]")
191
- console.print(result.stdout)
192
- else:
193
- console.print(f"[yellow]Session file not found:[/yellow] {session_path}")
194
-
195
-
196
- def extract_summary_from_body(body: str) -> Tuple[str, List[str], str]:
197
- """Extract summary metadata from commit body."""
198
- match = SUMMARY_BLOCK_PATTERN.search(body)
199
- if not match:
200
- return "", [], body
201
-
202
- summary_model = match.group("model").strip()
203
- bullets_text = match.group("bullets").strip()
204
- bullet_lines = [line.strip() for line in bullets_text.splitlines() if line.strip()]
205
- start, end = match.span()
206
- cleaned_body = (body[:start] + body[end:]).strip()
207
- return summary_model, bullet_lines, cleaned_body
208
-
209
-
210
- def show_file_content(file_path: Path, highlight: Optional[str] = None, max_lines: int = 20):
211
- """Show content of a file with optional highlighting."""
212
- try:
213
- with open(file_path, "r", encoding="utf-8") as f:
214
- lines = f.readlines()
215
-
216
- console.print(f"[dim]Showing first {min(len(lines), max_lines)} lines...[/dim]")
217
-
218
- for i, line in enumerate(lines[:max_lines], 1):
219
- if highlight and highlight.lower() in line.lower():
220
- console.print(f"{i:4d}: [yellow]{line.rstrip()}[/yellow]")
221
- else:
222
- console.print(f"{i:4d}: {line.rstrip()}")
223
-
224
- if len(lines) > max_lines:
225
- console.print(f"[dim]... ({len(lines) - max_lines} more lines)[/dim]")
226
-
227
- except Exception as e:
228
- console.print(f"[red]Error reading file:[/red] {e}")
229
-
230
-
231
- def extract_text_from_content(content: Any) -> str:
232
- """Extract plain text from various content formats."""
233
- if isinstance(content, str):
234
- return content
235
- elif isinstance(content, list):
236
- texts = []
237
- for item in content:
238
- if isinstance(item, dict):
239
- if item.get("type") == "text" and "text" in item:
240
- texts.append(item["text"])
241
- elif isinstance(item, str):
242
- texts.append(item)
243
- return " ".join(texts)
244
- elif isinstance(content, dict):
245
- if "text" in content:
246
- return content["text"]
247
- elif "content" in content:
248
- return extract_text_from_content(content["content"])
249
- return ""
250
-
251
-
252
- def extract_username_from_filename(file_path: Path) -> Optional[str]:
253
- """
254
- Extract username from session filename.
255
- Supports two formats:
256
- - New: username_agent_shortid.jsonl (e.g., alice_claude_a1b2c3d4.jsonl)
257
- - Old: timestamp_username_agent_shortid.jsonl (e.g., 1234567890_alice_claude_a1b2c3d4.jsonl)
258
- """
259
- try:
260
- filename = file_path.stem # Get filename without extension
261
- parts = filename.split('_')
262
-
263
- if len(parts) >= 3:
264
- if parts[0].isdigit():
265
- # Old format: timestamp_username_agent_shortid
266
- return parts[1]
267
- else:
268
- # New format: username_agent_shortid
269
- return parts[0]
270
- except Exception:
271
- pass
272
- # Return None if username cannot be extracted (e.g., UUID format)
273
- return None
274
-
275
-
276
- def extract_agent_from_filename(file_path: Path) -> Optional[str]:
277
- """
278
- Extract agent type from session filename.
279
- Supports two formats:
280
- - New: username_agent_shortid.jsonl (e.g., alice_claude_a1b2c3d4.jsonl)
281
- - Old: timestamp_username_agent_shortid.jsonl (e.g., 1234567890_alice_claude_a1b2c3d4.jsonl)
282
- Returns 'claude', 'codex', or None if not extractable.
283
- """
284
- try:
285
- filename = file_path.stem # Get filename without extension
286
- parts = filename.split('_')
287
-
288
- if len(parts) >= 3:
289
- if parts[0].isdigit():
290
- # Old format: timestamp_username_agent_shortid
291
- agent = parts[2]
292
- else:
293
- # New format: username_agent_shortid
294
- agent = parts[1]
295
-
296
- # Normalize agent name
297
- agent_lower = agent.lower()
298
- if agent_lower in ('claude', 'codex', 'unknown'):
299
- return agent_lower
300
- except Exception:
301
- pass
302
- return None
303
-
304
-
305
- def search_in_session_file(file_path: Path, keyword: str, max_matches: int = 5) -> List[Dict[str, Any]]:
306
- """
307
- Search for keyword in a session file and return matching messages with context.
308
-
309
- Returns a list of dicts with: role, text, timestamp, line_number, username (optional), model (optional)
310
- """
311
- matches = []
312
- # Extract username and agent type from filename
313
- username = extract_username_from_filename(file_path)
314
- agent_from_filename = extract_agent_from_filename(file_path)
315
-
316
- try:
317
- with open(file_path, "r", encoding="utf-8") as f:
318
- for line_num, line in enumerate(f, 1):
319
- line = line.strip()
320
- if not line:
321
- continue
322
-
323
- try:
324
- data = json.loads(line)
325
-
326
- # Extract role and message content
327
- role = None
328
- text = ""
329
- timestamp = data.get("timestamp", "")
330
- model = None
331
-
332
- # Handle different message formats
333
- # Format 1: Claude Code format
334
- if data.get("type") in ("user", "assistant"):
335
- role = data.get("type")
336
- message = data.get("message", {})
337
- if isinstance(message, dict):
338
- content = message.get("content", "")
339
- text = extract_text_from_content(content)
340
- # Extract model name for assistant messages
341
- if role == "assistant":
342
- model = message.get("model", "")
343
- # Format 2: Codex format
344
- elif data.get("type") == "response_item":
345
- payload = data.get("payload", {})
346
- if payload.get("type") == "message":
347
- role = payload.get("role")
348
- content = payload.get("content", [])
349
- # Extract text from Codex content format
350
- texts = []
351
- for item in content if isinstance(content, list) else []:
352
- if isinstance(item, dict):
353
- # Codex uses "input_text" and "output_text" types
354
- if item.get("type") in ("input_text", "output_text"):
355
- texts.append(item.get("text", ""))
356
- text = "\n".join(texts)
357
- # Codex doesn't store model info in session files
358
- model = None
359
- # Format 3: Simple JSONL format
360
- elif "role" in data and "content" in data:
361
- role = data.get("role")
362
- text = extract_text_from_content(data.get("content"))
363
- # Extract model name for assistant messages
364
- if role == "assistant":
365
- model = data.get("model", "")
366
-
367
- # Check if keyword is in the text (case-insensitive)
368
- if role and text and keyword.lower() in text.lower():
369
- # If no model from content, use agent from filename (for Codex)
370
- if not model and agent_from_filename:
371
- model = agent_from_filename
372
-
373
- matches.append({
374
- "role": role,
375
- "text": text,
376
- "timestamp": timestamp,
377
- "line_number": line_num,
378
- "username": username,
379
- "model": model,
380
- })
381
-
382
- if len(matches) >= max_matches:
383
- break
384
-
385
- except json.JSONDecodeError:
386
- continue
387
-
388
- except Exception as e:
389
- console.print(f"[red]Error reading session file:[/red] {e}")
390
-
391
- return matches
392
-
393
-
394
- def display_session_matches(file_path: Path, keyword: str, max_matches: int = 5):
395
- """Display matching messages from a session file with context and highlighting."""
396
- matches = search_in_session_file(file_path, keyword, max_matches)
397
-
398
- if not matches:
399
- return
400
-
401
- def highlight_text(text: str, keyword: str) -> str:
402
- """Highlight keyword in text (case-insensitive)."""
403
- if not keyword or not text:
404
- return text
405
- pattern = re.compile(f'({re.escape(keyword)})', re.IGNORECASE)
406
- return pattern.sub(r'[black on yellow]\1[/black on yellow]', text)
407
-
408
- for i, match in enumerate(matches, 1):
409
- role_color = "blue" if match["role"] == "user" else "green"
410
-
411
- # Build role label with optional username/model
412
- role_label = match['role'].upper()
413
- if match['role'] == 'user':
414
- username = match.get('username') or 'unknown'
415
- role_label = f"USER ({username})"
416
- elif match['role'] == 'assistant':
417
- model_name = match.get('model')
418
- if model_name:
419
- # Check if it's just an agent name (codex, claude, unknown) or full model name
420
- if model_name.lower() in ('codex', 'claude', 'unknown'):
421
- # Agent name from filename
422
- role_label = f"ASSISTANT ({model_name})"
423
- else:
424
- # Full model name from session content - extract short version
425
- model_short = model_name.split('-2024')[0].split('-2025')[0] # Remove date suffix
426
- role_label = f"ASSISTANT ({model_short})"
427
- else:
428
- role_label = "ASSISTANT (unknown)"
429
-
430
- console.print(f"\n [bold {role_color}]{role_label}[/bold {role_color}] (line {match['line_number']})")
431
-
432
- # Truncate and highlight text
433
- text = match["text"]
434
- if len(text) > 500:
435
- # Find keyword position and show context around it
436
- keyword_pos = text.lower().find(keyword.lower())
437
- if keyword_pos != -1:
438
- start = max(0, keyword_pos - 200)
439
- end = min(len(text), keyword_pos + 300)
440
- text = ("..." if start > 0 else "") + text[start:end] + ("..." if end < len(text) else "")
441
-
442
- # Display with highlighting and indentation
443
- for line in text.split('\n'):
444
- if line.strip():
445
- console.print(f" {highlight_text(line, keyword)}")
446
-
447
-
448
- if __name__ == "__main__":
449
- typer.run(search_command)