aline-ai 0.5.4__py3-none-any.whl → 0.5.5__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 (79) hide show
  1. {aline_ai-0.5.4.dist-info → aline_ai-0.5.5.dist-info}/METADATA +1 -1
  2. aline_ai-0.5.5.dist-info/RECORD +93 -0
  3. realign/__init__.py +1 -1
  4. realign/adapters/antigravity.py +28 -20
  5. realign/adapters/base.py +46 -50
  6. realign/adapters/claude.py +14 -14
  7. realign/adapters/codex.py +7 -7
  8. realign/adapters/gemini.py +11 -11
  9. realign/adapters/registry.py +14 -10
  10. realign/claude_detector.py +2 -2
  11. realign/claude_hooks/__init__.py +3 -3
  12. realign/claude_hooks/permission_request_hook_installer.py +31 -32
  13. realign/claude_hooks/stop_hook.py +4 -1
  14. realign/claude_hooks/stop_hook_installer.py +30 -31
  15. realign/cli.py +7 -0
  16. realign/codex_detector.py +11 -11
  17. realign/commands/add.py +88 -65
  18. realign/commands/config.py +3 -12
  19. realign/commands/context.py +3 -1
  20. realign/commands/export_shares.py +86 -127
  21. realign/commands/import_shares.py +145 -155
  22. realign/commands/init.py +166 -30
  23. realign/commands/restore.py +18 -6
  24. realign/commands/search.py +14 -42
  25. realign/commands/upgrade.py +155 -11
  26. realign/commands/watcher.py +98 -219
  27. realign/commands/worker.py +29 -6
  28. realign/config.py +25 -20
  29. realign/context.py +1 -3
  30. realign/dashboard/app.py +4 -4
  31. realign/dashboard/screens/create_event.py +3 -1
  32. realign/dashboard/screens/event_detail.py +14 -6
  33. realign/dashboard/screens/session_detail.py +3 -1
  34. realign/dashboard/screens/share_import.py +7 -3
  35. realign/dashboard/tmux_manager.py +54 -9
  36. realign/dashboard/widgets/config_panel.py +85 -1
  37. realign/dashboard/widgets/events_table.py +3 -1
  38. realign/dashboard/widgets/header.py +1 -0
  39. realign/dashboard/widgets/search_panel.py +37 -27
  40. realign/dashboard/widgets/sessions_table.py +24 -15
  41. realign/dashboard/widgets/terminal_panel.py +66 -22
  42. realign/dashboard/widgets/watcher_panel.py +6 -2
  43. realign/dashboard/widgets/worker_panel.py +10 -1
  44. realign/db/__init__.py +1 -1
  45. realign/db/base.py +5 -15
  46. realign/db/locks.py +0 -1
  47. realign/db/migration.py +82 -76
  48. realign/db/schema.py +2 -6
  49. realign/db/sqlite_db.py +23 -41
  50. realign/events/__init__.py +0 -1
  51. realign/events/event_summarizer.py +27 -15
  52. realign/events/session_summarizer.py +29 -15
  53. realign/file_lock.py +1 -0
  54. realign/hooks.py +150 -60
  55. realign/logging_config.py +12 -15
  56. realign/mcp_server.py +30 -51
  57. realign/mcp_watcher.py +0 -1
  58. realign/models/event.py +29 -20
  59. realign/prompts/__init__.py +7 -7
  60. realign/prompts/presets.py +15 -11
  61. realign/redactor.py +99 -59
  62. realign/triggers/__init__.py +9 -9
  63. realign/triggers/antigravity_trigger.py +30 -28
  64. realign/triggers/base.py +4 -3
  65. realign/triggers/claude_trigger.py +104 -85
  66. realign/triggers/codex_trigger.py +15 -5
  67. realign/triggers/gemini_trigger.py +57 -47
  68. realign/triggers/next_turn_trigger.py +3 -1
  69. realign/triggers/registry.py +6 -2
  70. realign/triggers/turn_status.py +3 -1
  71. realign/watcher_core.py +306 -131
  72. realign/watcher_daemon.py +8 -8
  73. realign/worker_core.py +3 -1
  74. realign/worker_daemon.py +3 -1
  75. aline_ai-0.5.4.dist-info/RECORD +0 -93
  76. {aline_ai-0.5.4.dist-info → aline_ai-0.5.5.dist-info}/WHEEL +0 -0
  77. {aline_ai-0.5.4.dist-info → aline_ai-0.5.5.dist-info}/entry_points.txt +0 -0
  78. {aline_ai-0.5.4.dist-info → aline_ai-0.5.5.dist-info}/licenses/LICENSE +0 -0
  79. {aline_ai-0.5.4.dist-info → aline_ai-0.5.5.dist-info}/top_level.txt +0 -0
