From cf2481590c00acc4811d7a6c8d66b99a292af0be Mon Sep 17 00:00:00 2001 From: Shamim Rehman Date: Tue, 17 Feb 2026 14:58:09 -0500 Subject: [PATCH] improve onboarding and portability defaults --- .env.example | 12 ++ .github/ISSUE_TEMPLATE/bug_report.yml | 59 ++++++ .github/ISSUE_TEMPLATE/feature_request.yml | 32 ++++ .github/pull_request_template.md | 27 +++ ARCHITECTURE.md | 51 +++++ CONTRIBUTING.md | 41 ++++ README.md | 74 +++++-- src/cli.py | 185 +++++++++++++++++- src/config.py | 213 +++++++++++++++++++-- src/dashboard.html | 50 +++++ src/model_manager.py | 126 +++++++++++- src/report_generator.py | 50 ++++- src/runner.py | 8 +- src/server.py | 16 +- src/task_queue.py | 38 ++++ tests/test_nightshift_core.py | 86 ++++++++- 16 files changed, 1009 insertions(+), 59 deletions(-) create mode 100644 .env.example create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 ARCHITECTURE.md create mode 100644 CONTRIBUTING.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6e624e5 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Optional: override where Nightshift stores sqlite/reports/state +NIGHTSHIFT_DATA_DIR=~/.nightshift + +# Optional: explicit config file path +# NIGHTSHIFT_CONFIG_FILE=~/.nightshift/config.toml + +# Optional: project alias path overrides (examples) +# NIGHTSHIFT_PROJECT_OPSORCHESTRA=~/Projects/opsorchestra +# NIGHTSHIFT_PROJECT_GHOST_SENTRY=~/Projects/anor/ghost-sentry + +# Optional: notifications +# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d59a2c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,59 @@ +name: Bug report +description: Report a bug in Nightshift runtime, API, plugin, or docs. +title: "[bug] " +labels: ["bug"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What happened? + placeholder: Clear description of the issue. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Exact commands/actions to reproduce. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + + - type: textarea + id: env + attributes: + label: Environment + description: Include OS, Python, OpenCode, and install method. + placeholder: | + - OS: + - Python: + - OpenCode: + - Nightshift: + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs / output + description: Paste relevant logs, stack traces, and command output. + render: shell diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0f8d448 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,32 @@ +name: Feature request +description: Propose an improvement for users or contributors. +title: "[feature] " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem statement + description: What user pain does this solve? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What should be added or changed? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + + - type: textarea + id: impact + attributes: + label: Impact + description: Who benefits and how? + placeholder: Users, contributors, maintainers, etc. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ca1c762 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +## Summary + +Describe what changed and why. + +## Problem + +What user/contributor issue does this PR address? + +## Root Cause + +What caused the issue? + +## Fix + +How does this PR resolve it? + +## Validation + +List checks run locally: + +- [ ] `python -m pytest -q` +- [ ] `python -m compileall -q src tests` +- [ ] `cd plugin && bun run typecheck` + +## Notes + +Any follow-ups, risks, or migration notes. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..a4af221 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,51 @@ +# Architecture + +Nightshift has three primary layers: + +1. `src/cli.py`: local command interface (`nightshift ...`). +2. `src/server.py`: FastAPI server and dashboard/API bridge. +3. `src/runner.py` + `src/task_queue.py`: execution loop and persistence. + +## Runtime Flow + +1. A run is started via CLI (`nightshift start ...`) or API (`POST /start`). +2. `NightshiftRunner` creates a run in sqlite and generates task rows. +3. Tasks are prioritized (`SmartPrioritizer`) and executed in order. +4. Agent calls are sent through `OpencodeAgentClient`. +5. Findings/errors are persisted in sqlite and rendered into reports. +6. Report and diff artifacts are written under `~/.nightshift/reports`. + +## Persistence Model + +- Database: `~/.nightshift/nightshift.db` (or `NIGHTSHIFT_DATA_DIR` override). +- Core tables: + - `runs` + - `tasks` (scoped by `run_id`) + - `findings` + +Run scoping is important: status, reports, and diffs should query by run context to avoid cross-run leakage. + +## Configuration Sources + +Order of precedence: + +1. Explicit command/API arguments +2. Environment variables +3. `config.toml` (`~/.nightshift/config.toml` by default) +4. Built-in defaults + +Key config behavior is implemented in `src/config.py`. + +## Model Selection + +`src/model_manager.py` resolves the failover chain by: + +1. Taking configured preferred models. +2. Discovering currently available OpenCode models (`opencode models`). +3. Keeping preferred models that exist. +4. Falling back to a ranked discovered chain when needed. + +## Frontend + +`src/dashboard.html` is a static UI served from `/` by FastAPI. +It polls `/status`, `/models`, `/reports`, and `/schedules`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5cbd69f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing To Nightshift + +Thanks for contributing. + +## Local Setup + +```bash +git clone +cd nightshift +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -U pip +python -m pip install -e '.[dev]' +cd plugin && bun install && cd .. +``` + +## Recommended First Run + +```bash +nightshift init +nightshift doctor +``` + +## Development Commands + +```bash +python -m pytest -q +python -m compileall -q src tests +cd plugin && bun run typecheck +``` + +## Pull Requests + +- Keep PRs focused and small when possible. +- Include validation notes (tests/checks run). +- Update docs when behavior or interfaces change. +- Prefer portable paths and avoid user-specific absolute paths. + +## Branch Naming + +- Suggested: `codex/` for feature branches. diff --git a/README.md b/README.md index 469fe22..49dc878 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,14 @@ Nightshift is an overnight autonomous research agent designed for **OpenCode**. ## Features - **Autonomous Research**: Deep dives into codebases without manual intervention. -- **Multi-Model Failover**: Intelligent failover chain across Claude Opus, GPT-5, and Gemini models to bypass rate limits and optimize performance. +- **Multi-Model Failover**: Automatically discovers available OpenCode models and builds a resilient fallback chain. - **Comprehensive Reports**: Generates detailed HTML research reports and differential reports comparing changes between runs. - **OpenCode Integration**: A dedicated plugin that lets you control Nightshift directly from your editor. - **Smart Scheduling**: Built-in support for daily research tasks using cron or macOS launchd. - **GitHub Integration**: Automatically creates issues for critical findings. - **Notifications**: Real-time alerts via Slack or custom webhooks. - **Web Dashboard**: Interactive dashboard for monitoring runs and viewing findings. +- **Setup UX**: Built-in `nightshift init` and `nightshift doctor` commands for first-run setup and troubleshooting. ## Installation @@ -40,17 +41,27 @@ To load the plugin in OpenCode, point your plugin configuration to the `plugin/i ## Quick Start -1. **Start the API Server**: +1. **Initialize local config**: + ```bash + nightshift init + ``` + +2. **Validate setup**: + ```bash + nightshift doctor + ``` + +3. **Start the API Server**: ```bash nightshift serve ``` -2. **Run a Research Task**: +4. **Run a Research Task**: ```bash - nightshift start opsorchestra ghost-sentry --duration 8.0 + nightshift start opsorchestra --duration 8.0 ``` -3. **View the Report**: +5. **View the Report**: ```bash nightshift report ``` @@ -60,6 +71,8 @@ To load the plugin in OpenCode, point your plugin configuration to the `plugin/i The `nightshift` command provides several subcommands: - `start [PROJECTS]...`: Start a research run on specified projects or paths. +- `init`: Create a starter `config.toml` in your Nightshift data directory. +- `doctor`: Validate OpenCode/GitHub/config/dependency setup and show fix hints. - `serve`: Start the HTTP API server (default port: 7890). - `status`: Show current run status and model availability. - `report`: Open the latest research report in your browser. @@ -109,18 +122,50 @@ curl http://127.0.0.1:7890/status Nightshift stores its data in `~/.nightshift`. +### Config File + +Nightshift reads optional user config from: + +- `~/.nightshift/config.toml` +- or `NIGHTSHIFT_CONFIG_FILE` if set + +Create a starter config with: + +```bash +nightshift init +``` + +Example: + +```toml +[defaults] +duration_hours = 8.0 +priority_mode = "balanced" +open_report_in_browser = true + +[projects] +backend = "/path/to/backend" +frontend = "/path/to/frontend" + +[models] +preferred = ["openai/gpt-5.2", "google/antigravity-gemini-3-pro-high"] +``` + ### Environment Variables - `NIGHTSHIFT_DATA_DIR`: Override the default data directory. +- `NIGHTSHIFT_CONFIG_FILE`: Override the default config path. - `NIGHTSHIFT_PROJECT_OPSORCHESTRA`: Override default path for the `opsorchestra` alias. - `NIGHTSHIFT_PROJECT_GHOST_SENTRY`: Override default path for the `ghost-sentry` alias. - `SLACK_WEBHOOK_URL`: Default webhook for notifications. -### Model Failover Chain -Nightshift automatically cycles through the following models: -1. Claude 4.5 Thinking (High) -2. GPT-5.2 -3. Gemini 3 Pro (High) -4. Gemini 3 Flash +See `.env.example` for a complete starter environment file. + +### Model Selection + +Nightshift tries to: +1. Use preferred models from `config.toml` (if set). +2. Keep only models available in your current OpenCode environment. +3. Auto-rank discovered models when preferred models are unavailable. ## Architecture @@ -129,5 +174,12 @@ Nightshift consists of three main components: 2. **API Server (FastAPI)**: Provides a bridge between the core engine and external tools. 3. **OpenCode Plugin (TypeScript)**: Exposes research tools directly within the developer environment. +For deeper implementation details, see `ARCHITECTURE.md`. + +## Contributing + +Forks and external contributions are welcome. +See `CONTRIBUTING.md` for local setup, dev commands, and PR expectations. + --- Built for autonomous engineering. diff --git a/src/cli.py b/src/cli.py index 18fe6df..d794f5f 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,6 +1,10 @@ import typer from pathlib import Path from typing import Optional +from dataclasses import dataclass +import subprocess +import shutil +import tempfile from rich.console import Console from rich.table import Table @@ -8,21 +12,29 @@ console = Console() +@dataclass +class DoctorCheck: + name: str + status: str + details: str + action: str = "" + + @app.command() def start( projects: list[str] = typer.Argument( ..., help="Project names or paths to analyze (e.g., opsorchestra ghost-sentry)" ), - duration: float = typer.Option( - 8.0, + duration: Optional[float] = typer.Option( + None, "--duration", "-d", - help="Maximum duration in hours" + help="Maximum duration in hours (defaults to config file value if set)" ), - priority_mode: str = typer.Option( - "balanced", + priority_mode: Optional[str] = typer.Option( + None, "--priority-mode", "-m", - help="Task prioritization mode: balanced, security_first, research_heavy, quick_scan", + help="Task prioritization mode (defaults to config file value if set)", ), ): """Start a nightshift research run.""" @@ -30,8 +42,8 @@ def start( console.print(f"[bold green]Starting Nightshift[/bold green]") console.print(f"Projects: {', '.join(projects)}") - console.print(f"Max duration: {duration} hours") - console.print(f"Priority mode: {priority_mode}") + console.print(f"Max duration: {duration if duration is not None else 'config default'}") + console.print(f"Priority mode: {priority_mode if priority_mode is not None else 'config default'}") console.print() try: @@ -46,6 +58,160 @@ def start( raise typer.Exit(1) +@app.command() +def init( + config_path: Optional[Path] = typer.Option( + None, + "--config-path", + help="Custom path for config.toml", + ), + add_current_project: bool = typer.Option( + True, + "--add-current-project/--no-add-current-project", + help="Add current working directory as a project alias", + ), + force: bool = typer.Option( + False, + "--force", + "-f", + help="Overwrite existing config without prompting", + ), +): + """Create a starter Nightshift config file.""" + from .config import get_config_path, render_default_config_toml + + target_path = get_config_path(config_path) + current_project = Path.cwd() if add_current_project else None + content = render_default_config_toml(current_project) + + if target_path.exists() and not force: + if not typer.confirm(f"{target_path} already exists. Overwrite?"): + console.print("[yellow]Init cancelled.[/yellow]") + raise typer.Exit(1) + + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(content) + + console.print(f"[green]Created config:[/green] {target_path}") + if current_project: + console.print(f"[green]Added project alias for:[/green] {current_project}") + console.print("\nNext steps:") + console.print("1. Edit the project aliases and defaults in config.toml") + console.print("2. Run [bold]nightshift doctor[/bold] to validate setup") + console.print("3. Start a run with [bold]nightshift start [/bold]") + + +def _status_label(status: str) -> str: + if status == "pass": + return "[green]PASS[/green]" + if status == "warn": + return "[yellow]WARN[/yellow]" + return "[red]FAIL[/red]" + + +@app.command() +def doctor(): + """Validate local Nightshift/OpenCode setup and print fixes.""" + from .config import get_config_path, get_data_dir, load_user_config, get_default_project_aliases + + checks: list[DoctorCheck] = [] + + opencode_path = shutil.which("opencode") + if opencode_path: + checks.append(DoctorCheck("OpenCode CLI", "pass", f"Found at {opencode_path}")) + else: + checks.append(DoctorCheck("OpenCode CLI", "fail", "Not installed", "Install OpenCode and ensure `opencode` is on PATH")) + + if opencode_path: + try: + models_result = subprocess.run( + ["opencode", "models"], + capture_output=True, + text=True, + timeout=20, + ) + models = [line.strip() for line in models_result.stdout.splitlines() if "/" in line] + if models_result.returncode == 0 and models: + checks.append(DoctorCheck("OpenCode models", "pass", f"{len(models)} model(s) available")) + elif models_result.returncode == 0: + checks.append(DoctorCheck("OpenCode models", "warn", "No models detected", "Run `opencode auth` and configure at least one provider")) + else: + details = models_result.stderr.strip().splitlines()[:1] + checks.append(DoctorCheck("OpenCode models", "fail", details[0] if details else "Command failed", "Run `opencode auth` and retry")) + except subprocess.TimeoutExpired: + checks.append(DoctorCheck("OpenCode models", "warn", "Timed out while checking models", "Retry later or run `opencode models` manually")) + + gh_path = shutil.which("gh") + if gh_path: + try: + gh_status = subprocess.run( + ["gh", "auth", "status"], + capture_output=True, + text=True, + timeout=15, + ) + if gh_status.returncode == 0: + checks.append(DoctorCheck("GitHub CLI auth", "pass", "Authenticated")) + else: + checks.append(DoctorCheck("GitHub CLI auth", "warn", "Not authenticated", "Run `gh auth login` to enable issue/PR workflows")) + except subprocess.TimeoutExpired: + checks.append(DoctorCheck("GitHub CLI auth", "warn", "Timed out", "Run `gh auth status` manually")) + else: + checks.append(DoctorCheck("GitHub CLI", "warn", "Not installed", "Install `gh` if you want auto issue/PR workflows")) + + data_dir = get_data_dir() + try: + data_dir.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=data_dir, prefix=".doctor_", delete=True): + pass + checks.append(DoctorCheck("Data directory", "pass", str(data_dir))) + except Exception as e: + checks.append(DoctorCheck("Data directory", "fail", f"{data_dir} is not writable: {e}", "Set `NIGHTSHIFT_DATA_DIR` to a writable location")) + + config_path = get_config_path() + if config_path.exists(): + cfg = load_user_config(config_path) + aliases = get_default_project_aliases(user_config=cfg) + checks.append( + DoctorCheck( + "Config file", + "pass", + f"{config_path} ({len(aliases)} project alias(es))", + ) + ) + else: + checks.append( + DoctorCheck( + "Config file", + "warn", + f"Missing {config_path}", + "Run `nightshift init` to generate a starter config", + ) + ) + + plugin_dir = Path(__file__).resolve().parent.parent / "plugin" + if (plugin_dir / "package.json").exists(): + if (plugin_dir / "node_modules").exists(): + checks.append(DoctorCheck("Plugin dependencies", "pass", "Installed")) + else: + checks.append(DoctorCheck("Plugin dependencies", "warn", "Not installed", f"Run `cd {plugin_dir} && bun install`")) + + table = Table(title="Nightshift Doctor") + table.add_column("Check", style="cyan") + table.add_column("Status") + table.add_column("Details", style="white") + table.add_column("Action", style="dim") + + for item in checks: + table.add_row(item.name, _status_label(item.status), item.details, item.action) + + console.print(table) + + has_failures = any(item.status == "fail" for item in checks) + if has_failures: + raise typer.Exit(1) + + @app.command() def report( open_browser: bool = typer.Option( @@ -102,6 +268,7 @@ def status(): """Show current nightshift status.""" from .config import NightshiftConfig from .task_queue import TaskQueue + from .config import get_preferred_models from .model_manager import create_default_manager config = NightshiftConfig() @@ -125,7 +292,7 @@ def status(): console.print(table) - manager = create_default_manager() + manager = create_default_manager(preferred_models=get_preferred_models()) model_status = manager.get_status() model_table = Table(title="Model Status") diff --git a/src/config.py b/src/config.py index d8b9e95..501393f 100644 --- a/src/config.py +++ b/src/config.py @@ -7,6 +7,30 @@ from typing import Optional import json import os +import re + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover + tomllib = None + + +def get_data_dir() -> Path: + data_dir = os.getenv("NIGHTSHIFT_DATA_DIR") + if data_dir: + return Path(data_dir).expanduser() + return Path.home() / ".nightshift" + + +def get_config_path(config_path: Optional[Path] = None) -> Path: + if config_path: + return Path(config_path).expanduser() + + env_config_path = os.getenv("NIGHTSHIFT_CONFIG_FILE") + if env_config_path: + return Path(env_config_path).expanduser() + + return get_data_dir() / "config.toml" @dataclass @@ -29,6 +53,15 @@ class ModelConfig: rate_limited_until: Optional[float] = None +def _default_models() -> list[ModelConfig]: + return [ + ModelConfig("google", "antigravity-claude-opus-4-5-thinking-high", priority=1), + ModelConfig("openai", "gpt-5.2", priority=2), + ModelConfig("google", "antigravity-gemini-3-pro-high", priority=3), + ModelConfig("google", "antigravity-gemini-3-flash", priority=4), + ] + + @dataclass class NightshiftConfig: """Main configuration for a nightshift run.""" @@ -37,12 +70,7 @@ class NightshiftConfig: projects: list[ProjectConfig] = field(default_factory=list) # Model failover chain (priority order) - models: list[ModelConfig] = field(default_factory=lambda: [ - ModelConfig("google", "antigravity-claude-opus-4-5-thinking-high", priority=1), - ModelConfig("openai", "gpt-5.2", priority=2), - ModelConfig("google", "antigravity-gemini-3-pro-high", priority=3), - ModelConfig("google", "antigravity-gemini-3-flash", priority=4), - ]) + models: list[ModelConfig] = field(default_factory=_default_models) # Task configuration enable_codebase_audit: bool = True @@ -57,8 +85,8 @@ class NightshiftConfig: open_report_in_browser: bool = True # Paths - data_dir: Path = field(default_factory=lambda: Path.home() / ".nightshift") - reports_dir: Path = field(default_factory=lambda: Path.home() / ".nightshift" / "reports") + data_dir: Path = field(default_factory=get_data_dir) + reports_dir: Path = field(default_factory=lambda: get_data_dir() / "reports") def __post_init__(self): # Ensure directories exist @@ -93,8 +121,7 @@ def _project_path_from_env_or_default(env_var: str, default_path: Path) -> Path: return default_path -# Default project paths (can be overridden via env vars) -DEFAULT_PROJECTS = { +BUILTIN_PROJECT_ALIASES = { "opsorchestra": _project_path_from_env_or_default( "NIGHTSHIFT_PROJECT_OPSORCHESTRA", Path.home() / "Projects" / "opsorchestra", @@ -106,18 +133,165 @@ def _project_path_from_env_or_default(env_var: str, default_path: Path) -> Path: } +def _slug_to_env_key(value: str) -> str: + return re.sub(r"[^A-Z0-9]+", "_", value.upper()).strip("_") + + +def _safe_float(value, fallback: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return fallback + + +def _safe_priority_mode(value, fallback: str) -> str: + if isinstance(value, str) and value in { + "balanced", + "security_first", + "research_heavy", + "quick_scan", + }: + return value + return fallback + + +def load_user_config(config_path: Optional[Path] = None) -> dict: + path = get_config_path(config_path) + if not path.exists(): + return {} + if tomllib is None: + return {} + + try: + loaded = tomllib.loads(path.read_text()) + return loaded if isinstance(loaded, dict) else {} + except Exception: + return {} + + +def get_config_defaults(user_config: Optional[dict] = None) -> dict: + cfg = user_config or {} + defaults = cfg.get("defaults", {}) + if not isinstance(defaults, dict): + defaults = {} + + return { + "duration_hours": _safe_float(defaults.get("duration_hours"), 8.0), + "priority_mode": _safe_priority_mode(defaults.get("priority_mode"), "balanced"), + "open_report_in_browser": bool(defaults.get("open_report_in_browser", True)), + } + + +def get_default_project_aliases( + config_path: Optional[Path] = None, + user_config: Optional[dict] = None, +) -> dict[str, Path]: + aliases = dict(BUILTIN_PROJECT_ALIASES) + cfg = user_config if user_config is not None else load_user_config(config_path) + + configured_projects = cfg.get("projects", {}) + if isinstance(configured_projects, dict): + for alias, path in configured_projects.items(): + if isinstance(alias, str) and isinstance(path, str) and path.strip(): + aliases[alias.strip()] = Path(path).expanduser() + + for alias in list(aliases.keys()): + override_env = f"NIGHTSHIFT_PROJECT_{_slug_to_env_key(alias)}" + override_path = os.getenv(override_env) + if override_path: + aliases[alias] = Path(override_path).expanduser() + + return aliases + + +DEFAULT_PROJECTS = get_default_project_aliases() + + +def get_preferred_models( + config_path: Optional[Path] = None, + user_config: Optional[dict] = None, +) -> list[ModelConfig]: + cfg = user_config if user_config is not None else load_user_config(config_path) + models_section = cfg.get("models", {}) + if not isinstance(models_section, dict): + return _default_models() + + preferred = models_section.get("preferred", []) + if not isinstance(preferred, list): + return _default_models() + + model_configs: list[ModelConfig] = [] + for idx, identifier in enumerate(preferred, start=1): + if not isinstance(identifier, str) or "/" not in identifier: + continue + provider, model_id = identifier.split("/", 1) + provider = provider.strip() + model_id = model_id.strip() + if not provider or not model_id: + continue + model_configs.append(ModelConfig(provider, model_id, priority=idx)) + + return model_configs or _default_models() + + +def _sanitize_alias(value: str) -> str: + sanitized = re.sub(r"[^a-zA-Z0-9_-]+", "_", value.strip()) + return sanitized or "project" + + +def render_default_config_toml(current_project: Optional[Path] = None) -> str: + project_line = '# example = "/path/to/your/project"' + if current_project: + alias = _sanitize_alias(current_project.name) + project_line = f'{alias} = "{current_project.resolve()}"' + + return f"""# Nightshift user configuration +# Save as: {get_config_path()} + +[defaults] +duration_hours = 8.0 +priority_mode = "balanced" +open_report_in_browser = true + +[projects] +{project_line} + +[models] +# Optional model preference order. If omitted, Nightshift auto-discovers +# available models and falls back to built-in defaults. +# preferred = ["openai/gpt-5.2", "google/antigravity-gemini-3-pro-high"] +""" + + def get_config( project_names: list[str], - duration_hours: float = 8.0, - priority_mode: str = "balanced", - open_report_in_browser: bool = True, + duration_hours: Optional[float] = None, + priority_mode: Optional[str] = None, + open_report_in_browser: Optional[bool] = None, + config_path: Optional[Path] = None, ) -> NightshiftConfig: """Create a NightshiftConfig from project names.""" - + user_config = load_user_config(config_path) + defaults = get_config_defaults(user_config) + aliases = get_default_project_aliases(config_path, user_config) + preferred_models = get_preferred_models(config_path, user_config) + + resolved_duration = duration_hours if duration_hours is not None else defaults["duration_hours"] + resolved_priority_mode = ( + _safe_priority_mode(priority_mode, defaults["priority_mode"]) + if priority_mode is not None + else defaults["priority_mode"] + ) + resolved_open_browser = ( + open_report_in_browser + if open_report_in_browser is not None + else defaults["open_report_in_browser"] + ) + projects = [] for name in project_names: - if name in DEFAULT_PROJECTS: - projects.append(ProjectConfig(name=name, path=DEFAULT_PROJECTS[name])) + if name in aliases: + projects.append(ProjectConfig(name=name, path=aliases[name])) else: # Assume it's a path path = Path(name).expanduser().resolve() @@ -128,7 +302,8 @@ def get_config( return NightshiftConfig( projects=projects, - max_duration_hours=duration_hours, - priority_mode=priority_mode, - open_report_in_browser=open_report_in_browser, + models=preferred_models, + max_duration_hours=resolved_duration, + priority_mode=resolved_priority_mode, + open_report_in_browser=resolved_open_browser, ) diff --git a/src/dashboard.html b/src/dashboard.html index bc1d1de..ae93d88 100644 --- a/src/dashboard.html +++ b/src/dashboard.html @@ -279,6 +279,37 @@ .log-time { color: var(--text-secondary); } .log-message { color: var(--text-primary); } + .failures-panel { + margin-top: 1rem; + border-top: 1px solid var(--border-color); + padding-top: 0.75rem; + } + + .failure-entry { + background: var(--bg-tertiary); + border-left: 3px solid var(--danger); + border-radius: 4px; + padding: 0.6rem; + margin-bottom: 0.5rem; + } + + .failure-entry:last-child { margin-bottom: 0; } + + .failure-title { + font-size: 0.82rem; + font-weight: 600; + margin-bottom: 0.3rem; + } + + .failure-error { + font-family: monospace; + font-size: 0.75rem; + color: var(--text-secondary); + white-space: pre-wrap; + max-height: 4.8rem; + overflow: hidden; + } + .empty-state { text-align: center; padding: 2rem; @@ -391,6 +422,10 @@

