aline-ai 0.6.2__py3-none-any.whl → 0.6.4__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 (40) hide show
  1. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/METADATA +1 -1
  2. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/RECORD +38 -37
  3. realign/__init__.py +1 -1
  4. realign/adapters/__init__.py +0 -3
  5. realign/adapters/codex.py +14 -9
  6. realign/cli.py +42 -236
  7. realign/codex_detector.py +72 -32
  8. realign/codex_home.py +85 -0
  9. realign/codex_terminal_linker.py +172 -0
  10. realign/commands/__init__.py +2 -2
  11. realign/commands/add.py +89 -9
  12. realign/commands/doctor.py +495 -0
  13. realign/commands/export_shares.py +154 -226
  14. realign/commands/init.py +66 -4
  15. realign/commands/watcher.py +30 -80
  16. realign/config.py +9 -46
  17. realign/dashboard/app.py +7 -11
  18. realign/dashboard/screens/event_detail.py +0 -3
  19. realign/dashboard/screens/session_detail.py +0 -1
  20. realign/dashboard/tmux_manager.py +129 -4
  21. realign/dashboard/widgets/config_panel.py +175 -241
  22. realign/dashboard/widgets/events_table.py +71 -128
  23. realign/dashboard/widgets/sessions_table.py +77 -136
  24. realign/dashboard/widgets/terminal_panel.py +349 -27
  25. realign/dashboard/widgets/watcher_panel.py +0 -2
  26. realign/db/sqlite_db.py +77 -2
  27. realign/events/event_summarizer.py +76 -35
  28. realign/events/session_summarizer.py +73 -32
  29. realign/hooks.py +334 -647
  30. realign/llm_client.py +201 -520
  31. realign/triggers/__init__.py +0 -2
  32. realign/triggers/next_turn_trigger.py +4 -5
  33. realign/triggers/registry.py +1 -4
  34. realign/watcher_core.py +53 -35
  35. realign/adapters/antigravity.py +0 -159
  36. realign/triggers/antigravity_trigger.py +0 -140
  37. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/WHEEL +0 -0
  38. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/entry_points.txt +0 -0
  39. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/licenses/LICENSE +0 -0
  40. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,495 @@
