aline-ai 0.6.4__py3-none-any.whl → 0.6.6__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 (41) hide show
  1. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/METADATA +1 -1
  2. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/RECORD +41 -34
  3. realign/__init__.py +1 -1
  4. realign/agent_names.py +79 -0
  5. realign/claude_hooks/stop_hook.py +3 -0
  6. realign/claude_hooks/terminal_state.py +11 -0
  7. realign/claude_hooks/user_prompt_submit_hook.py +3 -0
  8. realign/cli.py +62 -0
  9. realign/codex_detector.py +1 -1
  10. realign/codex_home.py +46 -15
  11. realign/codex_terminal_linker.py +18 -7
  12. realign/commands/agent.py +109 -0
  13. realign/commands/doctor.py +3 -1
  14. realign/commands/export_shares.py +297 -0
  15. realign/commands/search.py +58 -29
  16. realign/dashboard/app.py +9 -158
  17. realign/dashboard/clipboard.py +54 -0
  18. realign/dashboard/screens/__init__.py +4 -0
  19. realign/dashboard/screens/agent_detail.py +333 -0
  20. realign/dashboard/screens/create_agent_info.py +133 -0
  21. realign/dashboard/screens/event_detail.py +6 -27
  22. realign/dashboard/styles/dashboard.tcss +67 -0
  23. realign/dashboard/tmux_manager.py +49 -8
  24. realign/dashboard/widgets/__init__.py +2 -0
  25. realign/dashboard/widgets/agents_panel.py +1129 -0
  26. realign/dashboard/widgets/config_panel.py +17 -11
  27. realign/dashboard/widgets/events_table.py +4 -27
  28. realign/dashboard/widgets/sessions_table.py +4 -27
  29. realign/dashboard/widgets/terminal_panel.py +109 -31
  30. realign/db/base.py +27 -0
  31. realign/db/locks.py +4 -0
  32. realign/db/schema.py +53 -2
  33. realign/db/sqlite_db.py +185 -2
  34. realign/events/agent_summarizer.py +157 -0
  35. realign/events/session_summarizer.py +25 -0
  36. realign/watcher_core.py +60 -3
  37. realign/worker_core.py +24 -1
  38. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/WHEEL +0 -0
  39. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/entry_points.txt +0 -0
  40. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/licenses/LICENSE +0 -0
  41. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,109 @@