Current Status

--
+
@@ -474,6 +509,21 @@

Add Schedule

document.getElementById('findings').textContent = data.total_findings || 0; document.getElementById('elapsedTime').textContent = data.elapsed_minutes ? `${data.elapsed_minutes} min` : '--'; document.getElementById('runId').textContent = data.run_id || '--'; + + const failuresPanel = document.getElementById('failuresPanel'); + const failuresList = document.getElementById('failuresList'); + if (data.recent_failures && data.recent_failures.length > 0) { + failuresPanel.style.display = 'block'; + failuresList.innerHTML = data.recent_failures.map(f => ` +
+
${f.task_type} (${f.project_name})
+
${(f.error || 'Unknown error').replace(//g, '>')}
+
+ `).join(''); + } else { + failuresPanel.style.display = 'none'; + failuresList.innerHTML = ''; + } const startBtn = document.getElementById('startBtn'); const stopBtn = document.getElementById('stopBtn'); diff --git a/src/model_manager.py b/src/model_manager.py index fc6936e..f507080 100644 --- a/src/model_manager.py +++ b/src/model_manager.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Optional import time -import httpx +import subprocess from .config import ModelConfig @@ -73,12 +73,122 @@ def all_exhausted(self) -> bool: return self.get_available_model() is None -def create_default_manager() -> ModelFailoverManager: +DEFAULT_MODEL_CHAIN = [ + ModelConfig("google", "antigravity-claude-opus-4-5-thinking-high", priority=1), + ModelConfig("openai", "gpt-5.2", priority=2), + ModelConfig("google", "antigravity-gemini-3-pro-high", priority=3), + ModelConfig("google", "antigravity-gemini-3-flash", priority=4), +] + +_MODEL_DISCOVERY_CACHE = { + "timestamp": 0.0, + "models": [], +} + + +def discover_available_model_ids( + opencode_path: str = "opencode", + ttl_seconds: int = 300, + refresh: bool = False, +) -> list[str]: + now = time.time() + if not refresh and _MODEL_DISCOVERY_CACHE["models"] and (now - _MODEL_DISCOVERY_CACHE["timestamp"] < ttl_seconds): + return list(_MODEL_DISCOVERY_CACHE["models"]) + + try: + result = subprocess.run( + [opencode_path, "models"], + capture_output=True, + text=True, + timeout=20, + ) + except Exception: + return list(_MODEL_DISCOVERY_CACHE["models"]) + + if result.returncode != 0: + return list(_MODEL_DISCOVERY_CACHE["models"]) + + models = [line.strip() for line in result.stdout.splitlines() if "/" in line] + if models: + _MODEL_DISCOVERY_CACHE["models"] = models + _MODEL_DISCOVERY_CACHE["timestamp"] = now + + return list(_MODEL_DISCOVERY_CACHE["models"]) + + +def _score_discovered_model(identifier: str) -> int: + model_lower = identifier.lower() + provider = identifier.split("/", 1)[0] + + score = { + "openai": 35, + "anthropic": 30, + "google": 28, + "opencode": 20, + }.get(provider, 10) + + if any(token in model_lower for token in ("embedding", "image", "audio", "tts", "live")): + score -= 50 + if "preview" in model_lower: + score -= 10 + if any(token in model_lower for token in ("opus", "pro", "gpt-5", "claude-sonnet", "gemini-3-pro")): + score += 20 + if any(token in model_lower for token in ("nano", "lite", "flash", "haiku", "free")): + score -= 5 + + return score + + +def _build_fallback_chain_from_available(available_model_ids: list[str], limit: int = 4) -> list[ModelConfig]: + ranked = sorted( + available_model_ids, + key=lambda identifier: (_score_discovered_model(identifier), identifier), + reverse=True, + ) + + selected: list[ModelConfig] = [] + for identifier in ranked: + if "/" not in identifier: + continue + provider, model_id = identifier.split("/", 1) + selected.append(ModelConfig(provider=provider, model_id=model_id, priority=len(selected) + 1)) + if len(selected) >= limit: + break + + return selected + + +def _normalize_priorities(models: list[ModelConfig]) -> list[ModelConfig]: + normalized: list[ModelConfig] = [] + for index, model in enumerate(models, start=1): + normalized.append(ModelConfig(model.provider, model.model_id, priority=index)) + return normalized + + +def create_default_manager( + preferred_models: Optional[list[ModelConfig]] = None, + use_discovery: bool = True, +) -> ModelFailoverManager: + preferred = preferred_models or DEFAULT_MODEL_CHAIN + + if not use_discovery: + return ModelFailoverManager(models=_normalize_priorities(preferred)) + + available = discover_available_model_ids() + if not available: + return ModelFailoverManager(models=_normalize_priorities(preferred)) + + available_set = set(available) + discovered_preferred: list[ModelConfig] = [] + for model in preferred: + model_key = f"{model.provider}/{model.model_id}" + if model_key in available_set: + discovered_preferred.append(model) + + final_models = discovered_preferred or _build_fallback_chain_from_available(available) + if not final_models: + final_models = list(preferred) + return ModelFailoverManager( - models=[ - ModelConfig("google", "antigravity-claude-opus-4-5-thinking-high", priority=1), - ModelConfig("openai", "gpt-5.2", priority=2), - ModelConfig("google", "antigravity-gemini-3-pro-high", priority=3), - ModelConfig("google", "antigravity-gemini-3-flash", priority=4), - ] + models=_normalize_priorities(final_models) ) diff --git a/src/report_generator.py b/src/report_generator.py index b91cd0e..8704292 100644 --- a/src/report_generator.py +++ b/src/report_generator.py @@ -131,6 +131,32 @@ } .finding-recommendation strong { color: var(--accent); } + .failure-item { + background: var(--bg-tertiary); + border-left: 4px solid var(--danger, #f85149); + border-radius: 0 6px 6px 0; + padding: 1rem; + margin-bottom: 1rem; + } + .failure-title { + font-weight: 600; + margin-bottom: 0.4rem; + } + .failure-meta { + color: var(--text-secondary); + font-size: 0.8rem; + margin-bottom: 0.5rem; + font-family: monospace; + } + .failure-error { + white-space: pre-wrap; + font-family: monospace; + font-size: 0.85rem; + background: var(--bg-primary); + border-radius: 4px; + padding: 0.75rem; + } + details { margin-bottom: 0.5rem; } summary { cursor: pointer; @@ -340,6 +366,22 @@ {% endfor %} {% endif %} + + {% if failed_tasks %} +
+

Failed Tasks

+

+ {{ failed_tasks|length }} task(s) failed during this run. +

+ {% for task in failed_tasks %} +
+
{{ task.task_type }} ({{ task.project_name }})
+
model={{ task.model_used or "unknown" }} | completed={{ task.completed_at or "n/a" }}
+
{{ task.error or "Unknown failure" }}
+
+ {% endfor %} +
+ {% endif %}