1
+ """Aline doctor command - Repair common issues after updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import io
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from ..config import ReAlignConfig, get_default_config_content
16
+
17
+ console = Console()
18
+
19
+
20
+ def _clear_python_cache(root: Path, *, verbose: bool) -> Tuple[int, int]:
21
+ pyc_count = 0
22
+ pycache_count = 0
23
+
24
+ for pyc_file in root.rglob("*.pyc"):
25
+ try:
26
+ pyc_file.unlink()
27
+ pyc_count += 1
28
+ if verbose:
29
+ console.print(f" [dim]Removed: {pyc_file}[/dim]")
30
+ except Exception as e:
31
+ if verbose:
32
+ console.print(f" [yellow]Failed to remove {pyc_file}: {e}[/yellow]")
33
+
34
+ for pycache_dir in root.rglob("__pycache__"):
35
+ if not pycache_dir.is_dir():
36
+ continue
37
+ try:
38
+ shutil.rmtree(pycache_dir)
39
+ pycache_count += 1
40
+ if verbose:
41
+ console.print(f" [dim]Removed: {pycache_dir}[/dim]")
42
+ except Exception as e:
43
+ if verbose:
44
+ console.print(f" [yellow]Failed to remove {pycache_dir}: {e}[/yellow]")
45
+
46
+ return pyc_count, pycache_count
47
+
48
+
49
+ def _ensure_global_config(*, force: bool, verbose: bool) -> Path:
50
+ config_path = Path.home() / ".aline" / "config.yaml"
51
+
52
+ if force or not config_path.exists():
53
+ config_path.parent.mkdir(parents=True, exist_ok=True)
54
+ config_path.write_text(get_default_config_content(), encoding="utf-8")
55
+ if verbose:
56
+ console.print(f" [dim]Wrote config: {config_path}[/dim]")
57
+
58
+ return config_path
59
+
60
+
61
+ def _ensure_database_initialized(config: ReAlignConfig, *, verbose: bool) -> Path:
62
+ db_path = Path(config.sqlite_db_path).expanduser()
63
+ db_path.parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ from ..db.sqlite_db import SQLiteDatabase
66
+
67
+ db = SQLiteDatabase(str(db_path))
68
+ ok = db.initialize()
69
+ db.close()
70
+ if verbose:
71
+ console.print(f" [dim]Database init: {'ok' if ok else 'failed'}[/dim]")
72
+
73
+ return db_path
74
+
75
+
76
+ def _update_claude_hooks(*, verbose: bool) -> Tuple[list[str], list[str]]:
77
+ hooks_updated: list[str] = []
78
+ hooks_failed: list[str] = []
79
+
80
+ # Stop hook
81
+ try:
82
+ from ..claude_hooks.stop_hook_installer import install_stop_hook, get_settings_path
83
+
84
+ if install_stop_hook(get_settings_path(), quiet=True, force=True):
85
+ hooks_updated.append("Stop")
86
+ if verbose:
87
+ console.print(" [dim]Stop hook updated[/dim]")
88
+ else:
89
+ hooks_failed.append("Stop")
90
+ except Exception as e:
91
+ hooks_failed.append("Stop")
92
+ if verbose:
93
+ console.print(f" [yellow]Stop hook failed: {e}[/yellow]")
94
+
95
+ # UserPromptSubmit hook
96
+ try:
97
+ from ..claude_hooks.user_prompt_submit_hook_installer import (
98
+ install_user_prompt_submit_hook,
99
+ get_settings_path as get_submit_settings_path,
100
+ )
101
+
102
+ if install_user_prompt_submit_hook(get_submit_settings_path(), quiet=True, force=True):
103
+ hooks_updated.append("UserPromptSubmit")
104
+ if verbose:
105
+ console.print(" [dim]UserPromptSubmit hook updated[/dim]")
106
+ else:
107
+ hooks_failed.append("UserPromptSubmit")
108
+ except Exception as e:
109
+ hooks_failed.append("UserPromptSubmit")
110
+ if verbose:
111
+ console.print(f" [yellow]UserPromptSubmit hook failed: {e}[/yellow]")
112
+
113
+ # PermissionRequest hook
114
+ try:
115
+ from ..claude_hooks.permission_request_hook_installer import (
116
+ install_permission_request_hook,
117
+ get_settings_path as get_permission_settings_path,
118
+ )
119
+
120
+ if install_permission_request_hook(get_permission_settings_path(), quiet=True, force=True):
121
+ hooks_updated.append("PermissionRequest")
122
+ if verbose:
123
+ console.print(" [dim]PermissionRequest hook updated[/dim]")
124
+ else:
125
+ hooks_failed.append("PermissionRequest")
126
+ except Exception as e:
127
+ hooks_failed.append("PermissionRequest")
128
+ if verbose:
129
+ console.print(f" [yellow]PermissionRequest hook failed: {e}[/yellow]")
130
+
131
+ return hooks_updated, hooks_failed
132
+
133
+
134
+ def _update_skills(*, verbose: bool) -> int:
135
+ from .add import add_skills_command
136
+
137
+ stdout_capture = io.StringIO()
138
+ with contextlib.redirect_stdout(stdout_capture):
139
+ add_skills_command(force=True)
140
+
141
+ output = stdout_capture.getvalue()
142
+ updated_count = output.count("✓")
143
+ if verbose and output.strip():
144
+ for line in output.strip().split("\n"):
145
+ console.print(f" [dim]{line}[/dim]")
146
+ return updated_count
147
+
148
+
149
+ def _check_failed_jobs(
150
+ config: ReAlignConfig,
151
+ *,
152
+ verbose: bool,
153
+ fix: bool,
154
+ ) -> Tuple[int, int]:
155
+ """
156
+ Check for failed turn_summary and session_summary jobs.
157
+
158
+ Returns:
159
+ (failed_count, requeued_count)
160
+ """
161
+ from ..db.sqlite_db import SQLiteDatabase
162
+
163
+ db_path = Path(config.sqlite_db_path).expanduser()
164
+ if not db_path.exists():
165
+ return 0, 0
166
+
167
+ db = SQLiteDatabase(str(db_path))
168
+
169
+ try:
170
+ # Count failed jobs in queue
171
+ failed_turn_count = db.count_jobs(kinds=["turn_summary"], statuses=["failed"])
172
+ failed_session_count = db.count_jobs(kinds=["session_summary"], statuses=["failed"])
173
+ total_failed = failed_turn_count + failed_session_count
174
+
175
+ if verbose and total_failed > 0:
176
+ # Show details of failed jobs
177
+ failed_jobs = db.list_jobs(statuses=["failed"], limit=20)
178
+ if failed_jobs:
179
+ table = Table(title="Failed Jobs in Queue", show_header=True, header_style="bold")
180
+ table.add_column("Kind", style="cyan")
181
+ table.add_column("Session", style="dim")
182
+ table.add_column("Turn", style="dim")
183
+ table.add_column("Error", style="red", max_width=40)
184
+ table.add_column("Attempts", justify="right")
185
+
186
+ for job in failed_jobs:
187
+ payload = job.get("payload", {})
188
+ session_id = str(payload.get("session_id", ""))[:8]
189
+ turn_num = str(payload.get("turn_number", "-"))
190
+ error = (job.get("last_error") or "")[:40]
191
+ attempts = str(job.get("attempts", 0))
192
+ table.add_row(job["kind"], session_id, turn_num, error, attempts)
193
+
194
+ console.print(table)
195
+
196
+ requeued = 0
197
+ if fix and total_failed > 0:
198
+ # Requeue failed jobs
199
+ requeued, _ = db.requeue_failed_jobs(kinds=["turn_summary", "session_summary"])
200
+
201
+ return total_failed, requeued
202
+
203
+ finally:
204
+ db.close()
205
+
206
+
207
+ def _check_llm_error_turns(
208
+ config: ReAlignConfig,
209
+ *,
210
+ verbose: bool,
211
+ fix: bool,
212
+ ) -> Tuple[int, int]:
213
+ """
214
+ Check for turns with LLM API errors (llm_title contains 'LLM API Error').
215
+
216
+ Returns:
217
+ (error_count, requeued_count)
218
+ """
219
+ from ..db.sqlite_db import SQLiteDatabase
220
+
221
+ db_path = Path(config.sqlite_db_path).expanduser()
222
+ if not db_path.exists():
223
+ return 0, 0
224
+
225
+ db = SQLiteDatabase(str(db_path))
226
+
227
+ try:
228
+ conn = db._get_connection()
229
+
230
+ # Find turns with LLM API Error marker (exact prefix match)
231
+ rows = conn.execute(
232
+ """
233
+ SELECT t.session_id, t.turn_number, t.llm_title, s.session_file_path, s.workspace_path
234
+ FROM turns t
235
+ JOIN sessions s ON t.session_id = s.id
236
+ WHERE t.llm_title LIKE '⚠ LLM API Error%'
237
+ ORDER BY t.timestamp DESC
238
+ """
239
+ ).fetchall()
240
+
241
+ if not rows:
242
+ return 0, 0
243
+
244
+ if verbose:
245
+ table = Table(title="Turns with LLM API Error", show_header=True, header_style="bold")
246
+ table.add_column("Session", style="dim")
247
+ table.add_column("Turn", justify="right")
248
+ table.add_column("Title", style="yellow", max_width=50)
249
+
250
+ for row in rows[:20]: # Show max 20
251
+ session_id = str(row["session_id"])[:12]
252
+ turn_num = str(row["turn_number"])
253
+ title = (row["llm_title"] or "")[:50]
254
+ table.add_row(session_id, turn_num, title)
255
+
256
+ if len(rows) > 20:
257
+ table.add_row("...", "...", f"({len(rows) - 20} more)")
258
+
259
+ console.print(table)
260
+
261
+ if not fix:
262
+ return len(rows), 0
263
+
264
+ # Requeue turn_summary jobs for these turns
265
+ requeued = 0
266
+ skipped = 0
267
+ for row in rows:
268
+ session_file_path_str = row["session_file_path"] or ""
269
+
270
+ # Skip invalid session file paths
271
+ if not session_file_path_str or session_file_path_str in (".", ".."):
272
+ if verbose:
273
+ console.print(f" [dim]Skip: invalid session path for {row['session_id'][:8]} #{row['turn_number']}[/dim]")
274
+ skipped += 1
275
+ continue
276
+
277
+ session_file_path = Path(session_file_path_str)
278
+ workspace_path = Path(row["workspace_path"]) if row["workspace_path"] else None
279
+
280
+ if not session_file_path.exists():
281
+ if verbose:
282
+ console.print(f" [dim]Skip: session file not found: {session_file_path}[/dim]")
283
+ skipped += 1
284
+ continue
285
+
286
+ try:
287
+ db.enqueue_turn_summary_job(
288
+ session_file_path=session_file_path,
289
+ workspace_path=workspace_path,
290
+ turn_number=row["turn_number"],
291
+ skip_dedup=True, # Force regeneration
292
+ )
293
+ requeued += 1
294
+ except Exception as e:
295
+ if verbose:
296
+ console.print(f" [yellow]Failed to enqueue {row['session_id'][:8]} #{row['turn_number']}: {e}[/yellow]")
297
+ skipped += 1
298
+
299
+ return len(rows), requeued
300
+
301
+ finally:
302
+ db.close()
303
+
304
+
305
+ def run_doctor(
306
+ *,
307
+ restart_daemons: bool,
308
+ start_if_not_running: bool,
309
+ verbose: bool,
310
+ clear_cache: bool,
311
+ ) -> int:
312
+ """
313
+ Core doctor logic.
314
+
315
+ Args:
316
+ restart_daemons: Restart/ensure daemons at the end.
317
+ start_if_not_running: If True and restart_daemons is True, start daemons even if not running.
318
+ verbose: Print details.
319
+ clear_cache: Clear Python bytecode cache for the installed package directory.
320
+ """
321
+ from ..auth import is_logged_in
322
+ from . import watcher as watcher_cmd
323
+ from . import worker as worker_cmd
324
+ from . import init as init_cmd
325
+
326
+ console.print("\n[bold blue]═══ Aline Doctor ═══[/bold blue]\n")
327
+
328
+ watcher_running, _watcher_pid, _watcher_mode = watcher_cmd.detect_watcher_process()
329
+ worker_running, _worker_pid, _worker_mode = worker_cmd.detect_worker_process()
330
+
331
+ can_start_daemons = is_logged_in()
332
+ if restart_daemons and not can_start_daemons:
333
+ console.print("[yellow]![/yellow] Not logged in; skipping daemon restart/start.")
334
+ console.print("[dim]Run 'aline login' then re-run 'aline doctor'.[/dim]")
335
+ restart_daemons = False
336
+
337
+ # Stop daemons early to avoid DB lock during migrations.
338
+ if restart_daemons:
339
+ if watcher_running:
340
+ if verbose:
341
+ console.print("[dim]Stopping watcher...[/dim]")
342
+ try:
343
+ watcher_cmd.watcher_stop_command()
344
+ except Exception:
345
+ pass
346
+ if worker_running:
347
+ if verbose:
348
+ console.print("[dim]Stopping worker...[/dim]")
349
+ try:
350
+ worker_cmd.worker_stop_command()
351
+ except Exception:
352
+ pass
353
+
354
+ # 1. Clear Python cache (package scope)
355
+ if clear_cache:
356
+ console.print("[bold]1. Clearing Python cache...[/bold]")
357
+ package_root = Path(__file__).resolve().parents[1]
358
+ pyc_count, pycache_count = _clear_python_cache(package_root, verbose=verbose)
359
+ console.print(
360
+ f" [green]✓[/green] Cleared {pyc_count} .pyc files, {pycache_count} __pycache__ directories"
361
+ )
362
+
363
+ # 2. Ensure global environment (config/db/prompts/tmux)
364
+ console.print("\n[bold]2. Ensuring global environment...[/bold]")
365
+ try:
366
+ config_path = _ensure_global_config(force=False, verbose=verbose)
367
+ config = ReAlignConfig.load(config_path)
368
+ db_path = _ensure_database_initialized(config, verbose=verbose)
369
+
370
+ # Prompts + tmux are safe to re-run (no overwrite for prompts; tmux auto-updates Aline-managed config).
371
+ init_cmd._initialize_prompts_directory()
372
+ tmux_conf = init_cmd._initialize_tmux_config()
373
+
374
+ console.print(f" [green]✓[/green] Config: {config_path}")
375
+ console.print(f" [green]✓[/green] Database: {db_path}")
376
+ console.print(f" [green]✓[/green] Prompts: {Path.home() / '.aline' / 'prompts'}")
377
+ console.print(f" [green]✓[/green] Tmux: {tmux_conf}")
378
+ except Exception as e:
379
+ console.print(f" [red]✗[/red] Failed to ensure global environment: {e}")
380
+ return 1
381
+
382
+ # 3. Update Claude Code hooks
383
+ console.print("\n[bold]3. Updating Claude Code hooks...[/bold]")
384
+ hooks_updated, hooks_failed = _update_claude_hooks(verbose=verbose)
385
+ if hooks_updated:
386
+ console.print(f" [green]✓[/green] Updated hooks: {', '.join(hooks_updated)}")
387
+ if hooks_failed:
388
+ console.print(f" [yellow]![/yellow] Failed hooks: {', '.join(hooks_failed)}")
389
+
390
+ # 4. Update skills
391
+ console.print("\n[bold]4. Updating skills...[/bold]")
392
+ try:
393
+ updated_count = _update_skills(verbose=verbose)
394
+ if updated_count > 0:
395
+ console.print(f" [green]✓[/green] Updated {updated_count} skill(s)")
396
+ else:
397
+ console.print(" [green]✓[/green] Skills are up to date")
398
+ except Exception as e:
399
+ console.print(f" [yellow]![/yellow] Failed to update skills: {e}")
400
+
401
+ # 5. Check/fix failed jobs and LLM error turns
402
+ console.print("\n[bold]5. Checking failed summary jobs...[/bold]")
403
+ try:
404
+ # First pass: check counts without fixing
405
+ failed_count, _ = _check_failed_jobs(config, verbose=verbose, fix=False)
406
+ llm_error_count, _ = _check_llm_error_turns(config, verbose=verbose, fix=False)
407
+
408
+ total_issues = failed_count + llm_error_count
409
+
410
+ if total_issues == 0:
411
+ console.print(" [green]✓[/green] No failed jobs or LLM errors found")
412
+ else:
413
+ # Show what was found
414
+ if failed_count > 0:
415
+ console.print(f" [yellow]![/yellow] Found {failed_count} failed job(s) in queue")
416
+ if llm_error_count > 0:
417
+ console.print(f" [yellow]![/yellow] Found {llm_error_count} turn(s) with LLM API errors")
418
+
419
+ # Ask user if they want to fix
420
+ if typer.confirm("\n Do you want to requeue these for regeneration?", default=True):
421
+ requeued_jobs = 0
422
+ requeued_turns = 0
423
+
424
+ if failed_count > 0:
425
+ _, requeued_jobs = _check_failed_jobs(config, verbose=verbose, fix=True)
426
+ if llm_error_count > 0:
427
+ _, requeued_turns = _check_llm_error_turns(config, verbose=verbose, fix=True)
428
+
429
+ total_requeued = requeued_jobs + requeued_turns
430
+ console.print(f" [green]✓[/green] Requeued {total_requeued} item(s) for regeneration")
431
+ else:
432
+ console.print(" [dim]Skipped fixing failed jobs[/dim]")
433
+ except Exception as e:
434
+ console.print(f" [yellow]![/yellow] Failed to check jobs: {e}")
435
+
436
+ # 6. Restart/ensure daemons
437
+ if restart_daemons:
438
+ console.print("\n[bold]6. Checking daemons...[/bold]")
439
+
440
+ should_start_watcher = watcher_running or start_if_not_running
441
+ should_start_worker = worker_running or start_if_not_running
442
+
443
+ if should_start_watcher:
444
+ try:
445
+ exit_code = watcher_cmd.watcher_start_command()
446
+ if exit_code == 0:
447
+ console.print(" [green]✓[/green] Watcher is running")
448
+ else:
449
+ console.print(" [yellow]![/yellow] Failed to start watcher")
450
+ except Exception as e:
451
+ console.print(f" [yellow]![/yellow] Failed to start watcher: {e}")
452
+ else:
453
+ console.print(" [dim]Watcher was not running; leaving it stopped.[/dim]")
454
+
455
+ if should_start_worker:
456
+ try:
457
+ exit_code = worker_cmd.worker_start_command()
458
+ if exit_code == 0:
459
+ console.print(" [green]✓[/green] Worker is running")
460
+ else:
461
+ console.print(" [yellow]![/yellow] Failed to start worker")
462
+ except Exception as e:
463
+ console.print(f" [yellow]![/yellow] Failed to start worker: {e}")
464
+ else:
465
+ console.print(" [dim]Worker was not running; leaving it stopped.[/dim]")
466
+ else:
467
+ console.print("\n[dim]Skipping daemon restart (--no-restart)[/dim]")
468
+
469
+ console.print("\n[green]Done![/green] Aline is ready with the latest code.")
470
+ return 0
471
+
472
+
473
+ def doctor_command(
474
+ no_restart: bool = typer.Option(False, "--no-restart", help="Only repair files, don't restart daemons"),
475
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
476
+ ):
477
+ """
478
+ Fix common issues after code updates.
479
+
480
+ This command:
481
+ - Clears Python bytecode cache for the installed Aline package
482
+ - Ensures global config/DB/prompts/tmux are present and up to date
483
+ - Updates Claude Code hooks (Stop, UserPromptSubmit, PermissionRequest)
484
+ - Updates Claude Code skills to the latest version
485
+ - Checks for failed summary jobs (prompts to fix if found)
486
+ - Restarts watcher/worker (default) so long-running processes use the latest code
487
+ """
488
+ exit_code = run_doctor(
489
+ restart_daemons=not no_restart,
490
+ start_if_not_running=True,
491
+ verbose=verbose,
492
+ clear_cache=True,
493
+ )
494
+ raise typer.Exit(code=exit_code)
495
+