1
+ """Agent management commands."""
2
+
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+
11
+ def _get_db():
12
+ """Get database instance."""
13
+ from ..config import ReAlignConfig
14
+ from ..db.sqlite_db import SQLiteDatabase
15
+
16
+ config = ReAlignConfig.load()
17
+ db_path = Path(config.sqlite_db_path).expanduser()
18
+ db = SQLiteDatabase(str(db_path))
19
+ db.initialize()
20
+ return db
21
+
22
+
23
+ def agent_new_command(name: str | None = None, desc: str = "") -> int:
24
+ """Create a new agent profile.
25
+
26
+ Args:
27
+ name: Display name (random Docker-style name if None).
28
+ desc: Agent description.
29
+
30
+ Returns:
31
+ Exit code (0 = success).
32
+ """
33
+ from ..agent_names import generate_agent_name
34
+
35
+ agent_id = str(uuid.uuid4())
36
+ display_name = name or generate_agent_name()
37
+
38
+ db = _get_db()
39
+ try:
40
+ record = db.get_or_create_agent_info(agent_id, name=display_name)
41
+ if desc:
42
+ record = db.update_agent_info(agent_id, description=desc)
43
+
44
+ console.print(f"[bold green]Agent created[/bold green]")
45
+ console.print(f" id: {record.id}")
46
+ console.print(f" name: {record.name}")
47
+ console.print(f" description: {record.description or '(none)'}")
48
+ return 0
49
+ finally:
50
+ db.close()
51
+
52
+
53
+ def agent_list_command(*, include_invisible: bool = False) -> int:
54
+ """List agent profiles.
55
+
56
+ Returns:
57
+ Exit code (0 = success).
58
+ """
59
+ from rich.table import Table
60
+
61
+ db = _get_db()
62
+ try:
63
+ agents = db.list_agent_info(include_invisible=include_invisible)
64
+
65
+ if not agents:
66
+ console.print("[dim]No agents yet.[/dim]")
67
+ return 0
68
+
69
+ table = Table(title="Agents")
70
+ table.add_column("ID", style="dim", width=8)
71
+ table.add_column("Name", style="bold")
72
+ table.add_column("Description")
73
+ table.add_column("Sessions", style="cyan")
74
+ table.add_column("Created", style="dim")
75
+
76
+ def _unique_prefixes(ids: list[str], min_len: int = 8) -> list[str]:
77
+ if not ids:
78
+ return []
79
+ max_len = max(len(i) for i in ids)
80
+ length = min_len
81
+ while length <= max_len:
82
+ prefixes = [i[:length] for i in ids]
83
+ if len(set(prefixes)) == len(ids):
84
+ return prefixes
85
+ length += 2
86
+ return ids
87
+
88
+ for agent in agents:
89
+ created_str = agent.created_at.strftime("%Y-%m-%d %H:%M")
90
+ sessions = db.get_sessions_by_agent_id(agent.id)
91
+ if sessions:
92
+ raw_ids = [s.id for s in sessions]
93
+ short_ids = _unique_prefixes(raw_ids, min_len=8)
94
+ session_ids = ", ".join(short_ids)
95
+ sessions_display = f"{len(sessions)} ({session_ids})"
96
+ else:
97
+ sessions_display = "0"
98
+ table.add_row(
99
+ agent.id[:8],
100
+ agent.name,
101
+ agent.description or "",
102
+ sessions_display,
103
+ created_str,
104
+ )
105
+
106
+ console.print(table)
107
+ return 0
108
+ finally:
109
+ db.close()
@@ -308,6 +308,7 @@ def run_doctor(
308
308
  start_if_not_running: bool,
309
309
  verbose: bool,
310
310
  clear_cache: bool,
311
+ auto_fix: bool = False,
311
312
  ) -> int:
312
313
  """
313
314
  Core doctor logic.
@@ -317,6 +318,7 @@ def run_doctor(
317
318
  start_if_not_running: If True and restart_daemons is True, start daemons even if not running.
318
319
  verbose: Print details.
319
320
  clear_cache: Clear Python bytecode cache for the installed package directory.
321
+ auto_fix: If True, automatically fix failed jobs without prompting.
320
322
  """
321
323
  from ..auth import is_logged_in
322
324
  from . import watcher as watcher_cmd