@@ -115,7 +115,9 @@ class SearchPanel(Static):
115
115
  session_id = result.get("session_id") or ""
116
116
  turn_id = result.get("turn_id") or ""
117
117
  if session_id:
118
- self.app.push_screen(SessionDetailScreen(session_id, initial_turn_id=turn_id or None))
118
+ self.app.push_screen(
119
+ SessionDetailScreen(session_id, initial_turn_id=turn_id or None)
120
+ )
119
121
  return
120
122
 
121
123
  def on_input_submitted(self, event: Input.Submitted) -> None:
@@ -156,30 +158,32 @@ class SearchPanel(Static):
156
158
 
157
159
  # Search based on type
158
160
  if self._search_type in ("all", "event"):
159
- events = db.search_events(
160
- self._query, limit=20, use_regex=True, ignore_case=True
161
- )
161
+ events = db.search_events(self._query, limit=20, use_regex=True, ignore_case=True)
162
162
  for event in events:
163
- self._results.append({
164
- "type": "Event",
165
- "id": self._shorten_id(event.id),
166
- "full_id": event.id,
167
- "title": (event.title or "(no title)")[:60],
168
- "context": event.event_type or "-",
169
- })
163
+ self._results.append(
164
+ {
165
+ "type": "Event",
166
+ "id": self._shorten_id(event.id),
167
+ "full_id": event.id,
168
+ "title": (event.title or "(no title)")[:60],
169
+ "context": event.event_type or "-",
170
+ }
171
+ )
170
172
 
171
173
  if self._search_type in ("all", "session"):
172
174
  sessions = db.search_sessions(
173
175
  self._query, limit=20, use_regex=True, ignore_case=True
174
176
  )
175
177
  for session in sessions:
176
- self._results.append({
177
- "type": "Session",
178
- "id": self._shorten_id(session.id),
179
- "full_id": session.id,
180
- "title": (session.session_title or "(no title)")[:60],
181
- "context": session.session_type or "-",
182
- })
178
+ self._results.append(
179
+ {
180
+ "type": "Session",
181
+ "id": self._shorten_id(session.id),
182
+ "full_id": session.id,
183
+ "title": (session.session_title or "(no title)")[:60],
184
+ "context": session.session_type or "-",
185
+ }
186
+ )
183
187
 
184
188
  if self._search_type in ("all", "turn"):
185
189
  turns = db.search_conversations(
@@ -188,14 +192,18 @@ class SearchPanel(Static):
188
192
  for turn in turns:
189
193
  session_id = turn.get("session_id", "")
190
194
  turn_id = turn.get("turn_id", "")
191
- self._results.append({
192
- "type": "Turn",
193
- "id": self._shorten_id(turn_id),
194
- "turn_id": turn_id,
195
- "session_id": session_id,
196
- "title": (turn.get("title") or turn.get("summary") or "(no title)")[:60],
197
- "context": f"{self._shorten_id(session_id)} Turn #{turn.get('turn_number', '-')}",
198
- })
195
+ self._results.append(
196
+ {
197
+ "type": "Turn",
198
+ "id": self._shorten_id(turn_id),
199
+ "turn_id": turn_id,
200
+ "session_id": session_id,
201
+ "title": (turn.get("title") or turn.get("summary") or "(no title)")[
202
+ :60
203
+ ],
204
+ "context": f"{self._shorten_id(session_id)} • Turn #{turn.get('turn_number', '-')}",
205
+ }
206
+ )
199
207
 
200
208
  self._update_display()
201
209
 
@@ -232,7 +240,9 @@ class SearchPanel(Static):
232
240
 
233
241
  summary_text = f"Found {len(self._results)} results for '{self._query}'"
234
242
  if self._search_type == "all":
235
- summary_text += f" (Events: {event_count}, Sessions: {session_count}, Turns: {turn_count})"
243
+ summary_text += (
244
+ f" (Events: {event_count}, Sessions: {session_count}, Turns: {turn_count})"
245
+ )
236
246
 
237
247
  summary.update(f"[dim]{summary_text}[/dim]")
238
248
  else:
@@ -461,14 +461,16 @@ class SessionsTable(Container, can_focus=True):
461
461
  total_sessions = int(row[0]) if row else 0
462
462
 
463
463
  # Get stats
464
- stats_row = conn.execute("""
464
+ stats_row = conn.execute(
465
+ """
465
466
  SELECT
466
467
  COUNT(*) AS total,
467
468
  COUNT(CASE WHEN session_type = 'claude' THEN 1 END) AS claude,
468
469
  COUNT(CASE WHEN session_type = 'codex' THEN 1 END) AS codex,
469
470
  COUNT(CASE WHEN session_type = 'gemini' THEN 1 END) AS gemini
470
471
  FROM sessions
471
- """).fetchone()
472
+ """
473
+ ).fetchone()
472
474
 
473
475
  stats = {
474
476
  "total": stats_row[0] if stats_row else 0,
@@ -479,7 +481,8 @@ class SessionsTable(Container, can_focus=True):
479
481
 
480
482
  # Get paginated sessions
481
483
  offset = (int(page) - 1) * int(rows_per_page)
482
- rows = conn.execute("""
484
+ rows = conn.execute(
485
+ """
483
486
  SELECT
484
487
  s.id,
485
488
  s.session_type,
@@ -490,7 +493,9 @@ class SessionsTable(Container, can_focus=True):
490
493
  FROM sessions s
491
494
  ORDER BY s.last_activity_at DESC
492
495
  LIMIT ? OFFSET ?
493
- """, (int(rows_per_page), int(offset))).fetchall()
496
+ """,
497
+ (int(rows_per_page), int(offset)),
498
+ ).fetchall()
494
499
 
495
500
  for i, row in enumerate(rows):
496
501
  session_id = row[0]
@@ -517,16 +522,18 @@ class SessionsTable(Container, can_focus=True):
517
522
  except Exception:
518
523
  activity_str = last_activity
519
524
 
520
- sessions.append({
521
- "index": offset + i + 1,
522
- "id": session_id,
523
- "short_id": self._shorten_session_id(session_id),
524
- "source": source,
525
- "project": project,
526
- "turns": turn_count,
527
- "title": title,
528
- "last_activity": activity_str,
529
- })
525
+ sessions.append(
526
+ {
527
+ "index": offset + i + 1,
528
+ "id": session_id,
529
+ "short_id": self._shorten_session_id(session_id),
530
+ "source": source,
531
+ "project": project,
532
+ "turns": turn_count,
533
+ "title": title,
534
+ "last_activity": activity_str,
535
+ }
536
+ )
530
537
  except Exception:
531
538
  total_sessions = 0
532
539
  stats = {}
@@ -553,7 +560,9 @@ class SessionsTable(Container, can_focus=True):
553
560
  selected_session_id: Optional[str] = None
554
561
  try:
555
562
  if table.row_count > 0:
556
- selected_session_id = str(table.coordinate_to_cell_key(table.cursor_coordinate)[0].value)
563
+ selected_session_id = str(
564
+ table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
565
+ )
557
566
  except Exception:
558
567
  selected_session_id = None
559
568
  if self.wrap_mode != self._last_wrap_mode:
@@ -61,8 +61,7 @@ class _SignalFileWatcher:
61
61
  try:
62
62
  if PERMISSION_SIGNAL_DIR.exists():
63
63
  self._seen_files = {
64
- f.name for f in PERMISSION_SIGNAL_DIR.iterdir()
65
- if f.suffix == ".signal"
64
+ f.name for f in PERMISSION_SIGNAL_DIR.iterdir() if f.suffix == ".signal"
66
65
  }
67
66
  except Exception:
68
67
  self._seen_files = set()
@@ -89,8 +88,7 @@ class _SignalFileWatcher:
89
88
 
90
89
  # Check for new signal files
91
90
  current_files = {
92
- f.name for f in PERMISSION_SIGNAL_DIR.iterdir()
93
- if f.suffix == ".signal"
91
+ f.name for f in PERMISSION_SIGNAL_DIR.iterdir() if f.suffix == ".signal"
94
92
  }
95
93
  new_files = current_files - self._seen_files
96
94
 
@@ -115,7 +113,7 @@ class _SignalFileWatcher:
115
113
  files = sorted(
116
114
  PERMISSION_SIGNAL_DIR.glob("*.signal"),
117
115
  key=lambda f: f.stat().st_mtime,
118
- reverse=True
116
+ reverse=True,
119
117
  )
120
118
  # Keep only the 10 most recent
121
119
  for f in files[10:]:
@@ -132,6 +130,7 @@ class TerminalPanel(Container, can_focus=True):
132
130
 
133
131
  class PermissionRequestDetected(Message):
134
132
  """Posted when a new permission request signal file is detected."""
133
+
135
134
  pass
136
135
 
137
136
  DEFAULT_CSS = """
@@ -295,14 +294,32 @@ class TerminalPanel(Container, can_focus=True):
295
294
  def compose(self) -> ComposeResult:
296
295
  controls_enabled = self.supported()
297
296
  with Horizontal(classes="summary"):
298
- yield Button("+ Claude", id="new-cc", variant="primary", disabled=not controls_enabled)
299
- yield Button("+ Codex", id="new-codex", variant="primary", disabled=not controls_enabled)
297
+ yield Button(
298
+ "+ Claude",
299
+ id="new-cc",
300
+ variant="primary",
301
+ disabled=not controls_enabled,
302
+ )
303
+ yield Button(
304
+ "+ Codex",
305
+ id="new-codex",
306
+ variant="primary",
307
+ disabled=not controls_enabled,
308
+ )
309
+ yield Button(
310
+ "+ Opencode",
311
+ id="new-opencode",
312
+ variant="primary",
313
+ disabled=not controls_enabled,
314
+ )
300
315
  yield Button("+ zsh", id="new-zsh", variant="primary", disabled=not controls_enabled)
301
316
  yield Button("↻", id="refresh")
302
317
  yield Static("", id="status", classes="status")
303
318
  with Vertical(id="terminals", classes="list"):
304
319
  if controls_enabled:
305
- yield Static("No terminals yet. Click 'New cc' / 'New codex' to open the right pane.")
320
+ yield Static(
321
+ "No terminals yet. Click 'New cc' / 'New codex' to open the right pane."
322
+ )
306
323
  else:
307
324
  yield Static(self._support_message())
308
325
 
@@ -385,10 +402,16 @@ class TerminalPanel(Container, can_focus=True):
385
402
  for w in windows:
386
403
  if not self._is_claude_window(w) or not w.context_id:
387
404
  continue
388
- session_ids, session_count, event_count = self._get_loaded_context_info(w.context_id)
405
+ session_ids, session_count, event_count = self._get_loaded_context_info(
406
+ w.context_id
407
+ )
389
408
  if not session_ids and session_count == 0 and event_count == 0:
390
409
  continue
391
- context_info_by_context_id[w.context_id] = (session_ids, session_count, event_count)
410
+ context_info_by_context_id[w.context_id] = (
411
+ session_ids,
412
+ session_count,
413
+ event_count,
414
+ )
392
415
  all_context_session_ids.update(session_ids)
393
416
 
394
417
  if all_context_session_ids:
@@ -536,12 +559,7 @@ class TerminalPanel(Container, can_focus=True):
536
559
  )
537
560
  )
538
561
 
539
- if (
540
- w.active
541
- and self._is_claude_window(w)
542
- and w.context_id
543
- and expanded
544
- ):
562
+ if w.active and self._is_claude_window(w) and w.context_id and expanded:
545
563
  ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
546
564
  await container.mount(ctx)
547
565
  if loaded_ids:
@@ -600,7 +618,9 @@ class TerminalPanel(Container, can_focus=True):
600
618
  loaded_count = raw_sessions + raw_events
601
619
  detail_line = f"{detail_line} | loaded context: {loaded_count}"
602
620
  else:
603
- detail_line = f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
621
+ detail_line = (
622
+ f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
623
+ )
604
624
  details.append(detail_line, style="dim not bold")
605
625
  return details
606
626
 
@@ -631,7 +651,7 @@ class TerminalPanel(Container, can_focus=True):
631
651
  # Use osascript to invoke macOS native folder picker
632
652
  default_path_escaped = default_path.replace('"', '\\"')
633
653
  prompt_escaped = prompt.replace('"', '\\"')
634
- script = f'''
654
+ script = f"""
635
655
  set defaultFolder to POSIX file "{default_path_escaped}" as alias
636
656
  try
637
657
  set selectedFolder to choose folder with prompt "{prompt_escaped}" default location defaultFolder
@@ -639,7 +659,7 @@ class TerminalPanel(Container, can_focus=True):
639
659
  on error
640
660
  return ""
641
661
  end try
642
- '''
662
+ """
643
663
  try:
644
664
  proc = await asyncio.get_event_loop().run_in_executor(
645
665
  None,
@@ -716,7 +736,9 @@ class TerminalPanel(Container, can_focus=True):
716
736
  ok_global_permission = ensure_permission_request_hook_installed(quiet=True)
717
737
 
718
738
  project_root = Path(workspace)
719
- ok_project_stop = install_stop_hook(get_stop_settings_path(project_root), quiet=True)
739
+ ok_project_stop = install_stop_hook(
740
+ get_stop_settings_path(project_root), quiet=True
741
+ )
720
742
  ok_project_submit = install_user_prompt_submit_hook(
721
743
  get_submit_settings_path(project_root), quiet=True
722
744
  )
@@ -725,8 +747,12 @@ class TerminalPanel(Container, can_focus=True):
725
747
  )
726
748
 
727
749
  all_hooks_ok = (
728
- ok_global_stop and ok_global_submit and ok_global_permission
729
- and ok_project_stop and ok_project_submit and ok_project_permission
750
+ ok_global_stop
751
+ and ok_global_submit
752
+ and ok_global_permission
753
+ and ok_project_stop
754
+ and ok_project_submit
755
+ and ok_project_permission
730
756
  )
731
757
  if not all_hooks_ok:
732
758
  self.app.notify(
@@ -766,6 +792,24 @@ class TerminalPanel(Container, can_focus=True):
766
792
  await self.refresh_data()
767
793
  return
768
794
 
795
+ if button_id == "new-opencode":
796
+ workspace = await self._select_workspace("Select workspace for Opencode")
797
+ if not workspace:
798
+ return
799
+
800
+ command = self._command_in_directory(
801
+ tmux_manager.zsh_run_and_keep_open("opencode"), workspace
802
+ )
803
+ created = tmux_manager.create_inner_window("opencode", command)
804
+ if not created:
805
+ self.app.notify(
806
+ "Failed to open opencode terminal",
807
+ title="Terminal",
808
+ severity="error",
809
+ )
810
+ await self.refresh_data()
811
+ return
812
+
769
813
  if button_id == "new-zsh":
770
814
  created = tmux_manager.create_inner_window("zsh", "zsh")
771
815
  if not created:
@@ -389,7 +389,9 @@ class WatcherPanel(Container, can_focus=True):
389
389
  except Exception:
390
390
  return []
391
391
 
392
- def _collect_recent_sessions_page(self, *, page: int, rows_per_page: int) -> tuple[list[dict], int]:
392
+ def _collect_recent_sessions_page(
393
+ self, *, page: int, rows_per_page: int
394
+ ) -> tuple[list[dict], int]:
393
395
  """Collect one page of recent sessions from the database."""
394
396
  try:
395
397
  from ...db import get_database
@@ -477,7 +479,9 @@ class WatcherPanel(Container, can_focus=True):
477
479
  if exists:
478
480
  paths_lines.append(f" [green]●[/green] {name}: {path}")
479
481
  else:
480
- paths_lines.append(f" [yellow]○[/yellow] {name}: {path} [dim](not found)[/dim]")
482
+ paths_lines.append(
483
+ f" [yellow]○[/yellow] {name}: {path} [dim](not found)[/dim]"
484
+ )
481
485
  else:
482
486
  paths_lines.append(f" [dim]○ {name}: {path} (disabled)[/dim]")
483
487
  paths_widget.update("\n".join(paths_lines))
@@ -483,7 +483,16 @@ class WorkerPanel(Container, can_focus=True):
483
483
  ("Failed", failed, "red"),
484
484
  ]:
485
485
  bar_width = int((count / total) * max_width) if total > 0 else 0
486
- bar = "[" + color + "]" + ("█" * bar_width) + "[/" + color + "]" + ("░" * (max_width - bar_width))
486
+ bar = (
487
+ "["
488
+ + color
489
+ + "]"
490
+ + ("█" * bar_width)
491
+ + "[/"
492
+ + color
493
+ + "]"
494
+ + ("░" * (max_width - bar_width))
495
+ )
487
496
  lines.append(f" {label:<12} {bar} {count}")
488
497
 
489
498
  return "\n".join(lines)
realign/db/__init__.py CHANGED
@@ -20,7 +20,7 @@ def get_database(
20
20
  blocking CLI commands under worker/watcher write load.
21
21
  """
22
22
  global _DB_INSTANCE
23
-
23
+
24
24
  # Resolution order:
25
25
  # 1) Env override (tests/ops): REALIGN_SQLITE_DB_PATH or REALIGN_DB_PATH (legacy)
26
26
  # 2) Config: ~/.aline/config.yaml (sqlite_db_path)
realign/db/base.py CHANGED
@@ -121,9 +121,7 @@ class DatabaseInterface(ABC):
121
121
  pass
122
122
 
123
123
  @abstractmethod
124
- def get_or_create_project(
125
- self, path: Path, name: Optional[str] = None
126
- ) -> ProjectRecord:
124
+ def get_or_create_project(self, path: Path, name: Optional[str] = None) -> ProjectRecord:
127
125
  """Get existing project or create new one."""
128
126
  pass
129
127
 
@@ -141,9 +139,7 @@ class DatabaseInterface(ABC):
141
139
  pass
142
140
 
143
141
  @abstractmethod
144
- def update_session_activity(
145
- self, session_id: str, last_activity_at: datetime
146
- ) -> None:
142
+ def update_session_activity(self, session_id: str, last_activity_at: datetime) -> None:
147
143
  """Update last activity timestamp for a session."""
148
144
  pass
149
145
 
@@ -212,16 +208,12 @@ class DatabaseInterface(ABC):
212
208
  pass
213
209
 
214
210
  @abstractmethod
215
- def get_turn_by_hash(
216
- self, session_id: str, content_hash: str
217
- ) -> Optional[TurnRecord]:
211
+ def get_turn_by_hash(self, session_id: str, content_hash: str) -> Optional[TurnRecord]:
218
212
  """Check if a turn with this content hash already exists in the session."""
219
213
  pass
220
214
 
221
215
  @abstractmethod
222
- def get_turn_by_number(
223
- self, session_id: str, turn_number: int
224
- ) -> Optional[TurnRecord]:
216
+ def get_turn_by_number(self, session_id: str, turn_number: int) -> Optional[TurnRecord]:
225
217
  """Get a turn by session_id and turn_number."""
226
218
  pass
227
219
 
@@ -379,9 +371,7 @@ class DatabaseInterface(ABC):
379
371
  pass
380
372
 
381
373
  @abstractmethod
382
- def try_acquire_lock(
383
- self, lock_key: str, *, owner: str, ttl_seconds: float
384
- ) -> bool:
374
+ def try_acquire_lock(self, lock_key: str, *, owner: str, ttl_seconds: float) -> bool:
385
375
  """Try to acquire a cross-process lease lock."""
386
376
  pass
387
377
 
realign/db/locks.py CHANGED
@@ -65,4 +65,3 @@ def lease_lock(
65
65
  db.release_lock(lock_key, owner=lock_owner)
66
66
  except Exception:
67
67
  pass
68
-