@@ -417,7 +419,7 @@ def run_doctor(
417
419
  console.print(f" [yellow]![/yellow] Found {llm_error_count} turn(s) with LLM API errors")
418
420
 
419
421
  # Ask user if they want to fix
420
- if typer.confirm("\n Do you want to requeue these for regeneration?", default=True):
422
+ if auto_fix or typer.confirm("\n Do you want to requeue these for regeneration?", default=True):
421
423
  requeued_jobs = 0
422
424
  requeued_turns = 0
423
425
 
@@ -15,6 +15,7 @@ import secrets
15
15
  import hashlib
16
16
  import base64
17
17
  import threading
18
+ import shutil
18
19
  from urllib.parse import urlparse
19
20
  from collections import defaultdict
20
21
  from dataclasses import dataclass
@@ -2523,6 +2524,59 @@ def display_share_result(
2523
2524
  console.print(f"[bold]👁️ Max Views:[/bold] {max_views}\n")
2524
2525
 
2525
2526
 
2527
+ def _run_clipboard_command(command: list[str], text: str) -> bool:
2528
+ try:
2529
+ return (
2530
+ subprocess.run(
2531
+ command,
2532
+ input=text,
2533
+ text=True,
2534
+ capture_output=False,
2535
+ check=False,
2536
+ ).returncode
2537
+ == 0
2538
+ )
2539
+ except Exception:
2540
+ return False
2541
+
2542
+
2543
+ def _copy_text_to_clipboard(text: str) -> bool:
2544
+ if not text:
2545
+ return False
2546
+
2547
+ if shutil.which("pbcopy"):
2548
+ if _run_clipboard_command(["pbcopy"], text):
2549
+ return True
2550
+
2551
+ if os.name == "nt" and shutil.which("clip"):
2552
+ if _run_clipboard_command(["clip"], text):
2553
+ return True
2554
+
2555
+ if shutil.which("wl-copy"):
2556
+ if _run_clipboard_command(["wl-copy"], text):
2557
+ return True
2558
+
2559
+ if shutil.which("xclip"):
2560
+ if _run_clipboard_command(["xclip", "-selection", "clipboard"], text):
2561
+ return True
2562
+
2563
+ if shutil.which("xsel"):
2564
+ if _run_clipboard_command(["xsel", "--clipboard", "--input"], text):
2565
+ return True
2566
+
2567
+ return False
2568
+
2569
+
2570
+ def _copy_share_to_clipboard(share_url: Optional[str], slack_message: Optional[str]) -> bool:
2571
+ if not share_url:
2572
+ return False
2573
+ if slack_message:
2574
+ text_to_copy = f"{slack_message}\n\n{share_url}"
2575
+ else:
2576
+ text_to_copy = share_url
2577
+ return _copy_text_to_clipboard(text_to_copy)
2578
+
2579
+
2526
2580
  def _export_by_events_interactive(
2527
2581
  all_commits: List,
2528
2582
  shadow_git: Path,
@@ -3616,7 +3670,250 @@ def export_shares_interactive_command(
3616
3670
  max_views=max_views,
3617
3671
  admin_token=result.get("admin_token"),
3618
3672
  )
3673
+ copied = _copy_share_to_clipboard(
3674
+ result.get("share_url"),
3675
+ ui_metadata.get("slack_message") if ui_metadata else None,
3676
+ )
3677
+ if copied:
3678
+ print("📋 Copied Slack message and share link to clipboard.")
3619
3679
 
3620
3680
  if not json_output:
3621
3681
  logger.info(f"======== Interactive export completed: {result['share_url']} ========")
3622
3682
  return 0
3683
+
3684
+
3685
+ def export_agent_shares_command(
3686
+ agent_id: str,
3687
+ password: Optional[str] = None,
3688
+ expiry_days: int = 7,
3689
+ max_views: int = 100,
3690
+ backend_url: Optional[str] = None,
3691
+ enable_mcp: bool = True,
3692
+ json_output: bool = False,
3693
+ compact: bool = True,
3694
+ max_tool_result_chars: int = 8_000,
3695
+ max_tool_command_chars: int = 2_000,
3696
+ progress_callback: Optional[Callable[[str], None]] = None,
3697
+ ) -> int:
3698
+ """
3699
+ Export all sessions associated with an agent and generate a share link.
3700
+
3701
+ This function creates a synthetic event structure from agent sessions,
3702
+ generates UI metadata (Slack message), uploads to backend, and returns
3703
+ the share link.
3704
+
3705
+ Args:
3706
+ agent_id: The agent_info ID to export sessions for
3707
+ password: Encryption password (if None, no encryption)
3708
+ expiry_days: Share expiry in days
3709
+ max_views: Maximum number of views
3710
+ backend_url: Backend server URL (uses config default if None)
3711
+ enable_mcp: Whether to include MCP instructions
3712
+ json_output: If True, output JSON format
3713
+ compact: Whether to compact the export data
3714
+ max_tool_result_chars: Max chars for tool results (with compact)
3715
+ max_tool_command_chars: Max chars for tool commands (with compact)
3716
+ progress_callback: Optional callback for progress updates (message: str) -> None
3717
+
3718
+ Returns:
3719
+ 0 on success, 1 on error
3720
+ """
3721
+ def _progress(msg: str) -> None:
3722
+ if progress_callback:
3723
+ progress_callback(msg)
3724
+
3725
+ if not json_output:
3726
+ logger.info(f"======== Export agent shares command started for agent {agent_id} ========")
3727
+
3728
+ # Check dependencies
3729
+ if not CRYPTO_AVAILABLE:
3730
+ if not json_output:
3731
+ print("Error: cryptography package not installed", file=sys.stderr)
3732
+ return 1
3733
+
3734
+ if not HTTPX_AVAILABLE:
3735
+ if not json_output:
3736
+ print("Error: httpx package not installed", file=sys.stderr)
3737
+ return 1
3738
+
3739
+ # Check authentication
3740
+ if not is_logged_in():
3741
+ if not json_output:
3742
+ print("Error: Not logged in. Please run 'aline login' first.", file=sys.stderr)
3743
+ return 1
3744
+
3745
+ _progress("Fetching agent info...")
3746
+
3747
+ # Get backend URL
3748
+ if backend_url is None:
3749
+ from ..config import ReAlignConfig
3750
+
3751
+ config = ReAlignConfig.load()
3752
+ backend_url = config.share_backend_url
3753
+
3754
+ # Get database
3755
+ from ..db import get_database
3756
+
3757
+ db = get_database()
3758
+
3759
+ # Get agent info
3760
+ agent_info = db.get_agent_info(agent_id)
3761
+ if not agent_info:
3762
+ if not json_output:
3763
+ print(f"Error: Agent not found: {agent_id}", file=sys.stderr)
3764
+ return 1
3765
+
3766
+ # Get sessions for this agent
3767
+ session_records = db.get_sessions_by_agent_id(agent_id)
3768
+ if not session_records:
3769
+ if not json_output:
3770
+ print(f"Error: Agent has no sessions to share", file=sys.stderr)
3771
+ return 1
3772
+
3773
+ _progress(f"Found {len(session_records)} session(s)")
3774
+
3775
+ # Build exportable sessions
3776
+ selected_sessions = build_exportable_sessions_from_records(session_records)
3777
+ if not selected_sessions:
3778
+ if not json_output:
3779
+ print("Error: No sessions found for agent", file=sys.stderr)
3780
+ return 1
3781
+
3782
+ # Create a synthetic event structure for the agent
3783
+ # We use the agent name/description as event title/description
3784
+ event_title = agent_info.name or "Agent Sessions"
3785
+ event_description = agent_info.description or f"Sessions from agent: {agent_info.name}"
3786
+
3787
+ # Create a synthetic ExportableEvent
3788
+ synthetic_event = ExportableEvent(
3789
+ index=1,
3790
+ event_id=f"agent-{agent_id}",
3791
+ title=event_title,
3792
+ description=event_description,
3793
+ event_type="agent",
3794
+ status="active",
3795
+ updated_at=datetime.now(timezone.utc),
3796
+ sessions=session_records,
3797
+ )
3798
+
3799
+ # Build conversation data
3800
+ _progress("Building conversation data...")
3801
+
3802
+ username = os.environ.get("USER") or os.environ.get("USERNAME") or "anonymous"
3803
+
3804
+ compaction = None
3805
+ if compact:
3806
+ compaction = ExportCompactionConfig(
3807
+ enabled=True,
3808
+ max_tool_result_chars=max_tool_result_chars,
3809
+ max_tool_command_chars=max_tool_command_chars,
3810
+ )
3811
+
3812
+ try:
3813
+ conversation_data = build_enhanced_conversation_data(
3814
+ selected_event=synthetic_event,
3815
+ selected_sessions=selected_sessions,
3816
+ username=username,
3817
+ db=db,
3818
+ compaction=compaction,
3819
+ )
3820
+ except Exception as e:
3821
+ if not json_output:
3822
+ print(f"Error: Failed to build conversation data: {e}", file=sys.stderr)
3823
+ logger.error(f"Failed to build conversation data: {e}", exc_info=True)
3824
+ return 1
3825
+
3826
+ if not conversation_data.get("sessions"):
3827
+ if not json_output:
3828
+ print("Error: No sessions found in conversation data", file=sys.stderr)
3829
+ return 1
3830
+
3831
+ # Generate UI metadata with LLM
3832
+ _progress("Generating share message...")
3833
+
3834
+ from ..config import ReAlignConfig
3835
+
3836
+ config = ReAlignConfig.load()
3837
+ ui_metadata, _ = generate_ui_metadata_with_llm(
3838
+ conversation_data,
3839
+ [], # No commits
3840
+ event_title=event_title,
3841
+ event_description=event_description,
3842
+ provider=config.llm_provider,
3843
+ preset_id="default",
3844
+ silent=json_output,
3845
+ )
3846
+
3847
+ if ui_metadata:
3848
+ conversation_data["ui_metadata"] = ui_metadata
3849
+ else:
3850
+ conversation_data["ui_metadata"] = {
3851
+ "title": event_title,
3852
+ "description": event_description,
3853
+ }
3854
+
3855
+ # Add MCP instructions if enabled
3856
+ if enable_mcp:
3857
+ conversation_data["ui_metadata"]["mcp_instructions"] = {
3858
+ "tool_name": "ask_shared_conversation",
3859
+ "usage": "Local AI agents can install the aline MCP server and use the 'ask_shared_conversation' tool to query this conversation programmatically.",
3860
+ }
3861
+
3862
+ # Upload to backend (no encryption for agent shares by default)
3863
+ _progress("Uploading to cloud...")
3864
+
3865
+ metadata = {
3866
+ "username": username,
3867
+ "expiry_days": expiry_days,
3868
+ "max_views": max_views,
3869
+ }
3870
+
3871
+ try:
3872
+ if password:
3873
+ encrypted_payload = encrypt_conversation_data(conversation_data, password)
3874
+ result = upload_to_backend(
3875
+ encrypted_payload=encrypted_payload,
3876
+ metadata=metadata,
3877
+ backend_url=backend_url,
3878
+ ui_metadata=conversation_data.get("ui_metadata"),
3879
+ background=True,
3880
+ )
3881
+ else:
3882
+ result = upload_to_backend_unencrypted(
3883
+ conversation_data=conversation_data,
3884
+ metadata=metadata,
3885
+ backend_url=backend_url,
3886
+ background=True,
3887
+ )
3888
+ except Exception as e:
3889
+ if not json_output:
3890
+ print(f"Error: Upload failed: {e}", file=sys.stderr)
3891
+ logger.error(f"Upload failed: {e}", exc_info=True)
3892
+ return 1
3893
+
3894
+ share_url = result.get("share_url")
3895
+ slack_message = ui_metadata.get("slack_message") if ui_metadata else None
3896
+
3897
+ # Output results
3898
+ if json_output:
3899
+ output_data = {
3900
+ "agent_id": agent_id,
3901
+ "agent_name": agent_info.name,
3902
+ "share_link": share_url,
3903
+ "slack_message": slack_message,
3904
+ "session_count": len(selected_sessions),
3905
+ "password": password,
3906
+ }
3907
+ print(json.dumps(output_data, ensure_ascii=False, indent=2))
3908
+ else:
3909
+ print(f"\n✅ Shared {len(selected_sessions)} session(s) from agent: {agent_info.name}")
3910
+ print(f"🔗 Share link: {share_url}")
3911
+ if slack_message:
3912
+ print(f"\n📝 Slack message:\n{slack_message}")
3913
+ copied = _copy_share_to_clipboard(share_url, slack_message)
3914
+ if copied:
3915
+ print("📋 Copied Slack message and share link to clipboard.")
3916
+
3917
+ if not json_output:
3918
+ logger.info(f"======== Agent export completed: {share_url} ========")
3919
+ return 0
@@ -335,9 +335,28 @@ def search_command(
335
335
  context_session_ids = get_context_session_ids()
336
336
  context_event_ids = get_context_event_ids()
337
337
 
338
+ # Apply agent scoping if ALINE_AGENT_ID is set
339
+ agent_session_ids = None
340
+ if not no_context:
341
+ import os
342
+
343
+ agent_id = os.environ.get("ALINE_AGENT_ID")
344
+ if agent_id:
345
+ agent_sessions = db.get_sessions_by_agent_id(agent_id)
346
+ # Always set agent_session_ids when agent_id exists
347
+ # (empty list means no sessions for this agent -> empty results)
348
+ agent_session_ids = [s.id for s in agent_sessions]
349
+
338
350
  # Parse session IDs if provided (resolve prefixes)
339
351
  session_ids = _resolve_id_prefixes(db, "sessions", sessions) or None
340
352
 
353
+ # Intersect with agent sessions first (highest priority)
354
+ if agent_session_ids is not None:
355
+ if session_ids:
356
+ session_ids = list(set(session_ids) & set(agent_session_ids))
357
+ else:
358
+ session_ids = agent_session_ids if agent_session_ids else []
359
+
341
360
  # Intersect with context sessions
342
361
  if context_session_ids:
343
362
  if session_ids:
@@ -372,43 +391,53 @@ def search_command(
372
391
  turn_ids = _resolve_id_prefixes(db, "turns", turns) or None
373
392
 
374
393
  # 1. Search Events (events don't have session scope, skip if sessions/events filter is active)
375
- if type in ("all", "event") and not session_ids and not event_ids:
394
+ # Use 'is None' to distinguish "no filter" from "empty filter results"
395
+ if type in ("all", "event") and session_ids is None and event_ids is None:
376
396
  events = db.search_events(query, limit=limit, use_regex=regex, ignore_case=ignore_case)
377
397
  results["events"] = events
378
398
 
379
- # 2. Search Turns
399
+ # 2. Search Turns (skip if session filter results in empty list)
380
400
  if type in ("all", "turn"):
381
- turns = db.search_conversations(
382
- query,
383
- limit=limit,
384
- use_regex=regex,
385
- ignore_case=ignore_case,
386
- session_ids=session_ids,
387
- )
388
- results["turns"] = turns
401
+ if session_ids is not None and len(session_ids) == 0:
402
+ results["turns"] = []
403
+ else:
404
+ turns = db.search_conversations(
405
+ query,
406
+ limit=limit,
407
+ use_regex=regex,
408
+ ignore_case=ignore_case,
409
+ session_ids=session_ids if session_ids else None,
410
+ )
411
+ results["turns"] = turns
389
412
 
390
- # 3. Search Sessions
413
+ # 3. Search Sessions (skip if session filter results in empty list)
391
414
  if type in ("all", "session"):
392
- sessions_results = db.search_sessions(
393
- query,
394
- limit=limit,
395
- use_regex=regex,
396
- ignore_case=ignore_case,
397
- session_ids=session_ids,
398
- )
399
- results["sessions"] = sessions_results
415
+ if session_ids is not None and len(session_ids) == 0:
416
+ results["sessions"] = []
417
+ else:
418
+ sessions_results = db.search_sessions(
419
+ query,
420
+ limit=limit,
421
+ use_regex=regex,
422
+ ignore_case=ignore_case,
423
+ session_ids=session_ids if session_ids else None,
424
+ )
425
+ results["sessions"] = sessions_results
400
426
 
401
- # 4. Search Turn Content
427
+ # 4. Search Turn Content (skip if session filter results in empty list)
402
428
  if type == "content":
403
- content_results = db.search_turn_content(
404
- query,
405
- limit=limit,
406
- use_regex=regex,
407
- ignore_case=ignore_case,
408
- session_ids=session_ids,
409
- turn_ids=turn_ids,
410
- )
411
- results["content"] = content_results
429
+ if session_ids is not None and len(session_ids) == 0:
430
+ results["content"] = []
431
+ else:
432
+ content_results = db.search_turn_content(
433
+ query,
434
+ limit=limit,
435
+ use_regex=regex,
436
+ ignore_case=ignore_case,
437
+ session_ids=session_ids if session_ids else None,
438
+ turn_ids=turn_ids,
439
+ )
440
+ results["content"] = content_results
412
441
 
413
442
  # === Grep-style output for regex mode ===
414
443
  if regex: