commit 131413e630b9da3f60e2b1b5acf5cdfdf19cd471 Author: Jeremy Anderson Date: Sun Jun 28 11:37:44 2026 -0400 AI-LSC v3.0 - AI Local Stack Control diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef9cb93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +*.egg +dist/ +build/ +.eggs/ +*.whl + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# Type checking +*.pyi + +# Distribution +*.tar.gz +*.tar.bz2 +*.zip + +# OS files +.DS_Store +Thumbs.db +*.log + +# Application runtime data +pipeline_state.json +pipeline.json +config.json +ai_lsc_state.json + +# Tool output +*.yaml.bak +lxc-launch.sh +*.conf.bak + +# Downloaded models (Ollama, etc.) +models/ +weights/ + +# Logs +logs/ +*.log + +# Guardrail baseline (internal, not for publishing) +.guardrail_baseline.json + +# Temporary files +tmp/ +temp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f29c05 --- /dev/null +++ b/README.md @@ -0,0 +1,349 @@ +
+ AI-LSC Logo +
+ +

AI - Local Stack Control

+ +

+ v3.0 — Codename: Ankh of Jah
+ http://dcos.net +

+ +

+A PySide6 desktop application for orchestrating local AI/ML tool stacks across a 13-layer architecture. +

+ +AI Local Stack Control (AI-LSC) provides a unified interface to discover, configure, launch, and manage 121 tools spanning the entire AI software stack — from GPU runtimes and inference engines to agent frameworks and container deployment targets. + +![Overview](docs/screenshots/overview.png) + +## Features + +### 13-Layer Architecture + +Every tool in the registry is classified within a 13-layer taxonomy, giving you a clear mental model of your entire AI stack: + +| Layer | Name | Tools | +|-------|------|-------| +| L1 | Host Platform | PostgreSQL, MariaDB, Redis, SQLite3, DuckDB | +| L2 | Development Environment | Python, CuPy, ripgrep, fd, tree-sitter, SST | +| L3 | GPU Runtime | CUDA Toolkit, ROCm, Vulkan | +| L4 | Inference Engines | Ollama, llama.cpp, vLLM, SGlang, TGI, LMDeploy, TextGen | +| L5 | Distributed Runtime | vLLM (distributed) | +| L6 | AI Endpoints | LiteLLM Proxy, OpenRouter, TGI Endpoint, vLLM Endpoint, Ollama Endpoint, LM Studio, OpenAI Compatible, Groq | +| L7 | Data & Knowledge Pipelines | Dify, LangChain, LlamaIndex, LangGraph, Docling, Whisper, Unstructured, Haystack, Craw4AI, Firecrawl, LakeFS, DVC, nomic-embed | +| L8 | Automation & Execution | Aider, Claude Code, Codestral, Fabric, Jupyter, Streamlit, Gradio, Chainlit, Aider (Chat), Marqo, PyPDF, Docling (ETL), and more | +| L9 | Observability | Btop, Glances, Prometheus, Grafana, Loki, Jaeger, OpenTelemetry | +| L10 | Intelligent Routing | CrewAI, AutoGen, OpenBrain, Mnemosyne, Mnemo Cortex | +| L11 | User Interfaces | Open WebUI, ChatUI, InvokeAI, Forge (A1111), ComfyUI, Gradio Web, Streamlit Web | +| L12 | Containers | Terraform, Ansible, Puppet, Pulumi, Bicep, OpenTofu, AWS CDK, Crossplane, Terragrunt | +| L13 | Knowledge Management | Zotero, Calibre, Paperless-ngx, Logseq, Joplin | + +![Infrastructure Layers](docs/screenshots/infrastructure-layers.png) + +### Tool Registry + +Browse and search across 115 tools with real-time status detection, dependency tracking, and per-tool configuration. Each tool entry includes installer type, launcher specification, required dependencies, and feature flags (CLI/GUI/Web). + +![Tools Registry](docs/screenshots/tools-registry.png) + +### IPC Stack Editor + +Visually compose your tool stack using the AI-LSC Stack Editor — a drag-and-drop flow compiler. Validate dependencies, then compile the stack state to a portable JSON configuration file. + +![IPC Stack Editor](docs/screenshots/ipc-stack-editor.png) + +### Stack Templates + +Get started quickly with pre-configured stack templates: + +- **Claude Code Setup** — Full Claude Code ecosystem (11 tools: claude_code, ollama, aider, claude_mem, godmod3, awesome_claude_code, superpowers, ui_ux_pro_max, vibe_kanban, claude_squad, rcode) +- **Free Claude Code** — Minimal Claude Code setup (4 tools: claude_code, ollama, claude_mem, rcode) +- **SaaS Integrations** — Production deployment stack (12 tools including cloudflared, nginx_proxy, certbot, backup_agent) +- **Local LLM Lab** — Self-hosted LLM playground (10 tools: ollama, llamacpp, vllm, litellm, openwebui, chromadb, whisper, docling, aider, fabric) + +### Multi-Backend Container Export + +Export your compiled stack to multiple deployment targets: + +- **Podman Compose** — Rootless OCI containers via `compose.yaml` +- **Docker Compose** — Standard Docker Compose output +- **LXC Containers** — Per-container `.conf` files + `lxc-launch.sh` lifecycle script + +![Deployment Targets](docs/screenshots/deployment-targets.png) + +### Runtime Management + +Launch and manage tools via four runtime backends: + +- **systemd** — Persistent system services with `systemctl` +- **tmux** — Session-managed terminal processes +- **desktop** — One-shot CLI commands +- **lxc** — Full LXC container lifecycle (create, start, stop, freeze, attach) + +### Skills System + +Extend AI-LSC with skill modules that add specialized behaviors to your tool stack. The Skills Console provides activation toggles, behavior bindings, and runtime integration. + +![Skills Console](docs/screenshots/skills-console.png) + +### AI Chat Console + +Built-in chat interface for interacting with local LLM endpoints. Supports model selection, conversation history, and direct integration with your running stack. + +![Chat Console](docs/screenshots/chat-console.png) + +### Monitor Dashboard + +Real-time system health monitoring with CPU/memory metrics, per-service status indicators, and log aggregation across all running tools. + +![Monitor Dashboard](docs/screenshots/monitor-dashboard.png) + +### Code Analysis + +Source code analysis with syntax highlighting, complexity metrics, and dependency visualization. + +![Code Analysis](docs/screenshots/code-analysis.png) + +### Settings + +Configure base directories, model defaults, API endpoints, logging levels, and application preferences. + +![Settings](docs/screenshots/settings.png) + +## Architecture + +``` +ai_lsc/ + __init__.py # Public API re-exports + constants.py # App constants, styles, navigation order + types.py # Data classes: ToolMetadata, PipelineState, etc. + guardrails.py # Import guard for PySide6 + registry/ + __init__.py + defaults.py # Legacy 115-entry monolith registry (fallback) + loader.py # Merges per-layer files at runtime + manager.py # RegistryManager — query/filter tools + validator.py # Schema validation + layers/ # 13 per-layer tool files (108 → 115 tools) + automation.py # L8: 31 tools + containers.py # L12: 10 tools (incl. 6 new IaC tools) + data_knowledge.py # L7: 13 tools + development.py # L2: 6 tools (incl. SST) + distributed.py # L5: 1 tool + endpoints.py # L6: 10 tools + gpu.py # L3: 3 tools + host_platform.py # L1: 5 tools + inference.py # L4: 7 tools + intelligent_routing.py # L10: 5 tools + knowledge_management.py # L13: 5 tools + observability.py # L9: 7 tools + user_interfaces.py # L11: 12 tools + stack_templates/ + __init__.py + manager.py # StackTemplateManager + claude-code-setup.json # 11-tool template + free-claude-code.json # 4-tool template + saas-integrations.json # 12-tool template + local-llm-lab.json # 10-tool template + runtime/ + __init__.py + executor.py # RuntimeExecutor — dispatch to systemd/tmux/desktop/lxc + installer.py # Tool installation handlers + process.py # Process management + status.py # Service status detection + systemd.py # systemd service lifecycle + tmux.py # tmux session management + lxc.py # LXC container lifecycle + stack/ + export.py # ContainerBackend — generates compose YAML / LXC configs + ui/ + __init__.py + protocol.py # MainWindowProtocol (dependency injection) + main_window.py # AILocalStackControl — master QMainWindow + dialogs/ + __init__.py + stack_wizard.py # First-launch template selection wizard + pages/ + __init__.py + chatbot_console.py + code_analysis_tab.py + container_stacks_tab.py + datasets_tab.py + git_worktree_tab.py + infrastructure_layer_page.py + service_row.py + settings_page.py + skills_console.py + ipc_stack_tab.py + tools_tab.py + chat/ + api.py # Async chat API worker (PySide6 signals) + skills/ + resolver.py # SkillRuntimeResolver + manifest/ + support.py # Manifest generation + utils/ + filesystem.py + logging.py + paths.py + process.py +``` + +## Installation + +### Prerequisites + +- Python 3.11+ +- PySide6 (`pip install PySide6`) +- Arch Linux (pacman) or equivalent package manager + +### Quick Install + +```bash +git clone https://github.com/your-username/ai-lsc.git +cd ai-lsc +pip install -e . +``` + +See [quickstart.md](quickstart.md) for detailed setup instructions. + +### Bootstrap Script + +```bash +./bootstrap.sh +``` + +The bootstrap script installs all system dependencies (pacman packages), Python dependencies, and verifies your environment. + +## Usage + +### Launch the Application + +```bash +python -m ai_lsc +``` + +### First Launch + +On first launch, the Stack Template Wizard appears. Choose a pre-configured template (Claude Code Setup, Local LLM Lab, etc.) or start from scratch and manually select your tools. + +### Typical Workflow + +1. **Select a template** or manually pick tools from the registry +2. **Configure dependencies** — AI-LSC resolves tool dependencies automatically +3. **Compile your stack** — IPC Stack Editor validates and saves the configuration +4. **Launch services** — Tools start via systemd, tmux, or desktop launchers +5. **Monitor** — Dashboard shows real-time status across all running tools +6. **Export** — Generate Podman/Docker Compose or LXC container configs + +## Development + +### Project Structure + +The project follows a layered architecture with clear separation of concerns: + +- **registry/** — Tool definitions, loader, validator, templates +- **runtime/** — Process management, launchers, installers +- **stack/** — Container export backends +- **ui/** — PySide6 interface (guarded imports, protocol-based DI) +- **chat/** — Async chat API integration +- **skills/** — Skill runtime resolver +- **utils/** — Filesystem, logging, path helpers + +### PySide6 Guard Pattern + +All UI modules use a try/except guard: + +```python +try: + from PySide6.QtWidgets import QMainWindow + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + class MyWidget(QMainWindow): + ... + MyWidget = None +``` + +This allows the registry, runtime, and utility modules to be imported and tested without PySide6 installed. + +### Registry-Driven Dispatch + +Tool behavior is driven entirely by registry entries. No hardcoded switch statements: + +```python +LAUNCHER_DISPATCH = { + "systemd": systemd_start, + "tmux": tmux_start, + "desktop": desktop_start, + "lxc": lxc_start, +} +handler = LAUNCHER_DISPATCH[tool["launcher"]["type"]] +handler(tool) +``` + +### Adding a New Tool + +1. Identify the correct layer file in `registry/layers/` +2. Add a new entry to the `TOOLS` dict: + +```python +'my_tool': { + "name": "My Tool", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Development", + "installer": {"type": "npm", "pkg": "my-tool"}, + "launcher": {"type": "tmux", "cmd": "my-tool serve --port {port}", + "default_port": 8080}, + "deps": ["ollama"], + "description": "My awesome AI tool.", + "flags": {"has_cli": True, "has_gui": False, "has_web": True} +}, +``` + +3. Optionally add it to a stack template JSON in `registry/stack_templates/` + +### Creating a Stack Template + +```json +{ + "id": "my-template", + "name": "My Custom Stack", + "description": "A custom stack for my workflow", + "version": "1.0", + "author": "your-name", + "tags": ["custom", "development"], + "tools": ["ollama", "aider", "claude_code", "vllm"] +} +``` + +Save as `registry/stack_templates/my-template.json`. + +## Tech Stack + +| Component | Technology | +|-----------|-----------| +| UI Framework | PySide6 (Qt for Python) | +| Language | Python 3.11+ | +| Package Manager | pip / uv | +| Container Backends | Podman, Docker, LXC | +| Service Management | systemd, tmux | +| IaC Tools | Terraform, Pulumi, OpenTofu, AWS CDK, Crossplane, Bicep, Terragrunt | +| Config Format | JSON | + +## License + +MIT + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/my-feature`) +3. Add tools to the appropriate layer file +4. Ensure all 13 layer files pass AST validation (`python3 -c "import ast; ..."`) +5. Submit a pull request diff --git a/ai-lsc-logo.png b/ai-lsc-logo.png new file mode 100755 index 0000000..7b2df51 Binary files /dev/null and b/ai-lsc-logo.png differ diff --git a/ai_lsc.py b/ai_lsc.py new file mode 100755 index 0000000..b110e72 --- /dev/null +++ b/ai_lsc.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""AI Local Stack Control v3.0 — Ankh of Jah + +Direct launcher. Run from the project root: + + python ai_lsc.py + +No pip install, no entry-point scripts, no ~/.local pollution. +Reads .env (created by bootstrap.sh) for AI_LSC_BASE_DIR, then +points sys.path at src/ and calls main(). + +Works wherever it sits — fully portable. +""" +from __future__ import annotations + +import os +import sys + +# ── Resolve project root (works from any cwd) ────────────────── +_PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +_SRC_DIR = os.path.join(_PROJECT_ROOT, "src") +if _SRC_DIR not in sys.path: + sys.path.insert(0, _SRC_DIR) + +# ── Load .env file for AI_LSC_BASE_DIR (before any ai_lsc imports) ── +# Bootstrap writes this. If missing, constants.py falls back to /mnt/AI +# or the AI_LSC_BASE_DIR env var. +_ENV_FILE = os.path.join(_PROJECT_ROOT, ".env") +if os.path.isfile(_ENV_FILE): + with open(_ENV_FILE) as _f: + for _line in _f: + _line = _line.strip() + if _line and not _line.startswith("#") and "=" in _line: + _key, _, _val = _line.partition("=") + if _key.strip() == "AI_LSC_BASE_DIR" and _val.strip(): + os.environ.setdefault("AI_LSC_BASE_DIR", _val.strip()) + + +def main() -> int: + """Launch the AI-LSC desktop application.""" + # PySide6 required + try: + from PySide6.QtWidgets import QApplication + except ImportError: + print( + "PySide6 is required but not installed.\n\n" + " source .venv/bin/activate\n" + " pip install PySide6>=6.6\n\n" + " Or re-run: bash bootstrap.sh", + file=sys.stderr, + ) + return 1 + + from ai_lsc.ui.main_window import AILocalStackControl + + app = QApplication.instance() or QApplication(sys.argv) + window = AILocalStackControl() + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..7c628e5 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# AI Local Stack Control v3.0 — Ankh of Jah +# Bootstrap Script +# +# Fully portable: works wherever the tarball lands. +# Resolves the base directory from cwd or env var. +# Creates venv in-project. On Arch, uses pacman's +# pre-built PySide6 to avoid pip compile hell. +# ────────────────────────────────────────────────────────────── +set -euo pipefail + +BOLD='\033[1m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +# ── Resolve paths (current-path aware) ──────────────────────── +# SCRIPT_DIR = wherever bootstrap.sh lives (the project root) +# AI_BASE = the managed working directory for all AI tools +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="${SCRIPT_DIR}/.venv" +VENV_PYTHON_STAMP="${VENV_DIR}/.python-version-stamp" + +# Base directory: env var > parent of SCRIPT_DIR if named "tools" > /mnt/AI +# This lets you extract to /mnt/AI/tools/ai_lsc-v3/ and have it detect +# /mnt/AI as the base. If extracted elsewhere, defaults to /mnt/AI or +# whatever AI_LSC_BASE_DIR says. +_PARENT_DIR="$(dirname "$SCRIPT_DIR")" +_PARENT_NAME="$(basename "$_PARENT_DIR")" +if [ -n "${AI_LSC_BASE_DIR:-}" ]; then + AI_BASE="$AI_LSC_BASE_DIR" +elif [ "$_PARENT_NAME" = "tools" ] && [ -d "$(dirname "$_PARENT_DIR")/models" ]; then + # We're inside .../tools/ai_lsc-v3/ — base is the parent of tools/ + AI_BASE="$(dirname "$_PARENT_DIR")" +else + AI_BASE="${AI_LSC_BASE_DIR:-/mnt/AI}" +fi +export AI_LSC_BASE_DIR="$AI_BASE" + +echo "" +echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ AI Local Stack Control v3.0 — Ankh of Jah ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${CYAN} Project root : ${SCRIPT_DIR}${NC}" +echo -e "${CYAN} Base dir : ${AI_BASE}${NC}" +echo -e "${CYAN} Venv : ${VENV_DIR}${NC}" +echo "" + +# ── Clean stale ~/.local/bin/ai-lsc from old pip/pipx installs ── +STALE_BIN="${HOME}/.local/bin/ai-lsc" +if [ -f "$STALE_BIN" ]; then + warn "Found stale entry-point: ${STALE_BIN}" + warn "Removing — everything runs via the project venv now" + rm -f "$STALE_BIN" +fi + +if command -v pipx &>/dev/null && pipx list 2>/dev/null | grep -q "ai-lsc"; then + warn "Found pipx-installed ai-lsc — uninstalling" + pipx uninstall ai-lsc 2>/dev/null || true +fi + +# ── Ensure AI_BASE exists ───────────────────────────────────── +if [ ! -d "$AI_BASE" ]; then + if [ "$(id -u)" -eq 0 ]; then + mkdir -p "$AI_BASE" + info "Created ${AI_BASE} (running as root)" + else + echo "" + echo -e "${YELLOW}${AI_BASE} does not exist.${NC}" + echo " This is the managed working directory for all AI tools." + echo "" + read -p "Create ${AI_BASE} now? [Y/n] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + if command -v sudo &>/dev/null; then + sudo mkdir -p "$AI_BASE" + sudo chown "$(id -u):$(id -g)" "$AI_BASE" + info "Created ${AI_BASE}" + else + error "Need sudo/root to create ${AI_BASE}. Create it manually and re-run." + fi + else + echo "" + read -p "Alternative base directory? [${SCRIPT_DIR}/ai-stack] " ALT_BASE + ALT_BASE="${ALT_BASE:-${SCRIPT_DIR}/ai-stack}" + mkdir -p "$ALT_BASE" + warn "Using ${ALT_BASE}" + AI_BASE="$ALT_BASE" + export AI_LSC_BASE_DIR="$AI_BASE" + fi + fi +fi + +# ── Helper: get system Python version string ────────────────── +_sys_python_version() { + python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')" +} + +# ── Helper: check if venv is stale (Python version mismatch) ── +_venv_is_stale() { + if [ ! -f "$VENV_PYTHON_STAMP" ]; then + return 0 # no stamp → treat as stale + fi + local stamp_version + stamp_version="$(cat "$VENV_PYTHON_STAMP")" + local sys_version + sys_version="$(_sys_python_version)" + if [ "$stamp_version" != "$sys_version" ]; then + return 0 # version mismatch → stale + fi + return 1 # versions match → fresh +} + +# ── Detect OS ────────────────────────────────────────────────── +if command -v pacman &>/dev/null; then + PKG_MANAGER="pacman" + info "Detected Arch Linux (pacman)" +elif command -v apt-get &>/dev/null; then + PKG_MANAGER="apt" + warn "Detected Debian/Ubuntu — some packages may differ from Arch names" +elif command -v dnf &>/dev/null; then + PKG_MANAGER="dnf" + warn "Detected Fedora/RHEL — some packages may differ from Arch names" +else + warn "Unknown package manager. You may need to install dependencies manually." + PKG_MANAGER="manual" +fi + +# ── System Dependencies ─────────────────────────────────────── +echo "" +info "Installing system dependencies..." + +if [ "$PKG_MANAGER" = "pacman" ]; then + SUDO="" + if [ "$(id -u)" -ne 0 ]; then + if command -v sudo &>/dev/null; then + SUDO="sudo" + fi + fi + + # Core system packages (python-pyside6 is NOT in official repos — + # we attempt it, then fall back to pip inside the venv below) + $SUDO pacman -Sy --noconfirm --needed \ + python \ + python-pip \ + git \ + tmux \ + ripgrep \ + fd \ + tree-sitter \ + sqlite \ + redis \ + base-devel \ + || warn "Some system packages failed to install (non-critical)" + + # Try python-pyside6 from pacman if available (AUR / community) + if $SUDO pacman -Si python-pyside6 &>/dev/null; then + $SUDO pacman -Sy --noconfirm --needed python-pyside6 \ + || warn "python-pyside6 pacman install failed (will try pip fallback)" + else + warn "python-pyside6 not available in repos — will install via pip" + fi + + info "Core system packages installed." + + echo "" + read -p "Install NVIDIA CUDA support? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + $SUDO pacman -Sy --noconfirm --needed cuda || warn "CUDA install failed" + info "CUDA toolkit installed." + fi + + read -p "Install container runtimes (podman, docker)? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + $SUDO pacman -Sy --noconfirm --needed podman docker || warn "Container runtimes install failed" + info "Container runtimes installed." + fi + + read -p "Install LXC support? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + $SUDO pacman -Sy --noconfirm --needed lxc lxcfs || warn "LXC install failed" + info "LXC support installed." + fi + +elif [ "$PKG_MANAGER" = "apt" ]; then + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv python3-pyside6.qt6 \ + git tmux ripgrep fd-find sqlite3 redis-server \ + build-essential \ + || warn "Some system packages failed to install" + info "Core system packages installed." + +elif [ "$PKG_MANAGER" = "dnf" ]; then + sudo dnf install -y \ + python3 python3-pip git tmux ripgrep fd-find sqlite redis \ + || warn "Some system packages failed to install" + info "Core system packages installed." +fi + +# ── Python Virtual Environment ───────────────────────────────── +echo "" + +_create_venv() { + if [ "$PKG_MANAGER" = "pacman" ]; then + # Arch: --system-site-packages so venv sees pacman-installed PySide6 + python3 -m venv --system-site-packages "$VENV_DIR" + else + python3 -m venv "$VENV_DIR" + fi + _sys_python_version > "$VENV_PYTHON_STAMP" +} + +if [ ! -d "$VENV_DIR" ]; then + info "Creating Python virtual environment at ${VENV_DIR}..." + _create_venv +else + if _venv_is_stale; then + old_ver="" + if [ -f "$VENV_PYTHON_STAMP" ]; then + old_ver="$(cat "$VENV_PYTHON_STAMP")" + fi + warn "Virtual environment is stale (was Python ${old_ver}, system is now $(_sys_python_version))" + warn "Removing old venv and recreating..." + rm -rf "$VENV_DIR" + _create_venv + info "Virtual environment recreated with Python $(_sys_python_version)" + else + info "Virtual environment already exists and is up-to-date at ${VENV_DIR}" + fi +fi + +info "Activating virtual environment..." +source "$VENV_DIR/bin/activate" + +# ── Verify venv activation (critical on Arch) ─────────────────── +if [ ! -f "${VENV_DIR}/bin/pip" ]; then + error "Virtual environment pip not found at ${VENV_DIR}/bin/pip — venv may be broken. Delete ${VENV_DIR} and re-run." +fi + +VENV_PIP="${VENV_DIR}/bin/pip" + +# ── Python Dependencies (inside venv — safe from EXTERNALLY-MANAGED) ── +echo "" +info "Installing Python dependencies into venv..." + +$VENV_PIP install --upgrade pip setuptools wheel --quiet + +# PySide6 — on Arch this comes from pacman (already installed above). +if [ "$PKG_MANAGER" = "pacman" ]; then + if "${VENV_DIR}/bin/python3" -c "import PySide6" 2>/dev/null; then + info "PySide6 (system) — OK" + else + warn "PySide6 (system) not visible in venv — attempting pip install..." + $VENV_PIP install PySide6 --quiet 2>/dev/null || { + warn "PySide6 installation failed. The app will run in headless mode." + } + fi +else + $VENV_PIP install PySide6 --quiet 2>/dev/null || { + warn "PySide6 installation failed. The app will run in headless mode." + } +fi + +# uv — fast Python package manager +$VENV_PIP install uv --quiet 2>/dev/null || warn "uv installation failed (non-critical)" + +# ── Verify Installation ────────────────────────────────────── +echo "" +info "Verifying installation..." + +ERRORS=0 + +PY_VER="${VENV_DIR}/bin/python3" +PY_VER_STR=$("$PY_VER" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") +PY_MAJOR=$("$PY_VER" -c "import sys; print(sys.version_info.major)") +PY_MINOR=$("$PY_VER" -c "import sys; print(sys.version_info.minor)") +if [ "$PY_MAJOR" -ge 3 ] && [ "$PY_MINOR" -ge 11 ]; then + info "Python ${PY_VER_STR} (venv) — OK" +else + warn "Python ${PY_VER_STR} (venv) — recommend 3.11+" +fi + +if "$PY_VER" -c "import PySide6" 2>/dev/null; then + info "PySide6 — OK" +else + warn "PySide6 — NOT FOUND (UI will be unavailable)" + ERRORS=$((ERRORS + 1)) +fi + +# Check registry loads (pass AI_LSC_BASE_DIR so it resolves correctly) +cd "$SCRIPT_DIR" +if AI_LSC_BASE_DIR="$AI_BASE" "$PY_VER" -c " +import sys +sys.path.insert(0, 'src') +from ai_lsc.registry.loader import load_merged_registry +reg = load_merged_registry() +print(f' Registry: {len(reg)} tools loaded') +" 2>/dev/null; then + info "Registry — OK" +else + warn "Registry — could not load (check file structure)" + ERRORS=$((ERRORS + 1)) +fi + +for cmd in ollama podman docker tmux ripgrep fd tree-sitter; do + if command -v "$cmd" &>/dev/null; then + info "${cmd} — found" + else + warn "${cmd} — not found (optional)" + fi +done + +# ── Summary ─────────────────────────────────────────────────── +echo "" +if [ "$ERRORS" -eq 0 ]; then + echo -e "${GREEN}${BOLD}Bootstrap complete! AI Local Stack Control is ready.${NC}" + echo "" + echo -e " ${CYAN}Base dir: ${AI_BASE}${NC}" + echo "" + echo " Launch the application:" + echo " cd ${SCRIPT_DIR}" + echo " python ai_lsc.py" + echo "" + echo " Or use the convenience launcher:" + echo " bash run.sh" + echo "" +else + echo -e "${YELLOW}Bootstrap complete with ${ERRORS} warning(s).${NC}" + echo "The application may run in limited mode. Review warnings above." +fi + +# ── Write env file so run.sh and ai_lsc.py can pick it up ──── +cat > "${SCRIPT_DIR}/.env" < *This is the single architectural definition for AI-LSC. Every module, every +> template, every resolver path either implements something defined here or it +> does not belong.* + +--- + +## Status + +**Accepted.** Adopted as the foundational architecture for v3.0 (Ankh of Jah) +and all subsequent releases. The agentic execution layer is deferred to v4.0. + +--- + +## 1. Context + +AI-LSC did not begin as an architecture. It began as a question: + +> "Can I stop manually juggling a dozen AI tools on a Linux machine?" + +v1 answered: *yes, with a monolithic script.* +v2 answered: *yes, with a modular registry and layers.* +v3 answers a different question entirely: + +> "Can a system *understand* AI infrastructure well enough to deploy, +> validate, diagnose, and reproduce it — without the operator thinking +> about individual tools?" + +The shift is from tool-first to system-first. Earlier development asked +"how do we add support for X?" Current development asks "where does X belong +in the architecture?" That is not a cosmetic change. It is a phase change. + +Three releases revealed a consistent pattern: the same architectural verbs +kept reappearing across unrelated features. Install, verify, configure, +launch, monitor, export, diagnose, reproduce. Every tool needed them. Every +stack needed them. Every container needed them. The repetition was not a +failure to abstract — it was evidence of an abstraction waiting to be named. + +This document names it. + +--- + +## 2. The Foundational Object: Capability + +Every system has one concept that, if removed, causes the entire structure to +collapse. For AI-LSC, that concept is **Capability**. + +A Capability is a named, validated unit of infrastructure that a machine either +possesses or does not. It is not a tool. It is not a process. It is not a +package. It is a *statement about the machine*. + +``` +"Inference" — this machine can run LLM inference. +"Vector Store" — this machine can store and query embeddings. +"Monitoring" — this machine can observe its own services. +"GPU Compute" — this machine has CUDA/cuDNN available. +``` + +Capabilities are discovered, not declared. A tool *provides* capabilities. A +template *requires* capabilities. A pipeline *consumes* capabilities. A +container *exports* capabilities. A dashboard *reports* capabilities. A skill +*extends* capabilities. Monitoring *validates* capabilities. + +Every subsystem points at Capability. No subsystem points at Tool directly +except the Registry, which maps tools to the capabilities they provide. + +This single inversion eliminates most of the coupling in the application: + +``` +Tool ──provides──► Capability ◄──requires── Template + ▲ +Pipeline ──consumes──────┘ + ▲ +Container ──exports───────┘ + ▲ +Dashboard ──reports───────┘ + ▲ +Skill ──extends───────┘ + ▲ +Monitoring ──validates─────┘ +``` + +Swap Ollama for vLLM. Swap Grafana for another observability stack. Swap +Qdrant for Milvus. Everything above the Registry layer does not notice. +The capability model remains stable even when implementations evolve, +technologies are replaced, or entirely new categories of AI software emerge. + +--- + +## 3. The Architecture Pipeline + +AI-LSC is not an installer. It is a pipeline from intent to infrastructure. + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ USER INTENT │ +│ │ +│ "I want a Research Workstation" │ +│ "I want a RAG Server" │ +│ "I want a GPU Inference Cluster" │ +│ "I want a Coding Assistant" │ +└────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ TEMPLATE (Recipe) │ +│ Desired Architecture │ +│ │ +│ Research Workstation │ RAG Appliance │ Inference Node │ +└────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ RESOLVER │ +│ Infrastructure Planning │ +│ │ +│ • Detect hardware • Detect OS │ +│ • Detect installed sw • Detect conflicts │ +│ • Expand dependencies • Select implementations │ +│ • Produce execution plan │ +└────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ REGISTRY │ +│ Individual Components │ +│ │ +│ Every tool knows: Install · Update · Verify · Launch │ +│ Health · Configure · Container · Export │ +└────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ RUNTIME │ +│ │ +│ Native · Podman · Docker · LXC · Cluster · Remote │ +└──────────────────────────────────────────────────────────────────┘ +``` + +The Resolver is the brain. It is the only component that translates between +the declarative world of templates and the imperative world of package +managers, container runtimes, and service launchers. No other component +performs this translation. This constraint ensures that adding a new runtime +target (say, Kubernetes) requires changes only in the Registry (new tool +entries) and Runtime (new executor), never in templates or pipelines. + +--- + +## 4. Stack Recipes (Templates as Intent) + +### 4.1 What a Template Is + +A template is infrastructure intent, not an install script. It declares what +the operator wants the machine to become. It does not duplicate install +logic, configuration logic, or launch logic — the Registry already owns all +of that. + +The current template format is a flat list of tool IDs. This is functional +but insufficient for the capability architecture. The evolved format — the +**Stack Recipe** — declares capabilities, roles, connections, and startup +semantics: + +```yaml +# Stack Recipe — evolved template format (v4.0 target) +stack: + name: Claude Memory Assistant + version: "1.0" + maturity: official # official | community | local | frozen + +capabilities: + required: + - inference # needs an LLM engine + - vector_database # needs embedding storage + - relational_database # needs structured storage + - web_interface # needs a browser-accessible UI + optional: + - monitoring + - automation + +components: + inference: + engine: ollama + model: llama3 + memory: + vectordb: qdrant + embedding_model: nomic-embed-text + database: + engine: postgres + ui: + provider: open_webui + +connections: + - from: inference + to: vector_database + protocol: embedding + - from: inference + to: relational_database + protocol: session_store + - from: ui + to: inference + protocol: openai_compat + +startup: + order: + 1. relational_database + 2. vector_database + 3. inference + 4. ui + health_wait: + - relational_database # UI waits until DB is accepting connections + - vector_database + - inference + +health: + checks: + - capability: inference + probe: GET /api/tags + - capability: vector_database + probe: GET /collections +``` + +### 4.2 What a Template Is Not + +A template does not contain: + +- Installation commands (the Registry knows how to install) +- File paths (the Resolver knows the layout) +- Port assignments (conflict detection is automatic) +- OS-specific logic (the Resolver handles this) +- Dependency installation order beyond what `startup.order` declares + +A template also does not hardcode implementations. It specifies roles: + +```yaml +components: + vector_database: + role: vector_store # NOT "qdrant" +``` + +The Resolver maps `vector_store` to whatever provider is installed or +available. On one machine that is Qdrant. On another it is Milvus. On a +third the Resolver recommends Chroma. The template never changes. + +### 4.3 Template Maturity + +Templates have a maturity level that signals trust and intent: + +| Level | Meaning | Use Case | +|-------|---------|----------| +| **Official** | Maintained by the AI-LSC project | Curated reference stacks | +| **Community** | Shared by users, reviewed | Experimentation, collaboration | +| **Local** | Created by the operator | Personal workflows, one-off stacks | +| **Frozen** | Exact snapshot of a validated environment | Reproducibility, CI/CD, audit | + +A Frozen template pins every version, every config hash, every capability +signature. Deploying a Frozen template on a different machine produces a +bit-for-bit equivalent environment. This is the mechanism for long-term +reproducibility — not containerization alone, but declarative infrastructure +with verified provenance. + +--- + +## 5. Role-Based Resolution + +The critical distinction between AI-LSC and every other "AI launcher" is +that templates specify **roles**, not implementations. + +A role is a capability category with multiple possible providers: + +``` +Role: Inference Engine + Providers: Ollama · llama.cpp · vLLM · TensorRT-LLM · LM Studio + +Role: Vector Database + Providers: Qdrant · Chroma · Milvus · Weaviate · FAISS + +Role: LLM Gateway + Providers: LiteLLM · OpenRouter · Local proxy + +Role: Monitoring + Providers: Grafana + Prometheus · Glances · Netdata + +Role: Agent Frontend + Providers: Open WebUI · LibreChat · AnythingLLM · Continue +``` + +The Resolver performs role resolution in this order: + +1. **Already installed?** Use what is present. +2. **Compatible with hardware?** Select the best fit (GPU → CUDA-aware provider). +3. **Template preference?** Honor explicit provider hints. +4. **Fallback chain.** Try each candidate in order. +5. **Recommend.** If nothing installs cleanly, report what is needed. + +This means a single template shared between two machines can resolve to +completely different toolsets: + +``` +"Research Workstation" template + +Laptop (CPU-only): + → llama.cpp (CPU inference) + → LiteLLM (gateway) + → Chroma (lightweight vector store) + → Open WebUI (interface) + +Desktop (RTX 4090): + → Ollama (CUDA inference) + → vLLM (high-throughput serving) + → Qdrant (production vector store) + → LibreChat (multi-provider interface) +``` + +Same template. Different reality. The Resolver is what makes that work. + +--- + +## 6. Component Connections + +Installing tools side-by-side is not an architecture. Understanding how they +interact is. + +The Stack Recipe format includes a `connections` section that declares +relationships between components. These are not just documentation — they are +inputs to the Stack Doctor (Section 12) and the Resolver's validation +engine. + +A connection declaration: + +```yaml +connections: + - from: ui # Open WebUI + to: inference # Ollama + protocol: openai_compat # Expects OpenAI-compatible API + - from: ui + to: vector_database + protocol: embedding # Needs embedding endpoint +``` + +The Resolver uses connections to: + +- Validate that protocols are compatible (OpenAI-compat ↔ OpenAI-compat). +- Detect likely misconfigurations (OLLAMA_HOST=localhost when UI is remote). +- Generate connection-specific health checks. +- Produce diagnostic suggestions when connections fail. + +This is dependency injection for infrastructure. The template declares the +graph. The Resolver validates the graph. The Runtime instantiates the graph. + +--- + +## 7. The 13-Layer Model + +AI-LSC organizes all AI infrastructure into 13 layers. Each layer represents +a category of capability. Tools register into one (sometimes two) layers. +Templates reference layers instead of individual tools when expressing +broad requirements. + +``` +Layer 1 Host Platform — OS, kernel, filesystem, base packages +Layer 2 Development Env — Python, Rust, Node.js, Go, build tools +Layer 3 GPU Runtime — CUDA, cuDNN, ROCm, Vulkan compute +Layer 4 Inference Engines — Ollama, llama.cpp, vLLM, TensorRT-LLM +Layer 5 Distributed Runtime — Ray, Kubeflow, cluster schedulers +Layer 6 AI Endpoints — LiteLLM, model routers, API gateways +Layer 7 Data & Knowledge — PostgreSQL, MariaDB, data pipelines +Layer 8 Knowledge Management — Qdrant, Chroma, Milvus, vector stores +Layer 9 Automation & Execution — n8n, Airflow, task schedulers +Layer 10 Observability — Prometheus, Grafana, Glances, logging +Layer 11 Intelligent Routing — Fabric, Hermes, agent dispatchers +Layer 12 User Interfaces — Open WebUI, LibreChat, AnythingLLM +Layer 13 Containers — Podman, Docker, LXC, export targets +``` + +A template can express requirements by layer: + +```yaml +capabilities: + layers: + - Inference Engines # Layer 4 + - AI Endpoints # Layer 6 + - Knowledge Management # Layer 8 + - User Interfaces # Layer 12 +``` + +The Resolver fills in everything else. If the template needs inference +(Layer 4) and the host has no GPU (Layer 3), the Resolver knows to +recommend CPU-only providers and skip CUDA-dependent tools automatically. + +### Stress Test + +The 13-layer model must accommodate any AI project without forcing it. A +non-exhaustive validation set: + +| Project | Natural Layer Fit | +|---------|-------------------| +| Open WebUI | 12 (User Interfaces) | +| LiteLLM | 6 (AI Endpoints) | +| Qdrant | 8 (Knowledge Management) | +| Ollama | 4 (Inference Engines) | +| vLLM | 4 (Inference Engines) | +| ComfyUI | 12 (User Interfaces) | +| Flowise | 12 (User Interfaces) | +| n8n | 9 (Automation & Execution) | +| Prometheus | 10 (Observability) | +| Ray | 5 (Distributed Runtime) | +| Langflow | 12 (User Interfaces) | +| Chroma | 8 (Knowledge Management) | +| Milvus | 8 (Knowledge Management) | +| llama.cpp | 4 (Inference Engines) | +| TensorRT-LLM | 4 (Inference Engines) | +| OpenHands | 12 (User Interfaces) | +| Aider | 2 (Development Env) | +| Continue | 2 (Development Env) | +| Kubeflow | 5 (Distributed Runtime) | +| Kafka | 7 (Data & Knowledge) | + +Every project in the validation set fits naturally into exactly one layer. +None require special casing. The model appears to generalize well. + +--- + +## 8. Skills as Derived Capabilities + +Skills are not file lookups. They are capability queries. + +The old model: "Does this Python file exist in the skills directory?" +The new model: "Does this machine currently possess this capability?" + +Skills derive from deployed, validated infrastructure: + +``` +Template: Research Workstation + │ + ▼ Deployed + │ + ▼ Verified + │ + ▼ Registered as Capabilities + │ + ▼ Skills become available: + │ + ├── "Local RAG" (has: inference + vector_store + ui) + ├── "Python AI" (has: development + inference) + ├── "Vision" (has: inference + multimodal_model) + ├── "Speech" (has: inference + whisper + tts) + └── "Distributed Inference" (has: inference + distributed_runtime) +``` + +A skill definition references capabilities, not tools: + +```yaml +skill: + name: Local RAG + requires: + capabilities: [inference, vector_database, web_interface] + optional: + capabilities: [monitoring, relational_database] + description: > + End-to-end retrieval-augmented generation using local models. + Available when the machine has an inference engine, a vector store, + and a web interface — regardless of which specific tools provide them. +``` + +This means installing a new tool that provides an existing capability can +silently unlock skills the operator never explicitly configured. Replace +Qdrant with Milvus and every RAG skill still works, because the capability +did not change — only the provider did. + +--- + +## 9. Pipelines Consume Capabilities + +A pipeline is a directed graph of capability requirements. It never names a +tool. It names what it needs: + +``` +Pipeline: Document RAG + + [Source] → [Chunking] → [Embedding] → [Vector Store] → [Retriever] → [LLM] → [Output] +``` + +Each node is a capability. The Resolver maps each node to a tool at runtime: + +``` +Embedding: + → nomic-embed-text (via Ollama) + or + → bge-small (via llama.cpp) + +Vector Store: + → Qdrant + or + → Chroma + +LLM: + → Ollama (llama3) + or + → vLLM (deepseek-coder-33b) +``` + +The pipeline graph never changes when implementations change. This is what +makes pipelines portable across machines, containers, and clusters. + +--- + +## 10. Container Export as Capability Export + +A container image is not a bag of tools. It is a frozen capability set. + +When an operator exports a Research Workstation to Podman, the exported +image carries a capability manifest alongside the filesystem layers: + +``` +Research_Workstation_v1.0 + + Capabilities: + ✓ Inference (Ollama, llama3) + ✓ GPU Compute (CUDA 12.4, cuDNN 9.1) + ✓ Vector Database (Qdrant) + ✓ LLM Gateway (LiteLLM) + ✓ Web Interface (Open WebUI) + ✓ Monitoring (Prometheus + Grafana) + ✓ Relational Database (PostgreSQL) + + Stack Recipe: embedded (frozen) + Template: Research Workstation v1.0 + Exported: 2026-06-28 + Architecture: x86_64 +``` + +When another machine imports this image, AI-LSC reads the manifest and +immediately knows what the container provides — no scanning, no probing, no +guessing. The capabilities are declared, trusted, and verified. + +Export targets are format-agnostic: + +``` +Recipe → Resolver → Generate Deployment + ├── Podman Quadlet + ├── Docker Compose + ├── LXC Config + └── Kubernetes YAML (future) +``` + +The recipe never changes. Only the exporter changes. + +--- + +## 11. Dashboards Report Capability Health + +The dashboard does not display process status. It displays infrastructure +health. + +``` +┌──────────────────────────────────────────────────────┐ +│ Research Workstation ████████ 92%│ +│ │ +│ Host Platform ✓ │ +│ Development Env ✓ │ +│ GPU Runtime ⚠ CUDA Update Available │ +│ Inference Engines ✓ Ollama · llama3 │ +│ AI Endpoints ✓ LiteLLM :4000 │ +│ Data & Knowledge ✓ PostgreSQL :5432 │ +│ Knowledge Management ✓ Qdrant :6333 │ +│ Automation — │ +│ Observability ✓ Grafana · Prometheus │ +│ Intelligent Routing ✓ Fabric │ +│ User Interfaces ✓ Open WebUI :8080 │ +│ Containers 2 specialist images │ +│ │ +│ Templates: 7 installed Skills: 12 available │ +└──────────────────────────────────────────────────────┘ +``` + +Each row is a capability, not a tool. The status reflects whether the +machine possesses that capability in a healthy state, regardless of which +tool provides it. If the operator swaps Grafana for Netdata, the +Observability row still shows the same status — because the capability +did not change. + +--- + +## 12. Stack Doctor + +The Stack Doctor is a reasoning engine, not a log viewer. It understands +relationships between components and can diagnose problems that span multiple +tools. + +Example diagnosis: + +``` +DIAGNOSIS: Open WebUI cannot reach Ollama + +REASON: OLLAMA_HOST is set to localhost (127.0.0.1) + but Open WebUI is configured to connect to port 11434 + on all interfaces. Connection is refused. + +RECOMMENDATION: + Option A: Set OLLAMA_HOST=0.0.0.0 in Ollama environment + Option B: Bind Open WebUI to localhost only + Option C: Route through LiteLLM proxy +``` + +Example conflict detection: + +``` +DIAGNOSIS: Port conflict detected + + LiteLLM wants port 4000 ✓ (available) + vLLM wants port 8000 ✗ (occupied by TensorRT-LLM) + +RECOMMENDATION: + Move LiteLLM to port 4001 + or + Disable TensorRT-LLM if not needed +``` + +The Stack Doctor uses the connection graph from the Stack Recipe to trace +problems across component boundaries. It does not just check if a process is +running — it checks if the *capability chain* is intact from end to end. + +--- + +## 13. Operator Workflows + +### 13.1 Missions + +Complex deployments are presented as **Missions**, not wizards. A Mission +is a named, scoped objective with a clear completion state: + +``` +┌──────────────────────────────────────────────────────┐ +│ MISSION: Build Coding Assistant │ +│ │ +│ Estimated effort: 8 minutes │ +│ Status: Planning... │ +│ │ +│ [✓] Validate host platform │ +│ [✓] Detect installed capabilities │ +│ [→] Resolve missing dependencies │ +│ [ ] Install Python (Layer 2) │ +│ [ ] Install Ollama (Layer 4) │ +│ [ ] Install LiteLLM (Layer 6) │ +│ [ ] Install Open WebUI (Layer 12) │ +│ [ ] Configure connections │ +│ [ ] Verify health │ +│ [ ] Export ready │ +└──────────────────────────────────────────────────────┘ +``` + +### 13.2 Routines + +Routines are reusable infrastructure actions, not application macros: + +| Routine | Actions | +|---------|---------| +| **Morning Check** | Verify all services, restart unhealthy, check updates, check GPU, check disk | +| **Pre-Inference** | GPU memory, temperature, ports, models, KV cache, endpoint ready | +| **Before Export** | Verify services, verify configs, clean logs, freeze versions, generate manifest | +| **Before Commit** | Lint, test, validate registry, validate templates, schema check | + +One button. Comprehensive validation. + +### 13.3 Next Best Action + +AI-LSC suggests the operator's next step based on current state: + +``` +Good morning. + ✓ GPU healthy + ✓ Ollama healthy + ⚠ Open WebUI update available (v0.3.12 → v0.3.14) + ⚠ Research Workstation template has 1 missing dependency + +Suggested: Verify Research Workstation +``` + +This is not AI. It is deterministic inference over the capability graph. +The system knows what is installed, what is healthy, what is outdated, and +what templates require. The recommendation follows directly. + +### 13.4 Activity Timeline + +Every infrastructure action is recorded with a timestamp: + +``` +09:13 Installed LiteLLM +09:15 Verified CUDA (driver 550.54, CUDA 12.4) +09:16 Generated template: Research Workstation +09:20 Exported Podman image: research_ws_v1.0 +09:27 Health check passed (13/13 capabilities) +``` + +Timelines are queryable, filterable, and exportable. They provide audit +trail and operational memory. + +### 13.5 Workspaces + +Workspaces group related infrastructure by purpose, not by tool: + +``` +Research → inference + vector_db + ui + monitoring +Coding → development + inference + endpoints + ui +RAG → inference + vector_db + relational_db + ui +Cluster → distributed + inference + monitoring + containers +``` + +Click a workspace. Everything related appears. One context for one purpose. + +--- + +## 14. Adaptive Templates + +A single template adapts to the host hardware, installed software, and +available runtimes. The Resolver selects implementations based on +constraints, not preferences. + +``` +"Research Workstation" on different hardware: + +Laptop (CPU, 16GB RAM): + → llama.cpp (quantized, CPU inference) + → Chroma (in-process vector store, minimal memory) + → LiteLLM (lightweight gateway) + → Glances (lightweight monitoring) + → Open WebUI (browser interface) + +Desktop (RTX 4090, 64GB RAM): + → Ollama (CUDA-accelerated inference) + → Qdrant (production vector store with GPU-accelerated HNSW) + → LiteLLM + vLLM (dual gateway: fast + thorough) + → Prometheus + Grafana (full monitoring stack) + → LibreChat (multi-provider interface) + +Server (Dual MI300X, 256GB RAM): + → SGLang (ROCm-optimized inference) + → Milvus (distributed vector store) + → LiteLLM (cluster gateway) + → Prometheus + Grafana + AlertManager (production monitoring) + → Open WebUI (load-balanced) +``` + +Same template. Same intent. Different reality. The Resolver is what makes +the template portable. + +--- + +## 15. Rationale + +### Why Capability as the central abstraction? + +Because tools are ephemeral. The AI landscape changes monthly. New inference +engines appear. Old ones are abandoned. Monitoring stacks get replaced. +Vector databases get acquired and deprecated. + +But the *capabilities* those tools provide are remarkably stable. "The +machine can run LLM inference" has been true since 2023 and will be true +in 2030. The implementation changes. The capability does not. + +Building around capabilities means AI-LSC's architecture decays at the +rate of the AI industry's *conceptual* evolution, not its *tool* churn. +Conceptual evolution is orders of magnitude slower. + +### Why not just use Terraform / Kubernetes? + +Because those tools solve a different problem. Terraform manages cloud +infrastructure declaratively. Kubernetes orchestrates containers at scale. +Neither understands that "install Qdrant" implies "the machine now has +vector database capability" — nor should they. That is AI-LSC's domain. + +AI-LSC is specifically designed for the local AI operator who needs to +assemble, validate, and reproduce AI stacks on single machines or small +clusters. It fills the gap between "install scripts" and "cloud +orchestration." + +### Why role-based resolution instead of tool-specific templates? + +Because a template that hardcodes Qdrant cannot run on a machine that only +has Milvus. A template that hardcodes Ollama cannot leverage an existing +vLLM installation. Role-based resolution makes templates portable, +shareable, and future-proof without requiring the template author to +anticipate every possible provider. + +--- + +## 16. Consequences + +### Positive + +- **Tool swaps are zero-cost above the Registry.** Replacing a provider + requires only a new Registry entry with the same capability mapping. + Templates, pipelines, skills, and dashboards are unaffected. +- **Templates are shareable across heterogeneous hardware.** The same + recipe produces appropriate deployments on laptops, desktops, and + servers. +- **New capabilities can be added without modifying existing templates.** + Adding a "Speech-to-Text" capability does not require touching any + Research Workstation template. +- **Container exports carry semantic meaning**, not just filesystem + state. Importing a container immediately reveals its capabilities. +- **Diagnostics can reason about relationships**, not just individual + process health. + +### Neutral + +- **The Resolver is the most complex component.** It must understand + hardware detection, OS differences, dependency graphs, conflict + resolution, and provider selection. This is acceptable because the + Resolver is a single, well-bounded component. +- **The capability vocabulary must be curated.** New capabilities require + consensus on naming, boundaries, and provider criteria. This is a + governance concern, not a technical one. + +### Risks + +- **Over-abstraction.** If the capability vocabulary is too coarse + ("compute"), it loses discriminating power. If too fine ("qdrant-hnsw- + gpu"), it reverts to tool-specific coupling. The granularity must be + calibrated through real-world use. +- **Resolver complexity.** A naive Resolver that tries all combinations + is NP-hard. The Resolver must use heuristics, caching, and constraint + propagation to remain fast. +- **Capability drift.** As the AI ecosystem evolves, capabilities may + split or merge. "Inference" might split into "Text Inference" and + "Multimodal Inference." The architecture must handle capability + evolution without breaking existing templates. + +--- + +## 17. Architecture Completeness + +Current state of implementation (v3.0 Ankh of Jah): + +``` +Registry (tool metadata, 115 tools) ████████████░ 95% +Templates (stack recipes, 4 templates) ██████░░░░░░ 55% +Resolver (dependency expansion, planning) ███░░░░░░░░░ 30% +Installer (native, git, npm, pip) ████████████░ 95% +Verification (install checks, health probes) ██████████░░░ 85% +Health (service status, GPU monitoring) ███████░░░░░ 65% +Export (Podman, Docker, LXC configs) ████████░░░░ 80% +Monitoring (glances integration, Prometheus) █████░░░░░░░ 50% +Skills (capability-derived skills) ███░░░░░░░░░ 25% +Pipelines (capability graph execution) ██░░░░░░░░░░ 20% +Dashboards (capability health display) ████░░░░░░░░ 35% +Stack Doctor (diagnostic reasoning) ██░░░░░░░░░░ 15% +Missions (guided deployment flows) █░░░░░░░░░░░ 10% +Workspaces (purpose-based grouping) ███░░░░░░░░░ 25% +Activity Timeline ██░░░░░░░░░░ 20% +Next Best Action █░░░░░░░░░░░ 10% +Documentation (this ADR, README, guides) ██████░░░░░░ 55% +Tests ██░░░░░░░░░░ 20% +``` + +The pattern is clear: the foundation (Registry, Installer, Verification) is +strong. The intelligence layer (Resolver, Stack Doctor, Missions) is where +the next investment goes. The UI layer (Dashboards, Workspaces, Timeline) +follows the intelligence layer. + +--- + +## 18. Feature Policy (Ankh of Jah Stabilization) + +v3.0 enters a stabilization phase. Feature velocity decreases; stability +velocity increases. + +### Allowed + +- Bug fixes +- Registry additions (new tool metadata, new providers) +- New templates (stack recipes) +- Installer verification and hardening +- UI polish and usability improvements +- Documentation +- Tests +- Capability vocabulary refinement +- Resolver heuristic improvements + +### Not Allowed + +- New architectural concepts +- New runtime systems +- Major UI redesigns +- New registry formats (schema changes) +- Agent execution (deferred to v4.0) +- Cluster orchestration (deferred to v4.0) +- Remote node management (deferred to v4.0) + +### v4.0 Scope (Deferred) + +The agentic execution layer — where an LLM operates AI-LSC through +function-calling, using the agents/ bridge to start/stop services, pull +models, inject skills, and diagnose issues through natural language. This +is architecturally designed (agents/ package exists, tool_bridge and +ollama_tools are implemented, Redis pub/sub infrastructure is in place) +but intentionally not activated in v3.0. + +--- + +## 19. Project Philosophy + +AI-LSC is a native-first, metadata-driven infrastructure manager for local +AI systems. It treats AI software as reusable infrastructure rather than +isolated applications, enabling reproducible deployments, validation, +monitoring, and export of complete AI environments. + +This single paragraph is the decision filter for every proposed feature. +If a feature supports this philosophy — making AI infrastructure easier to +deploy, validate, reproduce, and understand — it belongs. If it does not, +it does not. + +AI-LSC's biggest competitor is not another AI launcher. It is the manual +process that most developers still follow: reading installation guides, +cloning repositories, creating Python environments, debugging version +conflicts, writing ad hoc shell scripts, and hoping they can recreate the +setup six months later. + +If AI-LSC can replace that with: select a template, review the execution +plan, deploy, verify, export — then it has solved a real engineering +problem. + +--- + +## 20. The Architectural Vocabulary + +These terms are stable. They will not change in v3.0 patches. They may +evolve in v4.0, but only with explicit ADR amendment. + +| Term | Definition | +|------|-----------| +| **Capability** | A named, validated unit of infrastructure that a machine possesses or does not. The central abstraction. | +| **Template / Stack Recipe** | A declarative document expressing infrastructure intent. Specifies capabilities and roles, not tools. | +| **Resolver** | The planning engine that maps intent to execution. Detects hardware, resolves roles, expands dependencies, produces plans. | +| **Registry** | The knowledge base of individual tools. Each entry maps a tool to its capabilities, installers, launchers, health probes, and exporters. | +| **Role** | A capability category with multiple possible providers (e.g., "Vector Database" → Qdrant, Chroma, Milvus). | +| **Skill** | A capability-derived behavior. Available when all required capabilities are present and healthy. | +| **Pipeline** | A directed graph of capability requirements. Consumes capabilities; does not name tools. | +| **Connection** | A declared relationship between two components in a Stack Recipe. Used for validation and diagnostics. | +| **Stack Doctor** | A diagnostic reasoning engine that traces problems across component boundaries using the connection graph. | +| **Mission** | A named, scoped deployment objective with a clear completion state. | +| **Routine** | A reusable infrastructure action (health check, pre-flight, cleanup). | +| **Workspace** | A purpose-based grouping of related infrastructure. | +| **Frozen** | An exact snapshot of a validated environment, pinned at every version. | +| **Layer** | One of 13 categories of AI infrastructure. Tools register into layers. Templates can reference layers. | +| **Runtime** | The execution target: native, Podman, Docker, LXC, cluster, or remote. | + +--- + +*Ankh of Jah marks the point where AI-LSC stopped being a Python application +and became a platform architecture. Future releases build on this foundation. +They do not revisit it.* diff --git a/docs/screenshots/chat-console.png b/docs/screenshots/chat-console.png new file mode 100644 index 0000000..a2957a9 Binary files /dev/null and b/docs/screenshots/chat-console.png differ diff --git a/docs/screenshots/code-analysis.png b/docs/screenshots/code-analysis.png new file mode 100644 index 0000000..ad3b453 Binary files /dev/null and b/docs/screenshots/code-analysis.png differ diff --git a/docs/screenshots/deployment-targets.png b/docs/screenshots/deployment-targets.png new file mode 100644 index 0000000..013a308 Binary files /dev/null and b/docs/screenshots/deployment-targets.png differ diff --git a/docs/screenshots/infrastructure-layers.png b/docs/screenshots/infrastructure-layers.png new file mode 100644 index 0000000..c4e4bbb Binary files /dev/null and b/docs/screenshots/infrastructure-layers.png differ diff --git a/docs/screenshots/ipc-stack-editor.png b/docs/screenshots/ipc-stack-editor.png new file mode 100644 index 0000000..ad3b453 Binary files /dev/null and b/docs/screenshots/ipc-stack-editor.png differ diff --git a/docs/screenshots/monitor-dashboard.png b/docs/screenshots/monitor-dashboard.png new file mode 100644 index 0000000..c65959f Binary files /dev/null and b/docs/screenshots/monitor-dashboard.png differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png new file mode 100644 index 0000000..c65959f Binary files /dev/null and b/docs/screenshots/overview.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png new file mode 100644 index 0000000..8437791 Binary files /dev/null and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/skills-console.png b/docs/screenshots/skills-console.png new file mode 100644 index 0000000..d38bd24 Binary files /dev/null and b/docs/screenshots/skills-console.png differ diff --git a/docs/screenshots/tools-registry.png b/docs/screenshots/tools-registry.png new file mode 100644 index 0000000..ad3b453 Binary files /dev/null and b/docs/screenshots/tools-registry.png differ diff --git a/docs/screenshots/verification-tab.png b/docs/screenshots/verification-tab.png new file mode 100644 index 0000000..c5afae5 Binary files /dev/null and b/docs/screenshots/verification-tab.png differ diff --git a/gitcommit b/gitcommit new file mode 100644 index 0000000..cea8a7a --- /dev/null +++ b/gitcommit @@ -0,0 +1,114 @@ +AI-LSC Release v3.0 -- codename: Ankh of Jah + +Full architectural rewrite from v2.4. This is not an incremental +release; the project fundamentally changed structure, scope, and +design philosophy. + +v2.4 was a monolithic tool launcher. +v3.0 is a metadata-driven infrastructure management platform. + +What changed: + +Architecture + - Migrated from flat script layout to src/ai_lsc/ package with 22 + core modules, 13 registry layer files, and clean separation of + concerns (registry, runtime, stack, skills, agents, ui) + - Introduced AI_LSC_BASE_DIR env var chain: bootstrap writes .env, + launcher reads it, constants.py resolves it -- entire runtime + derives from one variable. No hardcoded paths leak outside constants. + - ADR-001 established: Capability as the foundational abstraction. + Every subsystem (templates, pipelines, skills, containers, + dashboards, monitoring) points at Capability. Tool is a + implementation detail. Swap Ollama for vLLM -- nothing above the + registry notices. See docs/ADR-001-capability-architecture.md. + +Registry + - 115 registered tools organized across 13 infrastructure layers + (Host Platform -> Containers). Each entry carries full metadata: + installer, launcher, health probe, configuration, export rules, + capability mapping. + - Modular per-layer registry: ai_lsc/registry/layers/*.py -- adding a + tool means adding a dict entry, not touching Python logic. + - Registry auto-merges new keys across releases without user action. + +Stack Templates + - Template system expresses infrastructure intent, not install scripts. + - 4 included templates (Claude Code Setup, Agentic OS Stack, + SaaS Integrations, Free Claude Code). + - Templates reference tool IDs; the registry provides all install and + launch logic. Zero duplication. + - Stack Wizard UI for template selection and deployment. + +Runtime + - Multi-runtime execution: native, Podman, Docker Compose, LXC. + - RuntimeExecutor unifies install/start/stop/health across all targets. + - Container export generates Podman Quadlet, Docker Compose, or LXC + config from a template -- same template, different output format. + - Fixed LXC config path bug (Path string concat) and hardcoded + /mnt/AI mount targets. + +Bootstrap + - Fully portable: extracts anywhere, detects parent directory tree, + auto-resolves base dir. No assumptions about install location. + - Arch Linux: --system-site-packages venv sees pacman-installed + PySide6, avoids pip compile hell. + - Python version stamping detects stale venv after pacman -Syu, + auto-recreates. + - Cleans stale ~/.local/bin/ai-lsc and pipx installs from v2.x. + - python-pyside6 availability check before attempting pacman install + (package not in official Arch repos). + +Installer + - Supports pacman, apt, dnf, pip, git clone, npm, and manual installers. + - Dependency expansion: installing a template auto-detects and installs + missing prerequisite tools. + - License gate: tools requiring license acceptance prompt before install. + +Verification + - Per-tool health probes: process check, port check, API ping. + - Verification dashboard shows install status for all 115 tools. + - Hardware detection: GPU driver, CUDA version, memory, disk. + +UI + - PySide6 dark-themed interface with sidebar navigation across all + 13 layers. + - Dashboard with live service status, log feed, and system health. + - Infrastructure layer pages with per-tool ServiceRow widgets. + - Code analysis panel: ripgrep, fd, AST inspection, tree-sitter parsing. + - Skills console with modelfile tree browser and model pull. + - IPC stack editor for pipeline visualization. + - Settings page with base directory configuration. + - Fixed 8 NameError crashes in exception handlers (except without as). + - Fixed thread-unsafe UI mutation from background install thread. + - Fixed stack wizard KeyError on missing metadata fields. + +Skills + - Skill definitions as JSON manifests with capability requirements. + - SkillRuntimeResolver maps skill requests to available capabilities. + - 6 included skills (code-reviewer, vector-search, rag-analyst, + stack-operator, agent-orchestrator, redis-operator). + +Agents (infrastructure only -- deferred to v4.0) + - Redis pub/sub bridge for inter-agent communication. + - Tool bridge connecting agent function-calling to RuntimeExecutor. + - Ollama tools interface for model management. + - Model pool with tier routing (8B/14B/32B/70B). + - Dispatcher and clarification gate for multi-turn agent loops. + - All symbols safely stubbed; no agent execution in v3.0. + +Bug fixes from v2.4 + - Resolved externally-managed-environment error on Arch (venv isolation) + - Removed ModuleNotFoundError from stale ~/.local/bin/ai-lsc entry point + - Root launcher (ai_lsc.py) replaces pip entry-point -- no install needed + - Convenience launcher (run.sh) sources .env for proper env injection + - Path("/var/lib/lxc" / name) string concat crash -> proper Path join + - 8x except Exception without as exc -> all now capture correctly + - Thread-unsafe _run_install UI calls -> QTimer.singleShot dispatch + - Hardcoded /mnt/AI LXC mount targets -> dynamic path resolution + - Stack wizard meta['name'] KeyError -> .get() with safe defaults + - python-pyside6 pacman fallback for Arch repos that don't carry it + +Files changed: essentially everything. See ADR-001 for the architectural +rationale and the vocabulary that will govern v3.0 stabilization and +v4.0 development. +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dc2becb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,76 @@ +[build-system] +requires = [ + "setuptools>=80", + "wheel", +] +build-backend = "setuptools.build_meta" + +[project] +name = "ai-lsc" +version = "3.0.5" +description = "AI Local Stack Control — PySide6 desktop app for orchestrating local AI/ML tool stacks" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.11" +authors = [ + {name = "AI-LSC Contributors"}, +] +keywords = [ + "ai", "llm", "local-ai", "stack-management", "pyside6", + "ollama", "vllm", "container-management", "iac", + "terraform", "pulumi", "opentofu", "crossplane", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: X11 Applications :: Qt", + "Framework :: PySide6", + "Intended Audience :: Developers", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: System :: Systems Administration", +] + +dependencies = [ + "PySide6>=6.6", + "psutil>=5.9", +] + +[project.optional-dependencies] +gpu-nvidia = ["cupy-cuda12x"] +gpu-amd = ["cupy-rocm"] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "mypy>=1.0", + "ruff>=0.1", +] + +[project.scripts] +ai-lsc = "ai_lsc.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["ai_lsc*"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] diff --git a/quickstart.md b/quickstart.md new file mode 100644 index 0000000..443ea8d --- /dev/null +++ b/quickstart.md @@ -0,0 +1,224 @@ +# Quickstart Guide + +Get AI Local Stack Control up and running in under 5 minutes. + +## Prerequisites + +| Requirement | Minimum | Recommended | +|-------------|---------|-------------| +| OS | Arch Linux | Arch Linux / EndeavourOS | +| Python | 3.11 | 3.12+ | +| RAM | 8 GB | 16 GB+ (for LLM inference) | +| Disk | 4 GB free | 20 GB+ (for model storage) | +| GPU | None | NVIDIA (CUDA) or AMD (ROCm) | + +## Installation + +### Option 1: Bootstrap Script (Recommended) + +```bash +# Clone the repository +git clone https://github.com/your-username/ai-lsc.git +cd ai-lsc + +# Run the bootstrap script (installs system + Python deps) +chmod +x bootstrap.sh +./bootstrap.sh + +# Launch the application +python -m ai_lsc +``` + +### Option 2: Manual Install + +#### Step 1: System Dependencies + +```bash +# Core packages (Arch Linux) +sudo pacman -S python python-pip python-pyqt6 pyside6 \ + git tmux ripgrep fd tree-sitter sqlite redis + +# Optional: GPU support +sudo pacman -S cuda # NVIDIA +# sudo pacman -S rocm-hip-sdk # AMD + +# Optional: Container runtimes +sudo pacman -S podman docker +# Optional: LXC support +sudo pacman -S lxc lxcfs +``` + +#### Step 2: Python Dependencies + +```bash +cd ai-lsc + +# Create a virtual environment (recommended) +python -m venv .venv +source .venv/bin/activate + +# Install PySide6 and dependencies +pip install PySide6 +pip install -e . +``` + +#### Step 3: Verify Installation + +```bash +# Check that the registry loads correctly +python -c " +from ai_lsc import DEFAULT_REGISTRY, validate_registry +errors = validate_registry(DEFAULT_REGISTRY) +print(f'Registry loaded: {len(DEFAULT_REGISTRY)} tools') +print(f'Validation errors: {len(errors)}') +" + +# Expected output: +# Registry loaded: 115 tools +# Validation errors: 0 +``` + +#### Step 4: Launch + +```bash +python -m ai_lsc +``` + +## First Launch + +When you launch AI-LSC for the first time, you will see the **Stack Template Wizard**. This is your entry point for configuring your AI stack. + +### Choosing a Template + +| Template | Best For | Tool Count | +|----------|----------|-----------| +| Claude Code Setup | Claude Code development workflow | 11 | +| Free Claude Code | Minimal Claude Code environment | 4 | +| Local LLM Lab | Self-hosted LLM experimentation | 10 | +| SaaS Integrations | Production deployment with SSL/CDN | 12 | + +### Manual Configuration + +If you prefer to build your stack from scratch: + +1. Select **Create From Scratch** in the wizard +2. Navigate to the **Infrastructure** section in the sidebar +3. Expand each layer and toggle tools on/off +4. Use the **IPC Stack** tab to validate dependencies +5. Click **Compile** to save your stack configuration + +## Post-Setup + +### Installing a Base LLM + +Most tools depend on Ollama as the local LLM runtime: + +```bash +# Install Ollama (if not already installed) +curl -fsSL https://ollama.com/install.sh | sh + +# Pull a model +ollama pull llama3 +ollama pull codellama # Good for coding assistance +ollama pull mistral # Lightweight general-purpose +``` + +### Starting Services + +After configuring your stack in the IPC Stack tab: + +1. Click **Compile** to save the stack configuration +2. Switch to the **Monitor** tab +3. Click **Start All** or start individual services +4. Check service status indicators (green = running) + +### Connecting the Chat Console + +Once Ollama is running: + +1. Navigate to the **Chat** section +2. Select a model from the dropdown (e.g., `llama3`, `codellama`) +3. Start chatting with your local AI assistant + +## Common Tasks + +### Adding a New Tool + +1. Identify the target layer in `registry/layers/` +2. Add the tool entry following the canonical schema +3. Restart the application — the tool appears automatically + +### Exporting to Containers + +1. Open **Deployment Targets** from the sidebar +2. Select your backend: Podman, Docker, or LXC +3. Click **Export** to generate configuration files +4. Deploy with `podman compose up` or `lxc-launch.sh` + +### Managing LXC Containers + +```bash +# Create a container from exported config +sudo lxc-create -n ollama -f ollama.conf + +# Start the container +sudo lxc-start -n ollama + +# Attach to the container console +sudo lxc-attach -n ollama + +# Freeze/unfreeze +sudo lxc-freeze -n ollama +sudo lxc-unfreeze -n ollama + +# Destroy +sudo lxc-stop -n ollama +sudo lxc-destroy -n ollama +``` + +## Troubleshooting + +### PySide6 Import Error + +``` +ModuleNotFoundError: No module named 'PySide6' +``` + +**Fix:** Install PySide6: `pip install PySide6` + +### Registry Loading Errors + +``` +ERROR: Failed to load layer file: SyntaxError +``` + +**Fix:** Validate layer files: +```bash +python3 -c " +import ast, os +for f in os.listdir('ai_lsc/registry/layers'): + if f.endswith('.py') and f != '__init__.py': + ast.parse(open(f'ai_lsc/registry/layers/{f}').read()) + print(f'{f}: OK') +" +``` + +### Service Won't Start + +1. Check the **Monitor** tab for error messages +2. Verify the tool is installed: `which ` +3. Check launcher command in the registry entry +4. For systemd services: `systemctl --user status ` + +### Ollama Connection Refused + +1. Ensure Ollama is running: `ollama serve` or `systemctl --user start ollama` +2. Check port: `curl http://localhost:11434/api/tags` +3. Verify the endpoint in Settings matches your Ollama port + +## Next Steps + +- Explore the **Infrastructure** section to understand the 13-layer architecture +- Try different **Stack Templates** to find the right combination for your workflow +- Set up the **Skills Console** to extend your tool capabilities +- Use **Code Analysis** to inspect and understand your project dependencies diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..90b4852 --- /dev/null +++ b/run.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# AI-LSC v3.0 — Quick launch script +# +# Usage: +# bash run.sh # activates venv, launches GUI +# bash run.sh --headless # activates venv, runs without GUI +# ────────────────────────────────────────────────────────────── +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="${SCRIPT_DIR}/.venv" +VENV_PYTHON="${VENV_DIR}/bin/python" + +# ── Load .env for AI_LSC_BASE_DIR ──────────────────────────────── +_ENV_FILE="${SCRIPT_DIR}/.env" +if [ -f "$_ENV_FILE" ]; then + set -a # auto-export all variables + source "$_ENV_FILE" + set +a +fi + +# ── Ensure venv exists ──────────────────────────────────────── +if [ ! -f "$VENV_PYTHON" ]; then + echo "[ERROR] Virtual environment not found at ${VENV_DIR}" + echo " Run first: bash bootstrap.sh" + exit 1 +fi + +# ── Detect stale venv (Python version mismatch after pacman upgrade) +if [ -f "${VENV_DIR}/.python-version-stamp" ]; then + STAMP="$(cat "${VENV_DIR}/.python-version-stamp")" + SYS_VER="$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')")" + if [ "$STAMP" != "$SYS_VER" ]; then + echo "[WARN] Virtual environment is stale (venv: ${STAMP}, system: ${SYS_VER})" + echo " Run: bash bootstrap.sh" + exit 1 + fi +fi + +# ── Check for leftover ~/.local/bin/ai-lsc from old installs +STALE_BIN="${HOME}/.local/bin/ai-lsc" +if [ -f "$STALE_BIN" ]; then + echo "[WARN] Found stale entry-point at ${STALE_BIN}" + echo " This is from a previous pip/pipx install. Remove it:" + echo " rm -f ${STALE_BIN}" + echo "" +fi + +# ── Launch ──────────────────────────────────────────────────── +echo " Base dir: ${AI_LSC_BASE_DIR:-/mnt/AI}" +echo " Project : ${SCRIPT_DIR}" +echo "" + +if [ "${1:-}" = "--headless" ]; then + exec "$VENV_PYTHON" -c " +import sys +sys.path.insert(0, '${SCRIPT_DIR}/src') +from ai_lsc.constants import APP_DISPLAY_NAME, CANONICAL_BASE_DIR +print(f'{APP_DISPLAY_NAME}') +print(f' Base dir: {CANONICAL_BASE_DIR}') +" +else + exec "$VENV_PYTHON" "${SCRIPT_DIR}/ai_lsc.py" "$@" +fi diff --git a/src/ai_lsc/__init__.py b/src/ai_lsc/__init__.py new file mode 100644 index 0000000..2888355 --- /dev/null +++ b/src/ai_lsc/__init__.py @@ -0,0 +1,151 @@ +""" +AI Local Stack Control v3.0 — Release codename: Ankh of Jah. + +Extracted from the monolithic ``ai_lsc_v11.py`` in incremental phases. +Currently contains: + +* **Phase 0** — constants, typed data structures, the 13-layer registry + system, and utility modules. +* **Phase 1** — chat API worker, skill runtime resolver, stack export / + container backend, and manifest support. +* **Phase 2** — LXC container backend, stack template system. +* **Phase 3** — Expanded IaC registry (Pulumi, SST, Bicep, OpenTofu, + AWS CDK, Crossplane, Terragrunt). +* **v3.0** — Verification UI, ollama server path detection, packaging + overhaul, agentic layer deferred to v4.0. + +All with **zero behavioural change** from the original monolith. + +Public API +---------- +The ``__init__.py`` re-exports the most commonly used symbols so that +existing code can do:: + + from ai_lsc import BASE_DIR, DEFAULT_REGISTRY, RegistryManager + +instead of reaching into sub-packages. +""" + +# ── Constants ───────────────────────────────────────────────────────── +from ai_lsc.constants import ( + APP_CODENAME, + APP_DISPLAY_NAME, + APP_VERSION, + BASE_DIR, + CONFIG_FILE, + APP_ICON_FILE, + STATE_FILE_NAME, + PIPELINE_FILE_NAME, + STACK_SCHEMA_VERSION, + MANIFEST_FILE_NAME, + JCL_FILE_NAME, + REQUIRED_DIRS, + DEFAULT_PORTS, + STATUS_STYLES, + LOG_SOURCE_COLORS, + LOG_COLOR_DEFAULT, + SERVICE_LICENSES, + TREE_SKIP_PATTERNS, + NAV_LAYER_ORDER, + GLOBAL_STYLE, + SIDEBAR_TREE_STYLE, + MODEL_TIERS, + OLLAMA_SERVER_CANDIDATES, +) + +# ── Types ───────────────────────────────────────────────────────────── +from ai_lsc.types import ( + InstallerType, + LauncherType, + InstallerSpec, + LauncherSpec, + ToolFlags, + ToolMetadata, + FilesystemSpec, + VerifyCheck, + VerificationResult, + PreflightResult, + ServiceState, + PipelineState, +) + +# ── Registry ────────────────────────────────────────────────────────── +from ai_lsc.registry.defaults import DEFAULT_REGISTRY +from ai_lsc.registry.manager import RegistryManager +from ai_lsc.registry.stack_templates.manager import StackTemplateManager +from ai_lsc.registry.validator import validate_registry + +# ── Utils ───────────────────────────────────────────────────────────── +from ai_lsc.utils.paths import build_path_tree, resolve_launcher_cmd +from ai_lsc.utils.process import ( + enriched_env, + find_binary, + run_subprocess, + first_matching_process, + cpu_load_for_processes, +) +from ai_lsc.utils.filesystem import ensure_base_dirs, walk_tree +from ai_lsc.utils.logging import setup_logging, get_logger +from ai_lsc.utils.ollama import ( + detect_ollama_server_dir, + ollama_binary, + ollama_env, + ollama_is_installed, + ollama_models_dir, +) + +# ── Chat API (requires PySide6) ─────────────────────────────────────── +try: + from ai_lsc.chat.api import WorkerSignals, ApiRunnable +except ImportError: + WorkerSignals = None # PySide6 not installed + ApiRunnable = None + +# ── Skills ────────────────────────────────────────────────────────────── +from ai_lsc.skills.resolver import SkillRuntimeResolver + +# ── Stack export ─────────────────────────────────────────────────────── +from ai_lsc.stack.export import build_stack_spec, ContainerBackend + +# ── Manifest support ──────────────────────────────────────────────────── +from ai_lsc.manifest.support import ManifestSupport + +# ── Agents: DEFERRED to v4.0 ────────────────────────────────────────── +# The agentic tool-use bridge (ToolBridge, AgentLoop, AgentOrchestrator, +# etc.) has been removed from the v3.0 release. It will return in +# v4.0 with a redesigned architecture. + +__all__ = [ + # Constants + "APP_VERSION", "APP_CODENAME", "APP_DISPLAY_NAME", + "BASE_DIR", "CONFIG_FILE", "APP_ICON_FILE", + "STATE_FILE_NAME", "PIPELINE_FILE_NAME", "STACK_SCHEMA_VERSION", + "MANIFEST_FILE_NAME", "JCL_FILE_NAME", "REQUIRED_DIRS", + "DEFAULT_PORTS", "STATUS_STYLES", "LOG_SOURCE_COLORS", + "LOG_COLOR_DEFAULT", "SERVICE_LICENSES", "TREE_SKIP_PATTERNS", + "NAV_LAYER_ORDER", "GLOBAL_STYLE", "SIDEBAR_TREE_STYLE", + "MODEL_TIERS", "OLLAMA_SERVER_CANDIDATES", + # Types + "InstallerType", "LauncherType", "InstallerSpec", "LauncherSpec", + "ToolFlags", "ToolMetadata", "FilesystemSpec", "VerifyCheck", + "VerificationResult", "PreflightResult", "ServiceState", "PipelineState", + # Registry + "DEFAULT_REGISTRY", "RegistryManager", "StackTemplateManager", "validate_registry", + # Utils + "build_path_tree", "resolve_launcher_cmd", + "enriched_env", "find_binary", "run_subprocess", + "first_matching_process", "cpu_load_for_processes", + "ensure_base_dirs", "walk_tree", + "setup_logging", "get_logger", + # Ollama helpers + "detect_ollama_server_dir", "ollama_binary", "ollama_env", + "ollama_is_installed", "ollama_models_dir", + # Chat API + "WorkerSignals", "ApiRunnable", + # Skills + "SkillRuntimeResolver", + # Stack export + "build_stack_spec", "ContainerBackend", + # Manifest + "ManifestSupport", +] diff --git a/src/ai_lsc/__main__.py b/src/ai_lsc/__main__.py new file mode 100644 index 0000000..a0f37c3 --- /dev/null +++ b/src/ai_lsc/__main__.py @@ -0,0 +1,35 @@ +"""AI Local Stack Control — console entry point. + +Invoked via the ``ai-lsc`` script (registered in pyproject.toml) or +``python -m ai_lsc``. Launches the PySide6 desktop application. +""" + +import sys + + +def main() -> int: + """Launch the AI-LSC desktop application and return the exit code.""" + # PySide6 is a required dependency as of v3.0 — if it's missing we fail + # loudly rather than falling back to a degraded mode. + try: + from PySide6.QtWidgets import QApplication + except ImportError: + print( + "PySide6 is required but not installed.\n\n" + " source .venv/bin/activate\n" + " pip install PySide6>=6.6\n\n" + " Or re-run: bash bootstrap.sh", + file=sys.stderr, + ) + return 1 + + from ai_lsc.ui.main_window import AILocalStackControl + + app = QApplication.instance() or QApplication(sys.argv) + window = AILocalStackControl() + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ai_lsc/agents/__init__.py b/src/ai_lsc/agents/__init__.py new file mode 100644 index 0000000..ed3ace8 --- /dev/null +++ b/src/ai_lsc/agents/__init__.py @@ -0,0 +1,45 @@ +""" +AI-LSC — Agentic orchestration package. + +**DEFERRED to v4.0** — This package is preserved for reference but is not +imported or used in v3.0 (Ankh of Jah). The agentic tool-use bridge will +return in v4.0 with a redesigned architecture. + +All symbols are set to ``None`` so that existing code referencing them +gracefully degrades rather than raising ``ImportError``. +""" + +from __future__ import annotations + +# All agent symbols set to None for v3.0 — will be reactivated in v4.0 +ToolBridge = None +AgentDispatcher = None +AgentLoop = None +EnhancedSkillResolver = None +AgentOrchestrator = None +OrchestratorResult = None +WarmModelPool = None +ClarificationGate = None +ClarificationDecision = None +SkillInjector = None +RedisBridge = None +QdrantBridge = None +LiteLLMConfigGenerator = None +LibreChatConfigGenerator = None + +__all__ = [ + "ToolBridge", + "AgentDispatcher", + "AgentLoop", + "EnhancedSkillResolver", + "AgentOrchestrator", + "OrchestratorResult", + "WarmModelPool", + "ClarificationGate", + "ClarificationDecision", + "SkillInjector", + "RedisBridge", + "QdrantBridge", + "LiteLLMConfigGenerator", + "LibreChatConfigGenerator", +] diff --git a/src/ai_lsc/agents/agent_loop.py b/src/ai_lsc/agents/agent_loop.py new file mode 100644 index 0000000..d9d1d8e --- /dev/null +++ b/src/ai_lsc/agents/agent_loop.py @@ -0,0 +1,207 @@ +""" +AI-LSC — Standalone headless agent execution loop. + +Implements the multi-turn observation/action cycle for autonomous +agent execution without a GUI. The loop: + + 1. Sends user message + tool schemas to Ollama + 2. Receives response (may contain tool_calls) + 3. Executes tool_calls via AgentDispatcher + 4. Sends tool results back to Ollama + 5. Repeats until the model stops making tool calls + +This enables headless operation where AI-LSC agents can orchestrate +the stack autonomously — e.g., "set up my RAG pipeline" would +trigger: start qdrant → pull embedding model → inject rag skill → +open webui. + +Usage +----- + loop = AgentLoop(dispatcher, ollama_port=11434, model="qwen2.5:72b") + result = loop.run("Start the vector database and pull embedding model") +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import Any + +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + +# Safety limit: max tool-call rounds per user message +MAX_ROUNDS: int = 20 + + +class AgentLoop: + """Headless multi-turn agent execution loop. + + Parameters + ---------- + dispatcher : + An ``AgentDispatcher`` for executing tool calls. + ollama_port : + Port of the Ollama API server. + model : + Default model to use for agent conversations. + system_prompt : + Optional system prompt injected at the start. + timeout : + HTTP timeout per Ollama call in seconds. + max_rounds : + Maximum tool-call rounds before forcing a stop. + """ + + def __init__( + self, + dispatcher: Any, # AgentDispatcher + ollama_port: int = 11434, + model: str = "qwen2.5:32b", + system_prompt: str = "", + timeout: float = 300.0, + max_rounds: int = MAX_ROUNDS, + ) -> None: + self.dispatcher = dispatcher + self.base_url = f"http://127.0.0.1:{ollama_port}" + self.model = model + self.system_prompt = system_prompt + self.timeout = timeout + self.max_rounds = max_rounds + self.conversation_history: list[dict[str, str]] = [] + self._tool_schemas: list[dict[str, Any]] = [] + + def set_tool_schemas( + self, schemas: list[dict[str, Any]], + ) -> None: + """Set the tool schemas available to the agent.""" + self._tool_schemas = schemas + + # ── Main execution ──────────────────────────────────────────────── + + def run( + self, + user_message: str, + model: str | None = None, + ) -> dict[str, Any]: + """Execute an agent task end-to-end. + + Parameters + ---------- + user_message : + The task description from the user. + model : + Override the default model for this run. + + Returns + ------- + dict with ``final_response``, ``tool_calls_made``, ``rounds``. + """ + use_model = model or self.model + all_tool_calls: list[dict[str, Any]] = [] + + # Build initial messages + messages: list[dict[str, str]] = [] + if self.system_prompt: + messages.append({"role": "system", "content": self.system_prompt}) + messages.append({"role": "user", "content": user_message}) + + for round_num in range(self.max_rounds): + logger.info( + "Agent round %d/%d — model: %s", + round_num + 1, self.max_rounds, use_model, + ) + + # Call Ollama + response = self._call_ollama(messages, use_model) + if response is None: + break + + assistant_msg = response.get("message", {}) + content = assistant_msg.get("content", "") + tool_calls = assistant_msg.get("tool_calls", []) + + messages.append(assistant_msg) + + # No tool calls → agent is done + if not tool_calls: + logger.info( + "Agent finished after %d rounds", round_num + 1, + ) + self.conversation_history = messages + return { + "final_response": content, + "tool_calls_made": all_tool_calls, + "rounds": round_num + 1, + "model": use_model, + } + + # Execute each tool call + for tc in tool_calls: + func = tc.get("function", {}) + tc_name = func.get("name", "unknown") + tc_args = json.loads(func.get("arguments", "{}")) + + logger.info("Executing tool: %s(%s)", tc_name, tc_args) + result = self.dispatcher.execute_tool_call({ + "name": tc_name, + "arguments": tc_args, + }) + + all_tool_calls.append({ + "name": tc_name, + "arguments": tc_args, + "result": result, + }) + + # Send tool result back to Ollama + messages.append({ + "role": "tool", + "content": json.dumps(result), + }) + + # Max rounds reached + self.conversation_history = messages + return { + "final_response": "(Agent hit max rounds limit)", + "tool_calls_made": all_tool_calls, + "rounds": self.max_rounds, + "model": use_model, + } + + # ── Ollama HTTP ───────────────────────────────────────────────── + + def _call_ollama( + self, + messages: list[dict[str, str]], + model: str, + ) -> dict[str, Any] | None: + """Send a chat request to Ollama. Returns parsed response.""" + payload: dict[str, Any] = { + "model": model, + "messages": messages, + "stream": False, + } + if self._tool_schemas: + payload["tools"] = self._tool_schemas + + try: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + f"{self.base_url}/api/chat", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen( + req, timeout=self.timeout + ) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.URLError as exc: + logger.error("Ollama connection failed: %s", exc) + return None + except Exception as exc: + logger.error("Agent loop error: %s", exc) + return None diff --git a/src/ai_lsc/agents/clarification_gate.py b/src/ai_lsc/agents/clarification_gate.py new file mode 100644 index 0000000..d8bd117 --- /dev/null +++ b/src/ai_lsc/agents/clarification_gate.py @@ -0,0 +1,265 @@ +""" +AI-LSC — Confidence-gated clarification gate. + +Implements a three-tier clarification strategy that avoids interrupting +the user for obvious tasks while ensuring ambiguous requests get proper +scoping: + + 1. **Skip** (confidence >= 0.95): Execute immediately, no questions. + 2. **Quick confirm** (confidence >= 0.70): Ask a single yes/no before proceeding. + 3. **Full clarification** (confidence < 0.70): Ask up to 6 focused questions. + +The gate uses the 8B classifier model to estimate intent confidence, +then routes through the appropriate path. + +Usage +----- + gate = ClarificationGate(ollama_port=11434) + decision = gate.evaluate("start qdrant on port 6333") + # → ClarificationDecision(mode="skip", ...) +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from typing import Any + +from ai_lsc.constants import ( + CLARIFICATION_CONFIRM_THRESHOLD, + CLARIFICATION_SKIP_THRESHOLD, +) +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass +class ClarificationDecision: + """Result of the clarification gate evaluation.""" + + mode: str # "skip", "confirm", "clarify" + confidence: float + intent: str + tool_id: str = "" + arguments: dict[str, Any] = field(default_factory=dict) + question: str = "" # for "confirm" mode + questions: list[dict[str, str]] = field(default_factory=list) # for "clarify" mode + + def to_dict(self) -> dict[str, Any]: + return { + "mode": self.mode, + "confidence": round(self.confidence, 3), + "intent": self.intent, + "tool_id": self.tool_id, + "arguments": self.arguments, + "question": self.question, + "questions": self.questions, + } + + +class ClarificationGate: + """Confidence-gated clarification for agentic requests. + + Parameters + ---------- + ollama_port : + Port of the Ollama API server (classifier model). + classifier_model : + Small model used for intent classification (default 8B). + timeout : + HTTP timeout for classification calls. + """ + + def __init__( + self, + ollama_port: int = 11434, + classifier_model: str = "qwen2.5:7b", + timeout: float = 30.0, + ) -> None: + self.base_url = f"http://127.0.0.1:{ollama_port}" + self.classifier_model = classifier_model + self.timeout = timeout + + # ── Main evaluation ──────────────────────────────────────────────── + + def evaluate( + self, + user_message: str, + available_tools: list[str] | None = None, + ) -> ClarificationDecision: + """Evaluate a user message and return a clarification decision. + + Parameters + ---------- + user_message : + The raw user request. + available_tools : + Known tool IDs to help with classification. + + Returns + ------- + A ClarificationDecision with the appropriate mode and data. + """ + classification = self._classify(user_message, available_tools) + + confidence = classification.get("confidence", 0.5) + intent = classification.get("intent", "unknown") + tool_id = classification.get("tool_id", "") + arguments = classification.get("arguments", {}) + + if confidence >= CLARIFICATION_SKIP_THRESHOLD: + return ClarificationDecision( + mode="skip", + confidence=confidence, + intent=intent, + tool_id=tool_id, + arguments=arguments, + ) + elif confidence >= CLARIFICATION_CONFIRM_THRESHOLD: + question = ( + f"I'll {intent} using {tool_id or 'the appropriate tool'}. " + f"Proceed?" + ) + return ClarificationDecision( + mode="confirm", + confidence=confidence, + intent=intent, + tool_id=tool_id, + arguments=arguments, + question=question, + ) + else: + questions = self._generate_questions( + user_message, intent, tool_id, available_tools + ) + return ClarificationDecision( + mode="clarify", + confidence=confidence, + intent=intent, + tool_id=tool_id, + arguments=arguments, + questions=questions, + ) + + # ── Classification ───────────────────────────────────────────────── + + def _classify( + self, + message: str, + tools: list[str] | None = None, + ) -> dict[str, Any]: + """Call the classifier model to extract intent, tool, and confidence.""" + tools_str = ", ".join(tools[:20]) if tools else "start_service, stop_service, check_service_status, pull_model, list_available_tools, inject_skill, open_web_interface, search_registry, install_tool" + + system_prompt = ( + "You are an intent classifier for the AI-LSC agentic system. " + "Analyze the user's request and respond with ONLY valid JSON:\n" + '{"intent": "", ' + '"tool_id": "", ' + '"arguments": {}, ' + '"confidence": <0.0-1.0 float>}\n\n' + f"Available tools: {tools_str}\n" + "Respond with JSON only, no explanation." + ) + + payload = json.dumps({ + "model": self.classifier_model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": message}, + ], + "stream": False, + "options": {"temperature": 0.0}, + }).encode("utf-8") + + try: + req = urllib.request.Request( + f"{self.base_url}/api/chat", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + content = data.get("message", {}).get("content", "") + # Parse JSON from the response (may be wrapped in markdown) + cleaned = content.strip() + if cleaned.startswith("```"): + cleaned = cleaned.split("\n", 1)[1] + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + return json.loads(cleaned) + except (json.JSONDecodeError, urllib.error.URLError) as exc: + logger.warning("Classification failed: %s", exc) + except Exception as exc: + logger.error("Classifier error: %s", exc) + + return { + "intent": "unknown", + "tool_id": "", + "arguments": {}, + "confidence": 0.3, + } + + # ── Question generation ───────────────────────────────────────────── + + def _generate_questions( + self, + message: str, + intent: str, + tool_id: str, + tools: list[str] | None, + ) -> list[dict[str, str]]: + """Generate focused clarification questions for ambiguous requests. + + Returns up to 6 questions, each with a header and question string. + """ + # Seed questions based on intent category + seed_questions: list[dict[str, str]] = [] + + if not tool_id and tools: + seed_questions.append({ + "header": "Tool", + "question": f"Which tool should I use? Options: {', '.join(tools[:8])}", + }) + + if "start" in intent or "deploy" in intent: + seed_questions.extend([ + {"header": "Port", "question": "What port should the service listen on?"}, + {"header": "Config", "question": "Any specific configuration or model to use?"}, + ]) + elif "pull" in intent or "download" in intent: + seed_questions.append({ + "header": "Model", + "question": "Which specific model should I pull?", + }) + elif "search" in intent or "find" in intent: + seed_questions.extend([ + {"header": "Scope", "question": "What layer or category should I search in?"}, + {"header": "Filter", "question": "Any specific criteria (running only, web-enabled, etc.)?"}, + ]) + elif "analyze" in intent or "review" in intent: + seed_questions.extend([ + {"header": "Target", "question": "What file, directory, or service should I analyze?"}, + {"header": "Depth", "question": "How thorough should the analysis be (quick vs deep)?"}, + ]) + + # Fill remaining slots with general-purpose questions + general = [ + {"header": "Priority", "question": "How urgent is this task?"}, + {"header": "Output", "question": "What output format do you prefer (text, JSON, file)?"}, + {"header": "Scope", "question": "Should this affect the running pipeline?"}, + ] + + used_headers = {q["header"] for q in seed_questions} + for g in general: + if len(seed_questions) >= 6: + break + if g["header"] not in used_headers: + seed_questions.append(g) + used_headers.add(g["header"]) + + return seed_questions[:6] diff --git a/src/ai_lsc/agents/dispatcher.py b/src/ai_lsc/agents/dispatcher.py new file mode 100644 index 0000000..c9c92d6 --- /dev/null +++ b/src/ai_lsc/agents/dispatcher.py @@ -0,0 +1,272 @@ +""" +AI-LSC — Agent dispatcher. + +Bridges LLM tool_call JSON payloads to AI-LSC's RuntimeExecutor. +When an agent frontend (LibreChat, OpenWebUI) sends a tool_call +response, this dispatcher translates it into RuntimeExecutor calls. + +This module is in the ``agents`` package (allowed for urllib) because +it needs to make HTTP calls to Ollama's API for model pulls and status +checks that the RuntimeExecutor doesn't handle directly. + +Usage +----- + dispatcher = AgentDispatcher(runtime, registry_mgr, ollama_port) + result = dispatcher.execute_tool_call({ + "name": "start_service", + "arguments": {"tool_id": "qdrant"} + }) +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import Any + +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + + +class AgentDispatcher: + """Translates tool_call JSON into RuntimeExecutor method calls. + + Parameters + ---------- + runtime : + A ``RuntimeExecutor`` instance for process management. + registry_data : + The full registry dict from ``RegistryManager.get_all_tools()``. + active_tools : + Currently active tool IDs in the pipeline. + ollama_port : + Port of the Ollama API server. + """ + + def __init__( + self, + runtime: Any, # RuntimeExecutor — avoid circular import + registry_data: dict[str, dict[str, Any]], + active_tools: set[str], + ollama_port: int = 11434, + ) -> None: + self.runtime = runtime + self.registry = registry_data + self.active_tools = active_tools + self.ollama_port = ollama_port + + # ── Main dispatch entry point ───────────────────────────────────── + + def execute_tool_call( + self, + tool_call: dict[str, Any], + ) -> dict[str, Any]: + """Execute a single tool_call and return the result. + + Parameters + ---------- + tool_call : + A dict with ``name`` (str) and ``arguments`` (dict). + + Returns + ------- + dict with ``success``, ``result_text``, and optional ``data``. + """ + name = tool_call.get("name", "") + args = tool_call.get("arguments", {}) + + handlers: dict[str, Any] = { + "start_service": self._start_service, + "stop_service": self._stop_service, + "check_service_status": self._check_status, + "pull_model": self._pull_model, + "list_available_tools": self._list_tools, + "inject_skill": self._inject_skill_stub, + "open_web_interface": self._open_web, + "search_registry": self._search_registry, + "install_tool": self._install_tool, + } + + handler = handlers.get(name) + if handler is None: + return { + "success": False, + "result_text": f"Unknown tool: {name}. " + f"Available: {', '.join(handlers.keys())}", + } + + try: + return handler(args) + except Exception as exc: + logger.error("Tool call %s failed: %s", name, exc) + return { + "success": False, + "result_text": f"Error executing {name}: {exc}", + } + + # ── Tool handlers ────────────────────────────────────────────────── + + def _start_service(self, args: dict) -> dict[str, Any]: + tool_id = args.get("tool_id", "") + meta = self.registry.get(tool_id, {}) + if not meta: + return { + "success": False, + "result_text": f"Tool '{tool_id}' not found in registry.", + } + + launcher = meta.get("launcher", {}) + launcher_type = launcher.get("type", "tmux") + launcher_cmd = launcher.get("cmd", "") + port = str(args.get("port", launcher.get("default_port", ""))) + + result = self.runtime.start_service( + tool_id=tool_id, + launcher_cmd=launcher_cmd, + launcher_type=launcher_type, + port=port, + ) + self.active_tools.add(tool_id) + return {"success": True, "result_text": result} + + def _stop_service(self, args: dict) -> dict[str, Any]: + tool_id = args.get("tool_id", "") + meta = self.registry.get(tool_id, {}) + launcher = meta.get("launcher", {}) + result = self.runtime.stop_service( + tool_id=tool_id, + launcher_type=launcher.get("type", "tmux"), + launcher_cmd=launcher.get("cmd", ""), + search_term=launcher.get("cmd", ""), + is_docker=meta.get("flags", {}).get("is_docker", False), + ) + self.active_tools.discard(tool_id) + return {"success": True, "result_text": result} + + def _check_status(self, args: dict) -> dict[str, Any]: + tool_id = args.get("tool_id", "") + meta = self.registry.get(tool_id, {}) + launcher = meta.get("launcher", {}) + running = self.runtime.is_service_running( + launcher_type=launcher.get("type", "tmux"), + tool_id=tool_id, + service_cmd=launcher.get("cmd", ""), + search_term=launcher.get("cmd", ""), + ) + status = "RUNNING" if running else "OFFLINE" + return { + "success": True, + "result_text": f"{tool_id} is {status}", + "data": {"tool_id": tool_id, "running": running}, + } + + def _pull_model(self, args: dict) -> dict[str, Any]: + model_name = args.get("model_name", "") + if not model_name: + return { + "success": False, + "result_text": "model_name is required.", + } + proc = self.runtime.pull_model(model_name) + output, _ = proc.communicate(timeout=600) + return { + "success": proc.returncode == 0, + "result_text": output.strip() if output else "Pull completed.", + } + + def _list_tools(self, args: dict) -> dict[str, Any]: + filter_layer = args.get("filter_layer", "") + filter_cat = args.get("filter_category", "") + running_only = args.get("running_only", False) + + results = [] + for tid, meta in self.registry.items(): + if filter_layer and meta.get("layer") != filter_layer: + continue + if filter_cat and meta.get("category") != filter_cat: + continue + if running_only and tid not in self.active_tools: + continue + results.append({ + "tool_id": tid, + "name": meta.get("name", tid), + "layer": meta.get("layer", ""), + "category": meta.get("category", ""), + "active": tid in self.active_tools, + "description": meta.get("description", ""), + }) + + return { + "success": True, + "result_text": f"Found {len(results)} tools.", + "data": results, + } + + def _inject_skill_stub(self, args: dict) -> dict[str, Any]: + skill_name = args.get("skill_name", "") + return { + "success": True, + "result_text": ( + f"Skill '{skill_name}' queued for injection. " + f"The frontend should load the skill's system prompt " + f"and prepend it to the next LLM call." + ), + } + + def _open_web(self, args: dict) -> dict[str, Any]: + tool_id = args.get("tool_id", "") + meta = self.registry.get(tool_id, {}) + port = str( + args.get("port", meta.get("launcher", {}).get("default_port", "")) + ) + if not port: + return { + "success": False, + "result_text": f"No web port known for {tool_id}.", + } + url = self.runtime.open_web_url(port) + return { + "success": True, + "result_text": f"Opened {tool_id} web interface at {url}", + } + + def _search_registry(self, args: dict) -> dict[str, Any]: + query = args.get("query", "").lower() + results = [] + for tid, meta in self.registry.items(): + searchable = " ".join([ + tid, meta.get("name", ""), meta.get("description", ""), + meta.get("layer", ""), meta.get("category", ""), + ]).lower() + if query in searchable: + results.append({ + "tool_id": tid, + "name": meta.get("name", tid), + "layer": meta.get("layer", ""), + "description": meta.get("description", ""), + }) + return { + "success": True, + "result_text": f"Found {len(results)} matching tools.", + "data": results, + } + + def _install_tool(self, args: dict) -> dict[str, Any]: + tool_id = args.get("tool_id", "") + meta = self.registry.get(tool_id, {}) + if not meta: + return { + "success": False, + "result_text": f"Tool '{tool_id}' not found in registry.", + } + installer = meta.get("installer", {}) + result = self.runtime.install_tool( + inst_type=installer.get("type", "pacman"), + pkg=installer.get("pkg", ""), + cmd=installer.get("cmd", ""), + tool_id=tool_id, + ctx=self.runtime.format_context(), + ) + return {"success": True, "result_text": result} diff --git a/src/ai_lsc/agents/librechat_config.py b/src/ai_lsc/agents/librechat_config.py new file mode 100644 index 0000000..f86d6c1 --- /dev/null +++ b/src/ai_lsc/agents/librechat_config.py @@ -0,0 +1,301 @@ +""" +AI-LSC — LibreChat configuration generator. + +Generates the ``librechat.yaml`` configuration file that wires LibreChat +to the local AI-LSC stack: Ollama inference engine, LiteLLM proxy for +multi-model routing, and the agent tool-use schemas from the agents bridge. + +This makes LibreChat the turnkey agent frontend for the agentic stack +— just start it and all 210+ models plus tool-calling are available +through the web UI. + +Usage +----- + config = LibreChatConfigGenerator() + config.set_ollama_endpoint(ollama_port=11434) + config.set_litellm_endpoint(litellm_port=4000) + config.set_tool_schemas(tool_schemas) + config.save("/mnt/AI/tools/librechat/librechat.yaml") +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + + +class LibreChatConfigGenerator: + """Generate LibreChat configuration for AI-LSC integration. + + Parameters + ---------- + config_dir : + Directory where LibreChat is installed (contains docker-compose.yml + or the yarn project root). + """ + + def __init__(self, config_dir: str | Path | None = None) -> None: + self.config_dir = Path(config_dir) if config_dir else None + self._endpoints: dict[str, dict[str, Any]] = {} + self._tool_schemas: list[dict[str, Any]] = [] + self._assistants: list[dict[str, Any]] = [] + self._preset_customizations: list[dict[str, Any]] = [] + + # ── Endpoint Configuration ───────────────────────────────────────── + + def set_ollama_endpoint( + self, + ollama_port: int = 11434, + ollama_host: str = "127.0.0.1", + ) -> None: + """Configure the direct Ollama endpoint for native tool calling.""" + self._endpoints["ollama"] = { + "type": "ollama", + "name": "AI-LSC Ollama (Native Tool Calling)", + "url": f"http://{ollama_host}:{ollama_port}", + "models": { + "default": ["qwen2.5:32b", "qwen2.5:72b"], + "fetch": True, # auto-discover models from /api/tags + }, + } + + def set_litellm_endpoint( + self, + litellm_port: int = 4000, + litellm_host: str = "127.0.0.1", + api_key: str = "sk-ai-lsc-local", + ) -> None: + """Configure the LiteLLM proxy endpoint for multi-model routing.""" + self._endpoints["litellm"] = { + "type": "openai", + "name": "AI-LSC LiteLLM Proxy (All Models)", + "url": f"http://{litellm_host}:{litellm_port}/v1", + "apiKey": api_key, + "models": { + "default": [ + "classifier", "utility", "reasoner", "heavy", + "llama3-8b", "gemma2-9b", "phi4", "mistral", + "command-r", "coder-heavy", + ], + "fetch": True, + }, + } + + def set_openwebui_endpoint( + self, + port: int = 8080, + host: str = "127.0.0.1", + ) -> None: + """Configure OpenWebUI as an OpenAI-compatible endpoint.""" + self._endpoints["openwebui"] = { + "type": "openai", + "name": "Open WebUI", + "url": f"http://{host}:{port}/api", + "apiKey": "sk-local", + "models": {"default": ["*"], "fetch": True}, + } + + # ── Tool Schema Integration ──────────────────────────────────────── + + def set_tool_schemas( + self, + schemas: list[dict[str, Any]], + ) -> None: + """Set the AI-LSC tool schemas for LibreChat's tool-use system. + + These are registered as server-side tools that any assistant + can invoke through the OpenAI function-calling protocol. + """ + self._tool_schemas = schemas + + # ── Assistant Presets ─────────────────────────────────────────────── + + def add_assistant_preset( + self, + name: str, + model: str = "reasoner", + system_prompt: str = "", + tools_enabled: bool = True, + ) -> None: + """Add a pre-configured assistant definition. + + Parameters + ---------- + name : + Assistant display name. + model : + Default model identifier (matches LiteLLM alias or Ollama model). + system_prompt : + Initial system prompt. + tools_enabled : + Whether to enable AI-LSC tool calling. + """ + assistant: dict[str, Any] = { + "name": name, + "model": model, + "system_prompt": system_prompt, + } + if tools_enabled and self._tool_schemas: + assistant["tools"] = self._tool_schemas + self._assistants.append(assistant) + + def add_default_assistants(self) -> None: + """Add the standard AI-LSC assistant presets.""" + self.add_assistant_preset( + name="Stack Operator", + model="reasoner", + system_prompt=( + "You are the AI-LSC Stack Operator. You can start, stop, " + "and manage the entire AI tool stack. Use tools to control " + "services, pull models, and configure the pipeline. " + "Always check service status before starting or stopping." + ), + ) + self.add_assistant_preset( + name="RAG Analyst", + model="reasoner", + system_prompt=( + "You are the AI-LSC RAG Analyst. You search knowledge " + "bases, analyze documents using vector similarity, and " + "synthesize information from multiple sources. Use the " + "inject_skill tool to load the rag-analyst skill." + ), + ) + self.add_assistant_preset( + name="Code Reviewer", + model="heavy", + system_prompt=( + "You are the AI-LSC Code Reviewer. You review code for " + "bugs, style issues, security vulnerabilities, and " + "architectural problems. Use the inject_skill tool to " + "load the code-reviewer skill for deep analysis." + ), + ) + + # ── YAML Generation ───────────────────────────────────────────────── + + def generate_yaml(self) -> str: + """Generate the librechat.yaml configuration content.""" + lines = [ + "# AI-LSC — LibreChat Configuration", + "# Auto-generated by agents/librechat_config.py", + "# Connects LibreChat to the local AI-LSC tool stack", + "", + ] + + # Endpoints + if self._endpoints: + lines.append("endpoints:") + for name, config in self._endpoints.items(): + lines.append(f' - name: "{config.get("name", name)}"') + lines.append(f' type: "{config.get("type", "openai")}"') + lines.append(f' url: "{config.get("url", "")}"') + if "apiKey" in config: + lines.append(f' apiKey: "{config["apiKey"]}"') + lines.append("") + + # Tool schemas (written as JSON in a comment block for copy-paste) + if self._tool_schemas: + lines.append("# AI-LSC Tool Schemas (register via LibreChat admin UI):") + lines.append("# tools:") + lines.append(f"# schemas: {json.dumps(self._tool_schemas, indent=4)}") + lines.append("") + + # Assistant presets + if self._assistants: + lines.append("# AI-LSC Assistant Presets:") + for assistant in self._assistants: + lines.append(f"# - name: \"{assistant['name']}\"") + lines.append(f"# model: \"{assistant['model']}\"") + lines.append(f"# system_prompt: \"{assistant.get('system_prompt', '')}\"") + lines.append("") + + return "\n".join(lines) + + def generate_env_file(self) -> str: + """Generate the .env file for LibreChat configuration.""" + env_lines = [ + "# AI-LSC — LibreChat Environment", + "# Auto-generated by agents/librechat_config.py", + "", + "# Database (use MariaDB from AI-LSC stack)", + "DB_HOST=127.0.0.1", + "DB_PORT=3306", + "DB_NAME=librechat", + "DB_USER=librechat", + "DB_PASS=librechat", + "", + "# Redis (use Redis from AI-LSC stack)", + "REDIS_HOST=127.0.0.1", + "REDIS_PORT=6379", + "", + # Application settings + "PORT=3080", + "HOST=127.0.0.1", + "NODE_ENV=production", + "API_PLUGINS=false", + "", + # AI-LSC integration + "ALLOWED_ENDPOINTS=ollama,openai,custom", + "", + ] + + # Add endpoint-specific env vars + if "ollama" in self._endpoints: + lines = self._endpoints["ollama"] + env_lines.append(f"# Ollama endpoint") + env_lines.append(f"OLLAMA_BASE_URL={lines.get('url', 'http://127.0.0.1:11434')}") + env_lines.append("") + + if "litellm" in self._endpoints: + litellm = self._endpoints["litellm"] + env_lines.append(f"# LiteLLM proxy endpoint") + env_lines.append(f"OPENAI_REVERSE_PROXY={litellm.get('url', 'http://127.0.0.1:4000/v1')}") + env_lines.append(f"OPENAI_API_KEY={litellm.get('apiKey', 'sk-ai-lsc-local')}") + env_lines.append("") + + return "\n".join(env_lines) + + # ── Persistence ────────────────────────────────────────────────── + + def save( + self, + config_dir: str | Path | None = None, + ) -> dict[str, str]: + """Write configuration files to disk. + + Returns a dict mapping filename → absolute path. + """ + out_dir = Path(config_dir) if config_dir else self.config_dir + if not out_dir: + return {} + + out_dir.mkdir(parents=True, exist_ok=True) + written: dict[str, str] = {} + + # librechat.yaml + yaml_path = out_dir / "librechat.yaml" + yaml_path.write_text(self.generate_yaml(), encoding="utf-8") + written["librechat.yaml"] = str(yaml_path) + + # .env + env_path = out_dir / ".env" + env_path.write_text(self.generate_env_file(), encoding="utf-8") + written[".env"] = str(env_path) + + # tool_schemas.json (for import via admin UI) + if self._tool_schemas: + schemas_path = out_dir / "ai_lsc_tool_schemas.json" + schemas_path.write_text( + json.dumps(self._tool_schemas, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + written["ai_lsc_tool_schemas.json"] = str(schemas_path) + + logger.info("Saved LibreChat config files to %s", out_dir) + return written diff --git a/src/ai_lsc/agents/litellm_config.py b/src/ai_lsc/agents/litellm_config.py new file mode 100644 index 0000000..b2c6cba --- /dev/null +++ b/src/ai_lsc/agents/litellm_config.py @@ -0,0 +1,245 @@ +""" +AI-LSC — LiteLLM proxy configuration generator. + +Generates the ``litellm_config.yaml`` file that configures the LiteLLM +proxy to normalize all 210+ local Ollama models into a single OpenAI-compatible +endpoint. This lets LibreChat (and any other OpenAI-format client) talk to +the entire local model fleet through one port. + +The config also includes: + - Model tier routing (8B/14B/32B/70B) with custom names. + - Rate limiting per tier to manage VRAM contention. + - Fallback chains for graceful degradation. + +Usage +----- + config = LiteLLMConfigGenerator() + config.add_ollama_models(ollama_port=11434) + yaml_str = config.generate_yaml() + config.save("/mnt/AI/config/litellm_config.yaml") +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ai_lsc.constants import MODEL_TIERS +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + + +# Default model catalog — maps tier to representative Ollama models +_TIER_MODELS: dict[str, list[dict[str, str]]] = { + "8b": [ + {"ollama_name": "qwen2.5:7b", "alias": "classifier"}, + {"ollama_name": "llama3.1:8b", "alias": "llama3-8b"}, + {"ollama_name": "gemma2:9b", "alias": "gemma2-9b"}, + ], + "14b": [ + {"ollama_name": "qwen2.5:14b", "alias": "utility"}, + {"ollama_name": "phi4:14b", "alias": "phi4"}, + {"ollama_name": "mistral:7b", "alias": "mistral"}, + ], + "32b": [ + {"ollama_name": "qwen2.5:32b", "alias": "reasoner"}, + {"ollama_name": "llama3.1:70b", "alias": "llama3-70b"}, + {"ollama_name": "command-r:35b", "alias": "command-r"}, + ], + "70b": [ + {"ollama_name": "qwen2.5:72b", "alias": "heavy"}, + {"ollama_name": "deepseek-coder-v2:236b", "alias": "coder-heavy"}, + ], +} + +# Rate limits per tier (requests per minute) +_TIER_RPM: dict[str, int] = { + "8b": 60, + "14b": 30, + "32b": 10, + "70b": 3, +} + + +class LiteLLMConfigGenerator: + """Generate LiteLLM proxy configuration for the AI-LSC stack. + + Parameters + ---------- + general_settings : + Override dict for the ``general_settings`` section. + """ + + def __init__( + self, + general_settings: dict[str, Any] | None = None, + ) -> None: + self.model_list: list[dict[str, Any]] = [] + self.litellm_settings: dict[str, Any] = { + "drop_params": True, + "set_verbose": False, + } + self.general_settings: dict[str, Any] = general_settings or { + "master_key": "sk-ai-lsc-local", + } + self._tier_models: dict[str, list[dict[str, str]]] = { + k: list(v) for k, v in _TIER_MODELS.items() + } + + # ── Model Registration ──────────────────────────────────────────── + + def add_ollama_models( + self, + ollama_port: int = 11434, + ollama_host: str = "127.0.0.1", + ) -> None: + """Add the default tier-based Ollama models.""" + base_url = f"http://{ollama_host}:{ollama_port}" + + for tier, models in self._tier_models.items(): + rpm = _TIER_RPM.get(tier, 10) + tier_info = MODEL_TIERS.get(tier, {}) + + for model in models: + entry = { + "model_name": model["alias"], + "litellm_provider": "ollama", + "model_info": { + "id": model["ollama_name"], + "mode": "chat", + "tier": tier, + "max_vram_gb": tier_info.get("max_vram_gb", 32), + "description": tier_info.get("desc", ""), + }, + "litellm_params": { + "model": model["ollama_name"], + "api_base": base_url, + "rpm_limit": rpm, + }, + } + self.model_list.append(entry) + + logger.info("Added %d models from Ollama at %s", + len(self.model_list), base_url) + + def add_custom_model( + self, + alias: str, + ollama_name: str, + ollama_port: int = 11434, + rpm: int = 10, + tier: str = "32b", + ) -> None: + """Add a custom model to the configuration.""" + base_url = f"http://127.0.0.1:{ollama_port}" + tier_info = MODEL_TIERS.get(tier, {}) + + entry = { + "model_name": alias, + "litellm_provider": "ollama", + "model_info": { + "id": ollama_name, + "mode": "chat", + "tier": tier, + "max_vram_gb": tier_info.get("max_vram_gb", 32), + }, + "litellm_params": { + "model": ollama_name, + "api_base": base_url, + "rpm_limit": rpm, + }, + } + self.model_list.append(entry) + + def add_external_provider( + self, + alias: str, + provider: str, + api_key: str = "", + api_base: str = "", + model_id: str = "", + ) -> None: + """Add an external API provider (e.g. OpenAI, Anthropic).""" + entry = { + "model_name": alias, + "litellm_provider": provider, + "model_info": {"id": model_id}, + "litellm_params": { + "model": model_id, + "api_key": api_key, + }, + } + if api_base: + entry["litellm_params"]["api_base"] = api_base + self.model_list.append(entry) + + # ── YAML Generation ───────────────────────────────────────────────── + + def generate_yaml(self) -> str: + """Generate the litellm_config.yaml content.""" + lines = [ + "# AI-LSC — LiteLLM Proxy Configuration", + "# Auto-generated by agents/litellm_config.py", + "# Maps all local Ollama models into a single OpenAI-compatible endpoint", + "", + "model_list:", + ] + + for entry in self.model_list: + lines.append(f' - model_name: "{entry["model_name"]}"') + lines.append(f' litellm_provider: "{entry["litellm_provider"]}"') + for k, v in entry.get("litellm_params", {}).items(): + lines.append(f" {k}: {self._format_yaml_value(v)}") + lines.append("") + + # General settings + if self.general_settings: + lines.append("general_settings:") + for k, v in self.general_settings.items(): + lines.append(f" {k}: {self._format_yaml_value(v)}") + lines.append("") + + # LiteLLM settings + if self.litellm_settings: + lines.append("litellm_settings:") + for k, v in self.litellm_settings.items(): + lines.append(f" {k}: {self._format_yaml_value(v)}") + + return "\n".join(lines) + + def generate_dict(self) -> dict[str, Any]: + """Return the configuration as a plain dict (for JSON export).""" + return { + "model_list": self.model_list, + "general_settings": self.general_settings, + "litellm_settings": self.litellm_settings, + } + + # ── Persistence ────────────────────────────────────────────────── + + def save(self, path: str | Path) -> None: + """Write the YAML configuration to disk.""" + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(self.generate_yaml(), encoding="utf-8") + logger.info("Saved LiteLLM config to %s", out) + + # ── Helpers ──────────────────────────────────────────────────────── + + @staticmethod + def _format_yaml_value(value: Any) -> str: + """Format a Python value as a YAML scalar.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + if any(c in value for c in ':"\'{}[]&*?|>!%@`'): + return f'"{value}"' + return value + return str(value) + + def list_models(self) -> list[str]: + """Return all registered model aliases.""" + return [e["model_name"] for e in self.model_list] diff --git a/src/ai_lsc/agents/model_pool.py b/src/ai_lsc/agents/model_pool.py new file mode 100644 index 0000000..3c4e04d --- /dev/null +++ b/src/ai_lsc/agents/model_pool.py @@ -0,0 +1,226 @@ +""" +AI-LSC — Warm model pool with VRAM slot management. + +Manages a fixed pool of 4 VRAM slots with LRU eviction so that +models are pre-loaded and ready for inference. The pool maps +task types to model tiers: + + Slot 0: 8B classifier — routing, intent detection + Slot 1: 14B utility — summarization, clarification + Slot 2: 32B reasoning — analysis, code generation + Slot 3: 70B heavy — complex generation, documents + +When a new model is requested that does not fit in the current +allocation, the least-recently-used slot is evicted and the new +model is pulled and loaded. + +Usage +----- + pool = WarmModelPool(ollama_port=11434) + model = pool.acquire("reasoning") # returns "qwen2.5:32b" + pool.release(model) +""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request +from collections import OrderedDict +from typing import Any + +from ai_lsc.constants import MODEL_TIERS +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + +# Mapping from logical task types to model tiers +_TASK_TO_TIER: dict[str, str] = { + "classification": "8b", + "routing": "8b", + "intent": "8b", + "clarification": "14b", + "summarization": "14b", + "utility": "14b", + "reasoning": "32b", + "analysis": "32b", + "code": "32b", + "script": "32b", + "generation": "70b", + "document": "70b", + "chart": "70b", + "web": "70b", + "complex": "70b", +} + +# Default model name per tier (user can override via set_tier_model) +_DEFAULT_MODELS: dict[str, str] = { + "8b": "qwen2.5:7b", + "14b": "qwen2.5:14b", + "32b": "qwen2.5:32b", + "70b": "qwen2.5:72b", +} + + +class WarmModelPool: + """Fixed-slot VRAM pool with LRU eviction. + + Parameters + ---------- + ollama_port : + Port of the Ollama API server. + max_slots : + Maximum number of models loaded simultaneously. + """ + + def __init__( + self, + ollama_port: int = 11434, + max_slots: int = 4, + ) -> None: + self.ollama_port = ollama_port + self.max_slots = max_slots + self.base_url = f"http://127.0.0.1:{ollama_port}" + self._tier_models: dict[str, str] = dict(_DEFAULT_MODELS) + # LRU-ordered: most recent at the end + self._loaded: OrderedDict[str, float] = OrderedDict() + self._pull_lock = False # simple guard against concurrent pulls + + # ── Configuration ────────────────────────────────────────────────── + + def set_tier_model(self, tier: str, model_name: str) -> None: + """Override the default model for a tier.""" + if tier in MODEL_TIERS: + self._tier_models[tier] = model_name + logger.info("Tier %s mapped to model %s", tier, model_name) + + def get_tier_model(self, tier: str) -> str: + """Return the model name assigned to a tier.""" + return self._tier_models.get(tier, _DEFAULT_MODELS.get(tier, "")) + + # ── Acquisition ──────────────────────────────────────────────────── + + def acquire(self, task_type: str) -> str: + """Acquire a model for the given task type. + + If the model is already loaded, it is promoted in the LRU order. + If not, a slot is evicted (if necessary) and the model is pulled. + + Parameters + ---------- + task_type : + Logical task category (e.g. "reasoning", "classification"). + + Returns + ------- + The Ollama model name that is ready for inference. + """ + tier = _TASK_TO_TIER.get(task_type, "32b") + model = self._tier_models.get(tier, "qwen2.5:32b") + + if model in self._loaded: + # Promote to most-recently-used + self._loaded.move_to_end(model) + self._loaded[model] = time.monotonic() + logger.info("Cache hit: %s (tier=%s)", model, tier) + return model + + # Need to load — evict if at capacity + while len(self._loaded) >= self.max_slots: + self._evict_lru() + + # Pull the model + self._pull_model(model) + self._loaded[model] = time.monotonic() + logger.info("Loaded model %s (tier=%s, slots=%d/%d)", + model, tier, len(self._loaded), self.max_slots) + return model + + def release(self, model: str) -> None: + """Release a model from active use. + + This is a soft release — the model stays loaded in the pool + until it is LRU-evicted. Call ``evict`` to force unload. + """ + if model in self._loaded: + self._loaded.move_to_end(model) + self._loaded[model] = time.monotonic() + + # ── Eviction ────────────────────────────────────────────────────── + + def evict(self, model: str) -> bool: + """Force-evict a specific model from the pool. + + Returns True if the model was in the pool and was evicted. + """ + if model in self._loaded: + del self._loaded[model] + logger.info("Force-evicted model: %s", model) + return True + return False + + def _evict_lru(self) -> str | None: + """Evict the least-recently-used model.""" + if not self._loaded: + return None + model, _ = self._loaded.popitem(last=False) + logger.info("LRU-evicted model: %s", model) + return model + + # ── Ollama interaction ───────────────────────────────────────────── + + def _pull_model(self, model_name: str) -> None: + """Pull a model from Ollama registry.""" + if self._pull_lock: + logger.warning("Pull already in progress, skipping %s", model_name) + return + self._pull_lock = True + try: + payload = json.dumps({"name": model_name}).encode("utf-8") + req = urllib.request.Request( + f"{self.base_url}/api/pull", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + # Use streaming to avoid timeout on large models + with urllib.request.urlopen(req, timeout=600) as resp: + for line in resp: + pass # consume stream + logger.info("Pulled model: %s", model_name) + except urllib.error.URLError as exc: + logger.error("Failed to pull %s: %s", model_name, exc) + except Exception as exc: + logger.error("Pull error for %s: %s", model_name, exc) + finally: + self._pull_lock = False + + # ── Status ───────────────────────────────────────────────────────── + + def list_loaded(self) -> list[dict[str, Any]]: + """Return the currently loaded models with their tier info.""" + result = [] + for model, ts in self._loaded.items(): + tier = "unknown" + for t, m in self._tier_models.items(): + if m == model: + tier = t + break + result.append({ + "model": model, + "tier": tier, + "last_used": ts, + "age_seconds": time.monotonic() - ts, + }) + return result + + def status_summary(self) -> dict[str, Any]: + """Return pool status for logging/dashboard display.""" + return { + "max_slots": self.max_slots, + "used_slots": len(self._loaded), + "free_slots": self.max_slots - len(self._loaded), + "loaded_models": list(self._loaded.keys()), + "tier_mapping": dict(self._tier_models), + } diff --git a/src/ai_lsc/agents/ollama_tools.py b/src/ai_lsc/agents/ollama_tools.py new file mode 100644 index 0000000..73dd1c1 --- /dev/null +++ b/src/ai_lsc/agents/ollama_tools.py @@ -0,0 +1,148 @@ +""" +AI-LSC — Ollama tool schema registration. + +Registers AI-LSC tool schemas with Ollama's native ``/api/tools`` +endpoint so that models running through Ollama can use function calling +to control the AI-LSC stack. + +Ollama tool-use flow +-------------------- +1. Register tools via ``POST /api/tools`` (this module) +2. Include tool names in ``POST /api/chat`` request +3. Model returns ``tool_call`` objects in its response +4. Client executes the tool call and sends the result back +5. Model continues the conversation with tool results + +Usage +----- + registrar = OllamaToolRegistrar(ollama_port=11434) + registrar.register_all(tool_schemas) +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import Any + +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + + +class OllamaToolRegistrar: + """Register and manage tool schemas with a running Ollama instance. + + Parameters + ---------- + ollama_port : + Port of the Ollama API server. + timeout : + HTTP request timeout in seconds. + """ + + def __init__( + self, + ollama_port: int = 11434, + timeout: float = 10.0, + ) -> None: + self.base_url = f"http://127.0.0.1:{ollama_port}" + self.timeout = timeout + self._registered: set[str] = set() + + # ── Registration ────────────────────────────────────────────────── + + def register_all( + self, + schemas: list[dict[str, Any]], + ) -> dict[str, bool]: + """Register a list of tool schemas with Ollama. + + Returns a dict mapping tool name → success bool. + """ + results: dict[str, bool] = {} + for schema in schemas: + func = schema.get("function", {}) + name = func.get("name", "unknown") + try: + self._register_single(schema) + self._registered.add(name) + results[name] = True + logger.info("Registered tool: %s", name) + except Exception as exc: + results[name] = False + logger.warning("Failed to register %s: %s", name, exc) + return results + + def register_single( + self, + schema: dict[str, Any], + ) -> bool: + """Register a single tool schema. Returns True on success.""" + func = schema.get("function", {}) + name = func.get("name", "unknown") + try: + self._register_single(schema) + self._registered.add(name) + logger.info("Registered tool: %s", name) + return True + except Exception as exc: + logger.warning("Failed to register %s: %s", name, exc) + return False + + def _register_single(self, schema: dict[str, Any]) -> None: + """POST a single tool schema to Ollama's /api/tools endpoint.""" + # Extract just the function definition for Ollama + func_def = schema.get("function", {}) + payload = json.dumps({ + "name": func_def.get("name"), + "description": func_def.get("description", ""), + "parameters": func_def.get("parameters", {"type": "object", "properties": {}}), + }).encode("utf-8") + + url = f"{self.base_url}/api/tools" + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + if resp.status != 200: + raise RuntimeError( + f"Ollama returned status {resp.status}" + ) + + # ── Querying ─────────────────────────────────────────────────────── + + def list_registered_tools(self) -> list[str]: + """Return names of tools registered in this session.""" + return sorted(self._registered) + + def check_ollama_health(self) -> bool: + """Check if Ollama is reachable. Returns True if healthy.""" + try: + req = urllib.request.Request(self.base_url + "/") + with urllib.request.urlopen( + req, timeout=self.timeout + ) as resp: + return resp.status == 200 + except Exception: + return False + + def list_ollama_models(self) -> list[dict[str, Any]]: + """Query Ollama for available models via /api/tags.""" + try: + req = urllib.request.Request( + f"{self.base_url}/api/tags", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen( + req, timeout=self.timeout + ) as resp: + data = json.loads(resp.read().decode("utf-8")) + return data.get("models", []) + except Exception as exc: + logger.warning("Failed to list models: %s", exc) + return [] diff --git a/src/ai_lsc/agents/orchestrator.py b/src/ai_lsc/agents/orchestrator.py new file mode 100644 index 0000000..afe825a --- /dev/null +++ b/src/ai_lsc/agents/orchestrator.py @@ -0,0 +1,554 @@ +""" +AI-LSC — 7-layer agentic orchestration pipeline. + +Implements the full agentic architecture that transforms a user's natural +language request into a sequence of tool calls, skill injections, and +sub-agent operations: + + Layer 1: **Router** — Classify the request and route to the right handler. + Layer 2: **Skill Loader** — Load relevant skill summaries and full prompts. + Layer 3: **Clarification Gate** — Confidence-gated user interaction. + Layer 4: **Outline Planner** — Generate a step-by-step execution plan. + Layer 5: **Tool Orchestrator** — Translate plan into tool call sequences. + Layer 6: **Subagent Spawner** — Delegate subtasks to specialized agents. + Layer 7: **Quality Enforcer** — Validate results and retry if needed. + +Each layer is optional and can be bypassed based on confidence scores +and task complexity. Simple requests ("start qdrant") skip straight +from Router → Tool Orchestrator → execution. + +Usage +----- + orch = AgentOrchestrator( + dispatcher=dispatcher, + skill_resolver=skill_resolver, + redis_bridge=redis_bridge, + ollama_port=11434, + ) + result = orch.execute("start the RAG pipeline and search quarterly report") +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from typing import Any, Callable + +from ai_lsc.agents.clarification_gate import ClarificationGate, ClarificationDecision +from ai_lsc.agents.model_pool import WarmModelPool +from ai_lsc.agents.redis_bridge import RedisBridge +from ai_lsc.agents.skill_injector import SkillInjector +from ai_lsc.agents.skill_resolver import EnhancedSkillResolver +from ai_lsc.constants import ( + AGENT_DEFAULT_MODEL, + AGENT_MAX_ROUNDS, + CLARIFICATION_SKIP_THRESHOLD, +) +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass +class OrchestratorResult: + """Final result from the orchestration pipeline.""" + + success: bool + response: str + layers_executed: list[str] = field(default_factory=list) + tool_calls_made: list[dict[str, Any]] = field(default_factory=list) + skills_loaded: list[str] = field(default_factory=list) + rounds: int = 0 + model: str = "" + duration_seconds: float = 0.0 + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "success": self.success, + "response": self.response, + "layers_executed": self.layers_executed, + "tool_calls_made": self.tool_calls_made, + "skills_loaded": self.skills_loaded, + "rounds": self.rounds, + "model": self.model, + "duration_seconds": round(self.duration_seconds, 2), + "metadata": self.metadata, + } + + +class AgentOrchestrator: + """7-layer agentic orchestration pipeline. + + Parameters + ---------- + dispatcher : + An ``AgentDispatcher`` for executing tool calls. + skill_resolver : + An ``EnhancedSkillResolver`` for skill discovery. + redis_bridge : + A ``RedisBridge`` for hot-path coordination (can be None). + skills_root : + Path to the skills directory. + ollama_port : + Port of the Ollama API server. + timeout : + HTTP timeout per Ollama call in seconds. + max_rounds : + Maximum tool-call rounds per request. + on_needs_clarification : + Optional callback invoked when user clarification is needed. + Receives a ``ClarificationDecision`` and should return the + user's response (or an empty string to abort). + """ + + def __init__( + self, + dispatcher: Any, # AgentDispatcher + skill_resolver: EnhancedSkillResolver, + redis_bridge: RedisBridge | None = None, + skills_root: str = "", + ollama_port: int = 11434, + timeout: float = 300.0, + max_rounds: int = AGENT_MAX_ROUNDS, + on_needs_clarification: Callable[[ClarificationDecision], str] | None = None, + ) -> None: + from ai_lsc.constants import BASE_DIR + self.skills_root = skills_root or os.path.join(BASE_DIR, "skills") + self.dispatcher = dispatcher + self.skill_resolver = skill_resolver + self.redis = redis_bridge + self.ollama_port = ollama_port + self.timeout = timeout + self.max_rounds = max_rounds + self.on_clarify = on_needs_clarification + + # Sub-components + self.model_pool = WarmModelPool(ollama_port=ollama_port) + self.clarification_gate = ClarificationGate(ollama_port=ollama_port) + self.skill_injector = SkillInjector(skill_resolver, self.skills_root) + + # Active state tracking + self.active_tools: set[str] = set() + self._conversation_history: list[dict[str, str]] = [] + + # ── Main Execution Entry Point ───────────────────────────────────── + + def execute( + self, + user_message: str, + model: str | None = None, + available_tools: list[str] | None = None, + ) -> OrchestratorResult: + """Execute a user request through the full orchestration pipeline. + + Parameters + ---------- + user_message : + The user's natural language request. + model : + Override the auto-selected model. + available_tools : + Explicit list of tool IDs the orchestrator can use. + + Returns + ------- + An ``OrchestratorResult`` with the final output and metadata. + """ + start_time = time.monotonic() + layers_executed: list[str] = [] + tool_calls_made: list[dict[str, Any]] = [] + skills_loaded: list[str] = [] + use_model = model or AGENT_DEFAULT_MODEL + + try: + # ── Layer 1: Router ─────────────────────────────────── + route = self._route(user_message, available_tools) + layers_executed.append("router") + use_model = model or route.get("model", use_model) + logger.info( + "Route: intent=%s, confidence=%.2f, model=%s", + route.get("intent", "unknown"), + route.get("confidence", 0), + use_model, + ) + + # ── Layer 2: Skill Loader ──────────────────────────── + skills = self._load_skills(user_message) + layers_executed.append("skill_loader") + if skills: + for s in skills: + skills_loaded.append(s.name) + logger.info("Loaded %d skills: %s", len(skills), [s.name for s in skills]) + + # ── Layer 3: Clarification Gate ─────────────────────── + decision = self._clarify(user_message, available_tools) + layers_executed.append("clarification_gate") + + if decision.mode == "clarify": + if self.on_clarify: + response = self.on_clarify(decision) + if not response: + return OrchestratorResult( + success=False, + response="Request cancelled by user.", + layers_executed=layers_executed, + model=use_model, + duration_seconds=time.monotonic() - start_time, + ) + user_message = response # User clarified + else: + logger.info("Clarification needed but no callback — proceeding with defaults") + + # ── Layer 4: Outline Planner ─────────────────────────── + plan = self._plan(user_message, skills, decision) + layers_executed.append("outline_planner") + logger.info("Plan: %d steps", len(plan.get("steps", []))) + + # ── Layer 5: Tool Orchestrator ───────────────────────── + results, calls = self._orchestrate(plan, use_model, skills) + layers_executed.append("tool_orchestrator") + tool_calls_made.extend(calls) + + # ── Layer 6: Subagent Spawner ────────────────────────── + if plan.get("needs_subagents"): + sub_results = self._spawn_subagents(plan, use_model) + layers_executed.append("subagent_spawner") + results.append(f"Sub-agent results: {json.dumps(sub_results)}") + + # ── Layer 7: Quality Enforcer ────────────────────────── + final = self._enforce_quality(results, user_message) + layers_executed.append("quality_enforcer") + + return OrchestratorResult( + success=True, + response=final, + layers_executed=layers_executed, + tool_calls_made=tool_calls_made, + skills_loaded=skills_loaded, + rounds=len(calls), + model=use_model, + duration_seconds=time.monotonic() - start_time, + ) + + except Exception as exc: + logger.error("Orchestration failed: %s", exc) + return OrchestratorResult( + success=False, + response=f"Orchestration error: {exc}", + layers_executed=layers_executed, + tool_calls_made=tool_calls_made, + model=use_model, + duration_seconds=time.monotonic() - start_time, + ) + + # ── Layer 1: Router ──────────────────────────────────────────────── + + def _route( + self, + message: str, + tools: list[str] | None, + ) -> dict[str, Any]: + """Classify the request and determine the best model tier.""" + classification = self.clarification_gate._classify(message, tools) + + intent = classification.get("intent", "unknown") + tool_id = classification.get("tool_id", "") + confidence = classification.get("confidence", 0.5) + + # Select model tier based on intent complexity + task_type = self._intent_to_task_type(intent) + model = self.model_pool.acquire(task_type) + + return { + "intent": intent, + "tool_id": tool_id, + "confidence": confidence, + "task_type": task_type, + "model": model, + "arguments": classification.get("arguments", {}), + } + + @staticmethod + def _intent_to_task_type(intent: str) -> str: + """Map an intent string to a model task type.""" + intent_lower = intent.lower() + if any(w in intent_lower for w in ["start", "stop", "check", "list", "install"]): + return "classification" + if any(w in intent_lower for w in ["summarize", "clarify", "explain"]): + return "utility" + if any(w in intent_lower for w in ["analyze", "reason", "review", "code", "script"]): + return "reasoning" + if any(w in intent_lower for w in ["generate", "write", "create", "document"]): + return "generation" + return "reasoning" + + # ── Layer 2: Skill Loader ───────────────────────────────────────── + + def _load_skills( + self, + message: str, + ) -> list[Any]: + """Find and load skills matching the user's request.""" + matches = self.skill_resolver.find_by_trigger(message) + + # Also check semantic matching if Qdrant is available + # (graceful — no Qdrant = keyword-only matching) + + return matches[:3] # Limit to 3 skills max + + # ── Layer 3: Clarification Gate ─────────────────────────────────── + + def _clarify( + self, + message: str, + tools: list[str] | None, + ) -> ClarificationDecision: + """Run the clarification gate on the request.""" + return self.clarification_gate.evaluate(message, tools) + + # ── Layer 4: Outline Planner ────────────────────────────────────── + + def _plan( + self, + message: str, + skills: list[Any], + decision: ClarificationDecision, + ) -> dict[str, Any]: + """Generate an execution plan for the request. + + For high-confidence simple requests, the plan is a single step + derived from the clarification decision. For complex requests, + it calls the planner model. + """ + # Simple path: use the decision directly + if decision.mode == "skip" and decision.tool_id: + return { + "steps": [ + { + "action": decision.intent, + "tool": decision.tool_id, + "arguments": decision.arguments, + } + ], + "needs_subagents": False, + "complexity": "simple", + } + + # Complex path: ask the model to plan + plan = self._generate_plan(message, skills) + return plan + + def _generate_plan( + self, + message: str, + skills: list[Any], + ) -> dict[str, Any]: + """Use the planner model to generate a multi-step plan.""" + skill_names = [s.name for s in skills] if skills else [] + + system_prompt = ( + "You are a task planner for the AI-LSC agentic system. " + "Break the user's request into atomic steps. " + "Respond with ONLY valid JSON:\n" + '{"steps": [{"action": "", "tool": "", ' + '"arguments": {}}, ...], ' + '"needs_subagents": , ' + '"complexity": "simple|moderate|complex"}' + ) + + if skill_names: + system_prompt += f"\nAvailable skills: {', '.join(skill_names)}" + + try: + import json + import urllib.request + + payload = json.dumps({ + "model": AGENT_DEFAULT_MODEL, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Plan this request: {message}"}, + ], + "stream": False, + "options": {"temperature": 0.0}, + }).encode("utf-8") + + req = urllib.request.Request( + f"http://127.0.0.1:{self.ollama_port}/api/chat", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + content = data.get("message", {}).get("content", "").strip() + if content.startswith("```"): + content = content.split("\n", 1)[1] + if content.endswith("```"): + content = content[:-3] + return json.loads(content) + except Exception as exc: + logger.warning("Plan generation failed: %s", exc) + + # Fallback plan + return { + "steps": [{"action": message, "tool": "", "arguments": {}}], + "needs_subagents": False, + "complexity": "simple", + } + + # ── Layer 5: Tool Orchestrator ────────────────────────────────────── + + def _orchestrate( + self, + plan: dict[str, Any], + model: str, + skills: list[Any], + ) -> tuple[list[str], list[dict[str, Any]]]: + """Execute the plan steps via the dispatcher.""" + results: list[str] = [] + calls_made: list[dict[str, Any]] = [] + + for step in plan.get("steps", []): + tool_id = step.get("tool", "") + action = step.get("action", "") + arguments = step.get("arguments", {}) + + if not tool_id: + # No specific tool — let the agent loop handle it + result = self._agent_execute(action, model, skills) + results.append(result.get("final_response", "")) + calls_made.extend(result.get("tool_calls_made", [])) + else: + # Direct tool call + result = self.dispatcher.execute_tool_call({ + "name": tool_id, + "arguments": arguments, + }) + results.append(result.get("result_text", "")) + calls_made.append({ + "name": tool_id, + "arguments": arguments, + "result": result, + }) + + return results, calls_made + + def _agent_execute( + self, + task: str, + model: str, + skills: list[Any], + ) -> dict[str, Any]: + """Use the agent loop for complex multi-tool tasks.""" + from ai_lsc.agents.agent_loop import AgentLoop + + # Build tool schemas + from ai_lsc.agents.tool_bridge import ToolBridge + bridge = ToolBridge(self.dispatcher.registry, self.active_tools) + schemas = bridge.generate_all_schemas() + + loop = AgentLoop( + dispatcher=self.dispatcher, + ollama_port=self.ollama_port, + model=model, + system_prompt=self._build_system_prompt(skills), + timeout=self.timeout, + max_rounds=self.max_rounds, + ) + loop.set_tool_schemas(schemas) + + # Inject skill summaries if available + if skills: + summary = self.skill_injector.build_skill_summary(self.active_tools) + return loop.run(f"{summary}\n\nTask: {task}", model=model) + + return loop.run(task, model=model) + + def _build_system_prompt(self, skills: list[Any]) -> str: + """Build the system prompt with skill context.""" + parts = [ + "You are the AI-LSC Stack Operator. You can manage the entire " + "local AI tool stack using the provided tools. Always check " + "service status before starting or stopping services.", + ] + if skills: + parts.append( + "\nActive skills: " + + ", ".join(f"{s.name} ({s.description})" for s in skills) + ) + return "\n".join(parts) + + # ── Layer 6: Subagent Spawner ────────────────────────────────────── + + def _spawn_subagents( + self, + plan: dict[str, Any], + model: str, + ) -> list[dict[str, Any]]: + """Spawn sub-agents for parallelizable subtasks.""" + results: list[dict[str, Any]] = [] + + for step in plan.get("steps", []): + if step.get("parallelizable"): + try: + sub_result = self._agent_execute( + step.get("action", ""), + model, + [], + ) + results.append({ + "step": step.get("action", ""), + "result": sub_result.get("final_response", ""), + }) + except Exception as exc: + results.append({ + "step": step.get("action", ""), + "error": str(exc), + }) + + return results + + # ── Layer 7: Quality Enforcer ────────────────────────────────────── + + def _enforce_quality( + self, + results: list[str], + original_request: str, + ) -> str: + """Validate and refine the results.""" + if not results: + return "No results generated." + + # Check for error indicators in results + errors = [r for r in results if any( + e in r.lower() for e in ["error", "failed", "not found", "timeout"] + )] + + if errors: + # Filter out errors, keep successful results + clean = [r for r in results if r not in errors] + if clean: + return "\n".join(clean) + ( + f"\n\n(Warnings: {len(errors)} step(s) had issues)" + ) + return "All steps failed: " + "; ".join(errors[:3]) + + return "\n".join(results) + + # ── Public API ───────────────────────────────────────────────────── + + def update_active_tools(self, tools: set[str]) -> None: + """Update the set of currently active tools.""" + self.active_tools = set(tools) + + def get_status(self) -> dict[str, Any]: + """Return orchestrator status for monitoring.""" + return { + "model_pool": self.model_pool.status_summary(), + "active_tools": sorted(self.active_tools), + "conversation_length": len(self._conversation_history), + "redis": self.redis.health_check() if self.redis else {"connected": False}, + } diff --git a/src/ai_lsc/agents/qdrant_bridge.py b/src/ai_lsc/agents/qdrant_bridge.py new file mode 100644 index 0000000..882f72f --- /dev/null +++ b/src/ai_lsc/agents/qdrant_bridge.py @@ -0,0 +1,344 @@ +""" +AI-LSC — Qdrant vector memory bridge. + +Provides the Qdrant vector database integration for the Agentic OS +semantic memory layer, handling: + + - **Collection management**: Create, list, and delete vector collections. + - **Point operations**: Upsert, search, and delete vectors with payloads. + - **Embedding generation**: Use Ollama embedding models for vectorization. + - **Skill matching**: Semantic search over skill descriptions. + - **RAG pipeline**: Retrieve relevant context for document analysis. + +Qdrant serves as the **semantic path** (vector search, RAG), while Redis +handles the hot path and MariaDB handles cold persistence. + +Usage +----- + bridge = QdrantBridge(qdrant_port=6333, ollama_port=11434) + bridge.create_collection("documents", dimension=384) + bridge.upsert_points("documents", points) + results = bridge.search("documents", query="quarterly report", limit=5) +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import Any + +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + + +class QdrantBridge: + """Bridge to Qdrant for semantic vector operations. + + Uses Qdrant's REST API directly via urllib to maintain the same + zero-hard-dependency pattern as the rest of the agents package. + Falls back gracefully when Qdrant is not running. + + Parameters + ---------- + qdrant_host : + Qdrant server hostname. + qdrant_port : + Qdrant HTTP API port. + ollama_port : + Ollama port for embedding generation. + default_embedding_model : + Ollama model to use for embeddings. + timeout : + HTTP request timeout in seconds. + """ + + def __init__( + self, + qdrant_host: str = "127.0.0.1", + qdrant_port: int = 6333, + ollama_port: int = 11434, + default_embedding_model: str = "nomic-embed-text", + timeout: float = 30.0, + ) -> None: + self.qdrant_url = f"http://{qdrant_host}:{qdrant_port}" + self.ollama_url = f"http://127.0.0.1:{ollama_port}" + self.embedding_model = default_embedding_model + self.timeout = timeout + + def _qdrant_request( + self, + method: str, + path: str, + data: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: + """Make a request to the Qdrant REST API.""" + url = f"{self.qdrant_url}{path}" + payload = json.dumps(data).encode("utf-8") if data else None + try: + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json"}, + method=method, + ) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + if resp.status in (200, 201): + content = resp.read().decode("utf-8") + return json.loads(content) if content else {} + logger.warning("Qdrant %s %s returned %d", method, path, resp.status) + return None + except urllib.error.URLError as exc: + logger.debug("Qdrant not reachable: %s", exc) + return None + except Exception as exc: + logger.error("Qdrant request failed: %s %s: %s", method, path, exc) + return None + + # ── Collection Management ────────────────────────────────────────── + + def create_collection( + self, + name: str, + dimension: int = 768, + distance: str = "cosine", + ) -> bool: + """Create a new vector collection. + + Parameters + ---------- + name : + Collection name. + dimension : + Vector dimensionality (depends on embedding model). + distance : + Distance metric: "cosine", "euclid", or "dot". + """ + result = self._qdrant_request("PUT", f"/collections/{name}", { + "vectors": { + "size": dimension, + "distance": distance, + }, + }) + if result is not None: + logger.info("Created collection: %s (dim=%d, dist=%s)", name, dimension, distance) + return True + return False + + def list_collections(self) -> list[str]: + """Return names of all collections.""" + result = self._qdrant_request("GET", "/collections") + if result: + return [c["name"] for c in result.get("collections", [])] + return [] + + def delete_collection(self, name: str) -> bool: + """Delete a collection.""" + result = self._qdrant_request("DELETE", f"/collections/{name}") + return result is not None + + def collection_info(self, name: str) -> dict[str, Any] | None: + """Get detailed info about a collection.""" + return self._qdrant_request("GET", f"/collections/{name}") + + # ── Point Operations ─────────────────────────────────────────────── + + def upsert_points( + self, + collection: str, + points: list[dict[str, Any]], + ) -> bool: + """Upsert points (vectors + payloads) into a collection. + + Each point dict should have: + - "id": str or int — unique point ID + - "vector": list[float] — the embedding + - "payload": dict — arbitrary metadata + """ + result = self._qdrant_request("PUT", f"/collections/{collection}/points", { + "points": points, + }) + return result is not None + + def search( + self, + collection: str, + query_vector: list[float] | None = None, + query_text: str = "", + limit: int = 5, + filters: dict[str, Any] | None = None, + ) -> list[dict[str, Any]]: + """Search for similar vectors in a collection. + + Parameters + ---------- + collection : + Collection to search in. + query_vector : + Pre-computed embedding vector. If None, query_text is embedded. + query_text : + Text to embed and search with (used if query_vector is None). + limit : + Maximum results to return. + filters : + Optional Qdrant filter payload. + """ + if query_vector is None and query_text: + query_vector = self._embed(query_text) + if query_vector is None: + return [] + + search_body: dict[str, Any] = { + "vector": query_vector, + "limit": limit, + "with_payload": True, + } + if filters: + search_body["filter"] = filters + + result = self._qdrant_request( + "POST", f"/collections/{collection}/points/search", search_body + ) + if result: + return result.get("result", []) + return [] + + def delete_points( + self, + collection: str, + point_ids: list[str | int], + ) -> bool: + """Delete specific points from a collection.""" + result = self._qdrant_request( + "POST", + f"/collections/{collection}/points/delete", + {"points": [{"id": pid} for pid in point_ids]}, + ) + return result is not None + + def count_points(self, collection: str) -> int: + """Return the number of points in a collection.""" + result = self._qdrant_request( + "POST", f"/collections/{collection}/points/count", {} + ) + if result: + return result.get("result", {}).get("count", 0) + return 0 + + # ── Embedding Generation ─────────────────────────────────────────── + + def _embed(self, text: str) -> list[float] | None: + """Generate an embedding vector using Ollama's embedding API.""" + payload = json.dumps({ + "model": self.embedding_model, + "prompt": text, + }).encode("utf-8") + + try: + req = urllib.request.Request( + f"{self.ollama_url}/api/embeddings", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + return data.get("embedding") + except Exception as exc: + logger.error("Embedding generation failed: %s", exc) + return None + + def embed_batch(self, texts: list[str]) -> list[list[float] | None]: + """Generate embeddings for multiple texts. + + Returns a list aligned with the input texts. + """ + results = [] + for text in texts: + results.append(self._embed(text)) + return results + + # ── Skill Matching (semantic) ─────────────────────────────────────── + + def index_skills( + self, + skills: list[dict[str, Any]], + collection: str = "skills", + ) -> bool: + """Index skill descriptions for semantic matching. + + Parameters + ---------- + skills : + List of skill dicts with "name", "description", and optional "triggers". + collection : + Collection to store skill vectors. + """ + # Create collection if it doesn't exist + if collection not in self.list_collections(): + if not self.create_collection(collection, dimension=768): + return False + + points = [] + for skill in skills: + # Combine name, description, and triggers for embedding + text_parts = [skill.get("name", ""), skill.get("description", "")] + triggers = skill.get("triggers", []) + if triggers: + text_parts.append(" ".join(triggers)) + text = " ".join(text_parts) + + vector = self._embed(text) + if vector is None: + continue + + points.append({ + "id": skill.get("name", ""), + "vector": vector, + "payload": { + "name": skill.get("name", ""), + "description": skill.get("description", ""), + "category": skill.get("category", ""), + "required_tools": skill.get("required_tools", []), + "triggers": triggers, + }, + }) + + if points: + return self.upsert_points(collection, points) + return False + + def find_similar_skills( + self, + query: str, + collection: str = "skills", + limit: int = 3, + ) -> list[dict[str, Any]]: + """Find skills semantically similar to a query.""" + results = self.search(collection, query_text=query, limit=limit) + return [ + { + "name": r.get("payload", {}).get("name", ""), + "description": r.get("payload", {}).get("description", ""), + "score": r.get("score", 0.0), + "category": r.get("payload", {}).get("category", ""), + "required_tools": r.get("payload", {}).get("required_tools", []), + } + for r in results + ] + + # ── Health Check ─────────────────────────────────────────────────── + + def health_check(self) -> dict[str, Any]: + """Return Qdrant connection health status.""" + result = self._qdrant_request("GET", "/collections") + connected = result is not None + collections = [] + if result: + collections = [c["name"] for c in result.get("collections", [])] + return { + "connected": connected, + "collections": collections, + "embedding_model": self.embedding_model, + } diff --git a/src/ai_lsc/agents/redis_bridge.py b/src/ai_lsc/agents/redis_bridge.py new file mode 100644 index 0000000..29f5189 --- /dev/null +++ b/src/ai_lsc/agents/redis_bridge.py @@ -0,0 +1,367 @@ +""" +AI-LSC — Redis hot-path bridge. + +Provides the Redis integration for the Agentic OS memory layer, +handling the hot-path concerns that need sub-millisecond latency: + + - **Task Queue**: FIFO queue for agent task dispatch and coordination. + - **Pub/Sub**: Real-time event broadcasting for inter-agent communication. + - **Status Cache**: TTL-based caching of service status and health checks. + - **Lock Management**: Distributed locks for preventing concurrent conflicts. + +Redis serves as the **hot path** (real-time, volatile), while MariaDB handles +the **cold path** (persistent audit logs, task memory, config) and Qdrant +handles the **semantic path** (vector search, RAG). + +Usage +----- + bridge = RedisBridge(port=6379) + bridge.enqueue_task("rag-pipeline", {"query": " quarterly report"}) + bridge.publish_event("service_started", {"tool_id": "qdrant"}) + bridge.cache_status("qdrant", {"running": True, "port": 6333}, ttl=30) +""" + +from __future__ import annotations + +import json +import time +from typing import Any + +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + +# Channel names for pub/sub +_CHANNELS = { + "service_events": "ai_lsc:events:service", + "agent_events": "ai_lsc:events:agent", + "task_events": "ai_lsc:events:task", + "model_events": "ai_lsc:events:model", + "skill_events": "ai_lsc:events:skill", +} + +# Key prefixes for organized data +_KEY_PREFIXES = { + "task_queue": "ai_lsc:queue:", + "status_cache": "ai_lsc:status:", + "task_result": "ai_lsc:result:", + "lock": "ai_lsc:lock:", + "agent_state": "ai_lsc:agent:", + "model_pool": "ai_lsc:pool:", +} + + +class RedisBridge: + """Bridge to Redis for hot-path agentic operations. + + Uses raw ``redis-py`` for direct Redis protocol access. + Falls back to a no-op stub when redis-py is not installed. + + Parameters + ---------- + host : + Redis server hostname. + port : + Redis server port. + db : + Redis database number (default 0). + """ + def __init__( + self, + host: str = "127.0.0.1", + port: int = 6379, + db: int = 0, + ) -> None: + self.host = host + self.port = port + self.db = db + self._client = None + self._pubsub = None + self._connected = False + self._try_connect() + + def _try_connect(self) -> None: + """Attempt to connect to Redis. Graceful degradation if unavailable.""" + try: + import redis as redis_lib + self._client = redis_lib.Redis( + host=self.host, port=self.port, db=self.db, + decode_responses=True, socket_timeout=5, + ) + self._client.ping() + self._connected = True + logger.info("Redis connected at %s:%d", self.host, self.port) + except ImportError: + logger.warning("redis-py not installed — Redis features disabled") + except Exception as exc: + logger.warning("Redis not available at %s:%d: %s", self.host, self.port, exc) + + @property + def is_connected(self) -> bool: + return self._connected and self._client is not None + + # ── Task Queue ───────────────────────────────────────────────────── + + def enqueue_task( + self, + queue_name: str, + task_data: dict[str, Any], + priority: int = 0, + ) -> str | None: + """Add a task to a named queue. + + Parameters + ---------- + queue_name : + Logical queue name (e.g. "rag-pipeline", "code-review"). + task_data : + Task payload dict. + priority : + Higher priority tasks are processed first. + + Returns + ------- + Task ID string, or None if Redis is unavailable. + """ + if not self.is_connected: + return None + + task_id = f"{queue_name}:{int(time.time() * 1000)}" + task_data["_task_id"] = task_id + task_data["_enqueued_at"] = time.time() + task_data["_priority"] = priority + + key = f"{_KEY_PREFIXES['task_queue']}{queue_name}" + payload = json.dumps(task_data) + + try: + # Use sorted set with priority score for priority queue behavior + self._client.zadd(key, {payload: -priority}) # negative = higher priority first + logger.info("Enqueued task %s to queue '%s' (priority=%d)", task_id, queue_name, priority) + return task_id + except Exception as exc: + logger.error("Failed to enqueue task: %s", exc) + return None + + def dequeue_task(self, queue_name: str) -> dict[str, Any] | None: + """Pop the highest-priority task from a queue.""" + if not self.is_connected: + return None + + key = f"{_KEY_PREFIXES['task_queue']}{queue_name}" + try: + # Get highest priority (lowest negative score) + result = self._client.zpopmin(key, count=1) + if not result: + return None + payload, _ = result[0] + return json.loads(payload) + except Exception as exc: + logger.error("Failed to dequeue task: %s", exc) + return None + + def queue_length(self, queue_name: str) -> int: + """Return the number of pending tasks in a queue.""" + if not self.is_connected: + return 0 + key = f"{_KEY_PREFIXES['task_queue']}{queue_name}" + try: + return self._client.zcard(key) + except Exception: + return 0 + + # ── Pub/Sub ──────────────────────────────────────────────────────── + + def publish_event( + self, + event_type: str, + data: dict[str, Any], + ) -> bool: + """Publish an event to the appropriate channel. + + Parameters + ---------- + event_type : + One of the channel types (service_events, agent_events, etc.) + data : + Event payload. + """ + if not self.is_connected: + return False + + channel = _CHANNELS.get(event_type, _CHANNELS["service_events"]) + data["_timestamp"] = time.time() + data["_event_type"] = event_type + + try: + self._client.publish(channel, json.dumps(data)) + logger.debug("Published %s event", event_type) + return True + except Exception as exc: + logger.error("Failed to publish event: %s", exc) + return False + + # ── Status Cache ──────────────────────────────────────────────────── + + def cache_status( + self, + tool_id: str, + status_data: dict[str, Any], + ttl: int = 30, + ) -> bool: + """Cache a tool's status with an expiration time. + + Parameters + ---------- + tool_id : + The tool identifier. + status_data : + Status payload (running, port, cpu, etc.). + ttl : + Time-to-live in seconds. + """ + if not self.is_connected: + return False + + key = f"{_KEY_PREFIXES['status_cache']}{tool_id}" + try: + self._client.setex(key, ttl, json.dumps(status_data)) + return True + except Exception as exc: + logger.error("Failed to cache status for %s: %s", tool_id, exc) + return False + + def get_cached_status(self, tool_id: str) -> dict[str, Any] | None: + """Retrieve cached status for a tool.""" + if not self.is_connected: + return None + + key = f"{_KEY_PREFIXES['status_cache']}{tool_id}" + try: + data = self._client.get(key) + return json.loads(data) if data else None + except Exception: + return None + + # ── Task Results ─────────────────────────────────────────────────── + + def store_result( + self, + task_id: str, + result: dict[str, Any], + ttl: int = 300, + ) -> bool: + """Store a task result for retrieval by other agents.""" + if not self.is_connected: + return False + + key = f"{_KEY_PREFIXES['task_result']}{task_id}" + try: + self._client.setex(key, ttl, json.dumps(result)) + return True + except Exception as exc: + logger.error("Failed to store result: %s", exc) + return False + + def get_result(self, task_id: str) -> dict[str, Any] | None: + """Retrieve a stored task result.""" + if not self.is_connected: + return None + + key = f"{_KEY_PREFIXES['task_result']}{task_id}" + try: + data = self._client.get(key) + return json.loads(data) if data else None + except Exception: + return None + + # ── Lock Management ──────────────────────────────────────────────── + + def acquire_lock( + self, + resource: str, + ttl: int = 60, + ) -> bool: + """Try to acquire a distributed lock. + + Parameters + ---------- + resource : + Resource identifier to lock. + ttl : + Lock expiration in seconds. + + Returns + ------- + True if the lock was acquired, False if already held. + """ + if not self.is_connected: + return True # If Redis is down, allow through + + key = f"{_KEY_PREFIXES['lock']}{resource}" + try: + return bool(self._client.set(key, "1", nx=True, ex=ttl)) + except Exception as exc: + logger.error("Lock acquire failed: %s", exc) + return True + + def release_lock(self, resource: str) -> bool: + """Release a distributed lock.""" + if not self.is_connected: + return True + + key = f"{_KEY_PREFIXES['lock']}{resource}" + try: + return bool(self._client.delete(key)) + except Exception: + return False + + # ── Agent State ──────────────────────────────────────────────────── + + def save_agent_state( + self, + agent_id: str, + state: dict[str, Any], + ) -> bool: + """Persist an agent's working state to Redis.""" + if not self.is_connected: + return False + + key = f"{_KEY_PREFIXES['agent_state']}{agent_id}" + try: + self._client.hset(key, mapping={ + k: json.dumps(v) if isinstance(v, (dict, list)) else str(v) + for k, v in state.items() + }) + return True + except Exception as exc: + logger.error("Failed to save agent state: %s", exc) + return False + + def load_agent_state(self, agent_id: str) -> dict[str, Any]: + """Load an agent's working state from Redis.""" + if not self.is_connected: + return {} + + key = f"{_KEY_PREFIXES['agent_state']}{agent_id}" + try: + raw = self._client.hgetall(key) + state: dict[str, Any] = {} + for k, v in raw.items(): + try: + state[k] = json.loads(v) + except (json.JSONDecodeError, TypeError): + state[k] = v + return state + except Exception: + return {} + + # ── Health Check ─────────────────────────────────────────────────── + + def health_check(self) -> dict[str, Any]: + """Return Redis connection health status.""" + return { + "connected": self.is_connected, + "host": self.host, + "port": self.port, + } diff --git a/src/ai_lsc/agents/schema.py b/src/ai_lsc/agents/schema.py new file mode 100644 index 0000000..e7c8d95 --- /dev/null +++ b/src/ai_lsc/agents/schema.py @@ -0,0 +1,208 @@ +""" +AI-LSC — Tool schema definitions for OpenAI-compatible function calling. + +Defines the JSON schema fragments that describe each tool action the LLM +can invoke. These schemas are consumed by: + - ``ToolBridge.generate_schemas()`` → full schema list + - ``ollama_tools.register_with_ollama()`` → POST /api/tools + - LibreChat / OpenWebUI tool-definition imports + +Every schema follows the OpenAI function-calling format:: + + { + "type": "function", + "function": { + "name": "", + "description": "", + "parameters": { + "type": "object", + "properties": { ... }, + "required": [ ... ] + } + } + } +""" + +from __future__ import annotations + +from typing import Any + + +# ── Common parameter fragments ───────────────────────────────────────── + +_TOOL_ID_PARAM: dict[str, Any] = { + "type": "string", + "description": "Tool identifier from the AI-LSC registry " + "(e.g. 'ollama', 'qdrant', 'redis').", +} + +_PORT_PARAM: dict[str, Any] = { + "type": "integer", + "description": "Override the default port (optional).", +} + +_MODEL_NAME_PARAM: dict[str, Any] = { + "type": "string", + "description": "Model name for Ollama pull " + "(e.g. 'qwen2.5:72b', 'llama3:8b').", +} + +_SKILL_NAME_PARAM: dict[str, Any] = { + "type": "string", + "description": "Skill identifier to inject into the conversation " + "(e.g. 'rag-analyst', 'code-reviewer').", +} + +_QUERY_PARAM: dict[str, Any] = { + "type": "string", + "description": "Search query or description of what to find.", +} + +_TARGET_URL_PARAM: dict[str, Any] = { + "type": "string", + "description": "URL to open in the browser.", +} + + +# ── Schema factory ───────────────────────────────────────────────────── + +def _make_schema( + name: str, + description: str, + properties: dict[str, Any], + required: list[str] | None = None, +) -> dict[str, Any]: + """Build an OpenAI function-calling tool schema.""" + return { + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": { + "type": "object", + "properties": properties, + "required": required or [], + }, + }, + } + + +# ── Pre-built schemas for core actions ───────────────────────────────── + +SCHEMA_START_SERVICE = _make_schema( + name="start_service", + description="Start an AI-LSC managed service by its tool ID. " + "The service must be installed first.", + properties={ + "tool_id": _TOOL_ID_PARAM, + "port": _PORT_PARAM, + }, + required=["tool_id"], +) + +SCHEMA_STOP_SERVICE = _make_schema( + name="stop_service", + description="Stop a running AI-LSC managed service.", + properties={"tool_id": _TOOL_ID_PARAM}, + required=["tool_id"], +) + +SCHEMA_CHECK_STATUS = _make_schema( + name="check_service_status", + description="Check whether an AI-LSC service is currently running " + "and return its status.", + properties={"tool_id": _TOOL_ID_PARAM}, + required=["tool_id"], +) + +SCHEMA_PULL_MODEL = _make_schema( + name="pull_model", + description="Pull/download an Ollama model from the registry. " + "Requires Ollama to be running.", + properties={"model_name": _MODEL_NAME_PARAM}, + required=["model_name"], +) + +SCHEMA_LIST_TOOLS = _make_schema( + name="list_available_tools", + description="List all tools in the AI-LSC registry, optionally " + "filtered by layer, category, or status.", + properties={ + "filter_layer": { + "type": "string", + "description": "Optional layer name filter " + "(e.g. 'Inference Engines', 'Data & Knowledge Pipelines').", + }, + "filter_category": { + "type": "string", + "description": "Optional category filter " + "(e.g. 'Database', 'Cache', 'Vector Store').", + }, + "running_only": { + "type": "boolean", + "description": "If true, only return currently running tools.", + }, + }, +) + +SCHEMA_INJECT_SKILL = _make_schema( + name="inject_skill", + description="Inject a skill's system prompt into the current " + "conversation context. The skill must be registered in " + "the skills directory.", + properties={ + "skill_name": _SKILL_NAME_PARAM, + "params": { + "type": "object", + "description": "Optional key-value parameters for the skill.", + }, + }, + required=["skill_name"], +) + +SCHEMA_OPEN_WEB = _make_schema( + name="open_web_interface", + description="Open a tool's web interface in the browser.", + properties={ + "tool_id": _TOOL_ID_PARAM, + "port": _PORT_PARAM, + }, + required=["tool_id"], +) + +SCHEMA_SEARCH_REGISTRY = _make_schema( + name="search_registry", + description="Search the tool registry by keyword. Returns matching " + "tools with their IDs, descriptions, layers, and status.", + properties={"query": _QUERY_PARAM}, + required=["query"], +) + +SCHEMA_INSTALL_TOOL = _make_schema( + name="install_tool", + description="Install a tool from the AI-LSC registry if not " + "already present on the system.", + properties={ + "tool_id": _TOOL_ID_PARAM, + }, + required=["tool_id"], +) + + +# ── Convenience lookups ─────────────────────────────────────────────── + +CORE_SCHEMAS: list[dict[str, Any]] = [ + SCHEMA_START_SERVICE, + SCHEMA_STOP_SERVICE, + SCHEMA_CHECK_STATUS, + SCHEMA_PULL_MODEL, + SCHEMA_LIST_TOOLS, + SCHEMA_INJECT_SKILL, + SCHEMA_OPEN_WEB, + SCHEMA_SEARCH_REGISTRY, + SCHEMA_INSTALL_TOOL, +] + +SCHEMA_BY_NAME: dict[str, dict[str, Any]] = { + s["function"]["name"]: s for s in CORE_SCHEMAS +} diff --git a/src/ai_lsc/agents/skill_injector.py b/src/ai_lsc/agents/skill_injector.py new file mode 100644 index 0000000..6055a8f --- /dev/null +++ b/src/ai_lsc/agents/skill_injector.py @@ -0,0 +1,276 @@ +""" +AI-LSC — Three-phase skill injection. + +Manages progressive skill loading into agent context to minimize token +usage while maximizing capability: + + Phase 1: **Summary** — One-line skill description injected into the + system prompt so the agent knows what skills exist. + Phase 2: **Full skill** — Complete system prompt loaded when the agent + selects a skill for a task. + Phase 3: **Sub-files** — Additional reference files loaded on demand + (e.g. examples, templates, schema files). + +This avoids stuffing 50+ skill system prompts into context up front. + +Usage +----- + injector = SkillInjector(skill_resolver, skills_root) + # Phase 1: Build summary for system prompt + summary = injector.build_skill_summary() + # Phase 2: Get full skill prompt + full = injector.get_full_prompt("rag-analyst") + # Phase 3: Load sub-files for deep context + subs = injector.load_sub_files("rag-analyst", ["examples/", "schema.json"]) +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ai_lsc.agents.skill_resolver import EnhancedSkillResolver, SkillDefinition +from ai_lsc.utils.logging import get_logger + +logger = get_logger(__name__) + + +class SkillInjector: + """Three-phase skill injection manager. + + Parameters + ---------- + skill_resolver : + An ``EnhancedSkillResolver`` for loading skill metadata. + skills_root : + Path to the skills directory on disk. + """ + + def __init__( + self, + skill_resolver: EnhancedSkillResolver, + skills_root: str | Path, + ) -> None: + self.resolver = skill_resolver + self.skills_root = Path(skills_root) + self._phase2_cache: dict[str, str] = {} + self._phase3_cache: dict[str, dict[str, str]] = {} + + # ── Phase 1: Skill Summary ───────────────────────────────────────── + + def build_skill_summary(self, active_tools: set[str] | None = None) -> str: + """Build a compact summary of all available skills. + + This is injected into the system prompt so the agent knows + what skills exist without loading their full prompts. + + Parameters + ---------- + active_tools : + Currently active tool IDs — skills whose deps are met + are marked as [READY], others as [needs: deps...]. + + Returns + ------- + A multi-line summary string suitable for system prompt injection. + """ + active = active_tools or set() + lines = ["Available Skills:", "=" * 40] + + skills = self.resolver.list_all() + if not skills: + lines.append(" (no skills registered)") + return "\n".join(lines) + + for skill in skills: + missing = self.resolver.check_dependencies(skill.name, active) + if not missing: + status = "[READY]" + else: + status = f"[needs: {', '.join(missing)}]" + + triggers = ", ".join(skill.triggers[:3]) if skill.triggers else "no triggers" + lines.append( + f" {skill.name} {status} — {skill.description}\n" + f" Triggers: {triggers}" + ) + + lines.append("") + lines.append( + "Use inject_skill to load a skill's full prompt. " + "Only READY skills can be used immediately." + ) + return "\n".join(lines) + + # ── Phase 2: Full Skill Prompt ───────────────────────────────────── + + def get_full_prompt(self, skill_name: str) -> str: + """Load the complete system prompt for a skill. + + Cached after first load to avoid repeated disk I/O. + + Parameters + ---------- + skill_name : + The skill identifier. + + Returns + ------- + The full system prompt text, or an error message if not found. + """ + if skill_name in self._phase2_cache: + return self._phase2_cache[skill_name] + + skill = self.resolver.resolve(skill_name) + + if not skill.system_prompt: + msg = f"Skill '{skill_name}' has no SYSTEM block defined." + logger.warning(msg) + self._phase2_cache[skill_name] = msg + return msg + + # Wrap the system prompt with skill context + prompt_parts = [ + f"", + f"{skill.description}", + f"{skill.category}", + ] + + if skill.input_schema: + import json + schema_str = json.dumps(skill.input_schema, indent=2) + prompt_parts.append( + f"\n{schema_str}\n" + ) + + if skill.required_tools: + prompt_parts.append( + f"{', '.join(skill.required_tools)}" + ) + + prompt_parts.append(f"\n{skill.system_prompt}\n") + prompt_parts.append("") + + full_prompt = "\n".join(prompt_parts) + self._phase2_cache[skill_name] = full_prompt + logger.info("Loaded full prompt for skill: %s (%d chars)", + skill_name, len(full_prompt)) + return full_prompt + + # ── Phase 3: Sub-files ────────────────────────────────────────────── + + def load_sub_files( + self, + skill_name: str, + file_paths: list[str], + ) -> dict[str, str]: + """Load additional reference files for a skill. + + Sub-files are stored alongside the skill in a directory + named ``.d/``:: + + skills/ + rag-analyst ← Modelfile + rag-analyst.skill.json ← Metadata + rag-analyst.d/ ← Sub-files directory + examples/ + query.txt + schema.json + prompts/ + summarize.txt + + Parameters + ---------- + skill_name : + The skill identifier. + file_paths : + Relative paths within the skill's ``.d/`` directory. + + Returns + ------- + A dict mapping file path → content string. + """ + cache_key = skill_name + if cache_key not in self._phase3_cache: + self._phase3_cache[cache_key] = {} + + results: dict[str, str] = {} + skill_dir = self.skills_root / f"{skill_name}.d" + + for rel_path in file_paths: + # Check cache first + if rel_path in self._phase3_cache[cache_key]: + results[rel_path] = self._phase3_cache[cache_key][rel_path] + continue + + full_path = skill_dir / rel_path + if not full_path.exists(): + results[rel_path] = f"[File not found: {rel_path}]" + continue + + try: + content = full_path.read_text(encoding="utf-8", errors="ignore") + results[rel_path] = content + self._phase3_cache[cache_key][rel_path] = content + logger.info("Loaded sub-file for %s: %s (%d chars)", + skill_name, rel_path, len(content)) + except OSError as exc: + results[rel_path] = f"[Error reading {rel_path}: {exc}]" + logger.warning("Failed to load sub-file %s/%s: %s", + skill_name, rel_path, exc) + + return results + + def list_sub_files(self, skill_name: str) -> list[str]: + """List available sub-files for a skill.""" + skill_dir = self.skills_root / f"{skill_name}.d" + if not skill_dir.is_dir(): + return [] + return sorted( + str(p.relative_to(skill_dir)) + for p in skill_dir.rglob("*") + if p.is_file() + ) + + # ── Cache management ─────────────────────────────────────────────── + + def clear_cache(self, skill_name: str | None = None) -> None: + """Clear cached prompts. If skill_name is None, clears all.""" + if skill_name is None: + self._phase2_cache.clear() + self._phase3_cache.clear() + else: + self._phase2_cache.pop(skill_name, None) + self._phase3_cache.pop(skill_name, None) + + def get_injection_context( + self, + skill_name: str, + active_tools: set[str], + include_sub_files: bool = False, + ) -> dict[str, Any]: + """Build the complete injection context for a skill. + + Returns a dict with all phases assembled, ready for the + dispatcher to send back to the LLM. + """ + skill = self.resolver.resolve(skill_name) + missing = self.resolver.check_dependencies(skill_name, active_tools) + + context: dict[str, Any] = { + "skill_name": skill.name, + "description": skill.description, + "category": skill.category, + "missing_deps": missing, + "ready": len(missing) == 0, + "full_prompt": self.get_full_prompt(skill_name) if not missing else "", + } + + if include_sub_files and not missing: + available_subs = self.list_sub_files(skill_name) + if available_subs: + context["sub_files"] = self.load_sub_files( + skill_name, available_subs[:5] # limit to 5 sub-files + ) + + return context diff --git a/src/ai_lsc/agents/skill_resolver.py b/src/ai_lsc/agents/skill_resolver.py new file mode 100644 index 0000000..c746d3d --- /dev/null +++ b/src/ai_lsc/agents/skill_resolver.py @@ -0,0 +1,276 @@ +""" +AI-LSC — Enhanced skill resolver with dependency checking. + +Extends the base ``SkillRuntimeResolver`` with: + - Structured skill metadata from ``.skill.json`` companion files + - Tool dependency resolution (skills that require running services) + - Trigger-keyword matching for automatic skill activation + - Skill categorization and filtering + +Skill file layout +----------------- +A skill can now be accompanied by a ``.skill.json`` metadata file:: + + skills/ + rag-analyst ← Modelfile with SYSTEM block + rag-analyst.skill.json ← Structured metadata + +The ``.skill.json`` format:: + + { + "name": "rag-analyst", + "description": "Analyze documents using RAG pipeline", + "required_tools": ["qdrant", "ollama"], + "triggers": ["analyze document", "search knowledge base"], + "input_schema": { ... }, + "category": "analysis" + } + +Usage +----- + resolver = EnhancedSkillResolver(skills_root, registry_data) + skill = resolver.resolve("rag-analyst") + matches = resolver.find_by_trigger("analyze the quarterly report") +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +class SkillDefinition: + """Structured metadata for a single skill. + + Parameters + ---------- + name : + Skill identifier (matches the Modelfile filename). + description : + Human-readable description. + system_prompt : + Extracted SYSTEM block from the Modelfile. + required_tools : + Tool IDs from the registry that must be running. + triggers : + Keywords/phrases that should activate this skill. + input_schema : + JSON schema for skill input parameters. + category : + Skill category for grouping/filtering. + extra : + Additional metadata from the .skill.json file. + """ + + __slots__ = ( + "name", "description", "system_prompt", + "required_tools", "triggers", "input_schema", + "category", "extra", + ) + + def __init__( + self, + name: str, + description: str = "", + system_prompt: str = "", + required_tools: list[str] | None = None, + triggers: list[str] | None = None, + input_schema: dict[str, Any] | None = None, + category: str = "general", + extra: dict[str, Any] | None = None, + ) -> None: + self.name = name + self.description = description + self.system_prompt = system_prompt + self.required_tools = required_tools or [] + self.triggers = triggers or [] + self.input_schema = input_schema + self.category = category + self.extra = extra or {} + + def to_dict(self) -> dict[str, Any]: + """Serialize to JSON-safe dict.""" + return { + "name": self.name, + "description": self.description, + "has_system_prompt": bool(self.system_prompt), + "required_tools": self.required_tools, + "triggers": self.triggers, + "category": self.category, + **self.extra, + } + + +class EnhancedSkillResolver: + """Enhanced skill resolver with metadata and dependency checking. + + Parameters + ---------- + skills_root : + Path to the skills directory. + registry_data : + Full registry dict for dependency resolution. + """ + + def __init__( + self, + skills_root: str | Path, + registry_data: dict[str, dict[str, Any]] | None = None, + ) -> None: + self.skills_root = Path(skills_root) + self.registry = registry_data or {} + self._cache: dict[str, SkillDefinition] = {} + + # ── Skill resolution ──────────────────────────────────────────── + + def resolve(self, skill_name: str) -> SkillDefinition: + """Resolve a skill by name, loading metadata from disk.""" + if skill_name in self._cache: + return self._cache[skill_name] + + skill_file = self.skills_root / skill_name + meta_file = self.skills_root / f"{skill_name}.skill.json" + + # Extract system prompt from Modelfile + system_prompt = self._extract_system_prompt(skill_file) + + # Load metadata from companion JSON + meta = self._load_skill_meta(meta_file) + + definition = SkillDefinition( + name=meta.get("name", skill_name), + description=meta.get( + "description", + self._extract_description(skill_file), + ), + system_prompt=system_prompt, + required_tools=meta.get("required_tools", []), + triggers=meta.get("triggers", []), + input_schema=meta.get("input_schema"), + category=meta.get("category", "general"), + extra=meta, + ) + self._cache[skill_name] = definition + return definition + + # ── Trigger matching ───────────────────────────────────────────── + + def find_by_trigger( + self, text: str, + ) -> list[SkillDefinition]: + """Find skills whose triggers match the given text. + + Used for automatic skill activation when a user message + contains trigger keywords. + """ + text_lower = text.lower() + matches: list[SkillDefinition] = [] + for name in self._scan_skill_files(): + skill = self.resolve(name) + for trigger in skill.triggers: + if trigger.lower() in text_lower: + matches.append(skill) + break + return matches + + # ── Dependency checking ───────────────────────────────────────── + + def check_dependencies( + self, + skill_name: str, + active_tools: set[str], + ) -> list[str]: + """Return required tools that are not yet running. + + Parameters + ---------- + skill_name : + The skill to check. + active_tools : + Set of currently active tool IDs. + """ + skill = self.resolve(skill_name) + return [ + t for t in skill.required_tools + if t not in active_tools + ] + + def get_skills_for_active_tools( + self, + active_tools: set[str], + ) -> list[SkillDefinition]: + """Return all skills whose dependencies are satisfied.""" + results: list[SkillDefinition] = [] + for name in self._scan_skill_files(): + skill = self.resolve(name) + missing = self.check_dependencies(name, active_tools) + if not missing: + results.append(skill) + return results + + # ── Listing ────────────────────────────────────────────────────── + + def list_all(self) -> list[SkillDefinition]: + """Return all skill definitions.""" + return [self.resolve(n) for n in self._scan_skill_files()] + + def list_by_category( + self, category: str, + ) -> list[SkillDefinition]: + """Return skills filtered by category.""" + return [ + s for s in self.list_all() + if s.category == category + ] + + # ── Internal helpers ──────────────────────────────────────────── + + def _scan_skill_files(self) -> list[str]: + """Return names of Modelfile skill definitions.""" + if not self.skills_root.is_dir(): + return [] + return sorted( + p.name for p in self.skills_root.iterdir() + if p.is_file() and not p.name.endswith(".skill.json") + and not p.name.endswith(".json") + ) + + def _extract_system_prompt(self, path: Path) -> str: + """Extract SYSTEM block from a Modelfile.""" + if not path.exists(): + return "" + import re + try: + content = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return "" + patterns = [ + (r'SYSTEM\s+"""(.*?)"""', re.DOTALL | re.IGNORECASE), + (r'SYSTEM\s+"(.*?)"', re.IGNORECASE), + ] + return next( + (m.group(1).strip() + for pattern, flags in patterns + for m in [re.search(pattern, content, flags)] + if m), + "", + ) + + def _extract_description(self, path: Path) -> str: + """Extract a one-line description from a Modelfile.""" + prompt = self._extract_system_prompt(path) + if not prompt: + return "" + first_line = prompt.split("\n")[0].strip() + return first_line[:200] if first_line else "" + + @staticmethod + def _load_skill_meta(path: Path) -> dict[str, Any]: + """Load metadata from a .skill.json companion file.""" + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} diff --git a/src/ai_lsc/agents/tool_bridge.py b/src/ai_lsc/agents/tool_bridge.py new file mode 100644 index 0000000..ae68176 --- /dev/null +++ b/src/ai_lsc/agents/tool_bridge.py @@ -0,0 +1,147 @@ +""" +AI-LSC — Registry-to-function-calling schema translator. + +``ToolBridge`` reads the 115-tool registry and generates OpenAI-compatible +tool schemas that describe which actions an LLM can take. It also enriches +schemas with context from the registry (descriptions, layers, ports). + +Usage +----- + bridge = ToolBridge(registry_mgr) + schemas = bridge.generate_all_schemas() + # Pass schemas to Ollama, LibreChat, or OpenWebUI +""" + +from __future__ import annotations + +from typing import Any + +from ai_lsc.agents.schema import ( + CORE_SCHEMAS, + SCHEMA_BY_NAME, + _make_schema, +) +from ai_lsc.constants import DEFAULT_PORTS + + +class ToolBridge: + """Translates the AI-LSC registry into function-calling tool schemas. + + Parameters + ---------- + registry_data : + The full registry dict from ``RegistryManager.get_all_tools()``. + active_tools : + Set of tool IDs currently active in the pipeline (from + ``PipelineState.active_tools``). Used to annotate which + tools are available vs. just registered. + """ + + def __init__( + self, + registry_data: dict[str, dict[str, Any]], + active_tools: set[str] | None = None, + ) -> None: + self.registry = registry_data + self.active_tools = active_tools or set() + + # ── Schema generation ──────────────────────────────────────────── + + def generate_all_schemas(self) -> list[dict[str, Any]]: + """Return the complete list of tool schemas for function calling. + + This merges the 9 core action schemas with per-tool annotations + for tools that have web interfaces or are Ollama models. + """ + schemas = list(CORE_SCHEMAS) + + # Annotate list_tools with available tool IDs + list_schema = SCHEMA_BY_NAME["list_available_tools"] + tool_names = sorted(self.registry.keys()) + list_desc = ( + f"{list_schema['function']['description']} " + f"Known tools: {', '.join(tool_names[:20])}" + f"{'...' if len(tool_names) > 20 else ''}. " + f"Active: {', '.join(sorted(self.active_tools)) or 'none'}." + ) + schemas.append(_make_schema( + name="list_available_tools", + description=list_desc, + properties=list_schema["function"]["parameters"]["properties"], + required=list_schema["function"]["parameters"]["required"], + )) + + return schemas + + def generate_tool_summary(self) -> str: + """Return a human-readable summary of all registered tools. + + Designed to be injected as context into the LLM's system prompt + so it knows what tools are available without needing a tool call. + """ + lines = ["AI-LSC Managed Tools:", "=" * 40] + for tool_id, meta in sorted(self.registry.items()): + status = "ACTIVE" if tool_id in self.active_tools else "available" + port = meta.get("launcher", {}).get("default_port") + port_str = f" :{port}" if port else "" + desc = meta.get("description", "No description") + flags = meta.get("flags", {}) + flags_str = [] + if flags.get("has_web"): + flags_str.append("web") + if flags.get("is_ollama"): + flags_str.append("ollama") + if flags.get("has_cli"): + flags_str.append("cli") + flag_str = f" [{','.join(flags_str)}]" if flags_str else "" + lines.append( + f" {tool_id}{port_str} — {desc} ({status}){flag_str}" + ) + return "\n".join(lines) + + # ── Tool lookup helpers ─────────────────────────────────────────── + + def get_tool_info(self, tool_id: str) -> dict[str, Any]: + """Return registry metadata for a single tool, or empty dict.""" + return self.registry.get(tool_id, {}) + + def get_tools_by_layer(self, layer: str) -> list[tuple[str, dict]]: + """Return all tools in a given layer.""" + return [ + (tid, meta) for tid, meta in self.registry.items() + if meta.get("layer") == layer + ] + + def get_tools_by_flag( + self, flag: str, value: bool = True + ) -> list[tuple[str, dict]]: + """Return tools matching a specific flag (e.g. 'has_web').""" + return [ + (tid, meta) for tid, meta in self.registry.items() + if meta.get("flags", {}).get(flag) == value + ] + + def get_web_tools(self) -> list[tuple[str, dict]]: + """Return all tools with web interfaces.""" + return self.get_tools_by_flag("has_web") + + def get_ollama_tools(self) -> list[tuple[str, dict]]: + """Return all Ollama-related tools.""" + return self.get_tools_by_flag("is_ollama") + + def suggest_model_for_task(self, task_type: str) -> str: + """Suggest an appropriate Ollama model tier for a task type. + + This mirrors the Layer 1 routing logic from the agentic + architecture template. + """ + routing: dict[str, str] = { + "document": "70b", + "chart": "70b", + "web": "70b", + "script": "32b", + "analysis": "70b", + "classification": "8b", + "clarification": "14b", + } + return routing.get(task_type, "32b") diff --git a/src/ai_lsc/chat/__init__.py b/src/ai_lsc/chat/__init__.py new file mode 100644 index 0000000..c1f675f --- /dev/null +++ b/src/ai_lsc/chat/__init__.py @@ -0,0 +1 @@ +"""AI-LSC chat sub-package.""" diff --git a/src/ai_lsc/chat/api.py b/src/ai_lsc/chat/api.py new file mode 100644 index 0000000..8db103c --- /dev/null +++ b/src/ai_lsc/chat/api.py @@ -0,0 +1,165 @@ +""" +AI-LSC — Chat API thread-pool worker. + +Isolates all network I/O (Ollama ``/api/chat`` endpoint) from the GUI +main loop using Qt's ``QThreadPool`` + ``QRunnable`` pattern. + +Architecture +------------ +``ApiRunnable`` is submitted to the thread pool. When the HTTP call +completes (or fails), results are delivered back to the main thread +via ``WorkerSignals.result`` — a Qt Signal that the UI connects to +with a slot running on the main thread. + +No UI widgets are imported here; only ``PySide6.QtCore`` for the +Signal / Runnable machinery. + +Availability +------------- +If PySide6 is not installed this module still imports successfully but +``WorkerSignals`` and ``ApiRunnable`` will be ``None``. The top-level +``__init__.py`` handles this gracefully. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request + +try: + from PySide6.QtCore import QObject, QRunnable, Signal + _HAS_QT = True +except ImportError: + _HAS_QT = False + + +# ── Signal emitter (thread-safe bridge to main loop) ─────────────────── + +if _HAS_QT: + class WorkerSignals(QObject): + """Emits results from a background thread back to the main thread. + + ``result`` carries three values: + 1. ``identity`` (str) — display name for the response source. + 2. ``reply`` (str) — the assistant's response or error message. + 3. ``history_append`` (str | None) — text to append to the chat + history, or *None* if the response was an error. + """ + result = Signal(str, str, object) + + +# ── API runnable ───────────────────────────────────────────────────── + +if _HAS_QT: + class ApiRunnable(QRunnable): + """Background task that calls the Ollama ``/api/chat`` endpoint. + + Parameters + ---------- + model_id : + Model identifier string passed to the Ollama API (e.g. + ``"llama3:8b"``). + port_id : + Port number of the running Ollama server. + history_snapshot : + List of ``{"role": …, "content": …}`` message dicts sent as + the conversation history. + temperature : + Sampling temperature (0.0–2.0). + max_tokens : + Maximum tokens to generate (``num_predict`` in Ollama API). + timeout : + HTTP request timeout in seconds. + """ + + def __init__( + self, + model_id: str, + port_id: int, + history_snapshot: list[dict], + temperature: float = 0.7, + max_tokens: int = 4096, + timeout: float = 120.0, + ) -> None: + super().__init__() + self.model_id = model_id + self.port_id = port_id + self.history_snapshot = history_snapshot + self.temperature = temperature + self.max_tokens = max_tokens + self.timeout = timeout + self.signals = WorkerSignals() + self.setAutoDelete(True) + + def run(self) -> None: + identity, reply, history_append = self.model_id, "", None + try: + url = f"http://127.0.0.1:{self.port_id}/api/chat" + payload = json.dumps({ + "model": self.model_id, + "messages": self.history_snapshot, + "stream": False, + "options": { + "temperature": self.temperature, + "num_predict": self.max_tokens, + }, + }).encode("utf-8") + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + reply = data.get("message", {}).get("content", "").strip() + + if not reply: + identity, reply = "? System Guard", ( + "Received empty execution token from model. " + "Core might lack system context space allocation." + ) + else: + history_append = reply + + except urllib.error.HTTPError as he: + identity = "? Ollama Stack Exception" + err_body = self._safe_read_error(he) + reply = ( + f"Ollama Engine rejected execution layout " + f"(Code {he.code}).\n\n" + f"[Reason]: {err_body}\n\n" + "*Troubleshooting:*\n" + "1. Did you click 'Build/Register Selected Skills' first?\n" + "2. Ensure the base model has been pulled." + ) + + except urllib.error.URLError as ue: + identity = "? Cluster Port Offline" + reply = ( + f"Failed to connect to Ollama API on port " + f"[{self.port_id}].\n\n" + f"[Details]: {ue.reason}\n\n" + "*Action Required*: Verify Ollama is [ LIVE ] on Dashboard." + ) + + except Exception as e: + identity = "? Exception Tracker" + reply = f"Unhandled background interruption:\n{e}" + + self.signals.result.emit(identity, reply, history_append) + + @staticmethod + def _safe_read_error(http_error) -> str: + """Best-effort extraction of the error body from an HTTP error.""" + try: + body = json.loads( + http_error.read().decode("utf-8", errors="ignore") + ) + return body.get("error", str(body)) + except Exception: + return "Internal structural parser issue." +else: + WorkerSignals = None # type: ignore[assignment, misc] + ApiRunnable = None # type: ignore[assignment, misc] diff --git a/src/ai_lsc/constants.py b/src/ai_lsc/constants.py new file mode 100644 index 0000000..4c58e04 --- /dev/null +++ b/src/ai_lsc/constants.py @@ -0,0 +1,185 @@ +""" +AI-LSC v3.0 — Application-wide constants. + +Release codename: Ankh of Jah + +Pure data: file names, schema version, required directories, default ports, +status styles, log colours, service licences, tree-skip patterns, and the +navigation layer order. No behaviour lives here. +""" + +import os + +# ── Base directory ───────────────────────────────────────────────────── +# Overridable via AI_LSC_BASE_DIR environment variable. +# Bootstrap sets this; the app resolves everything relative to it. +BASE_DIR: str = os.environ.get("AI_LSC_BASE_DIR", "/mnt/AI") + +# ── Filenames ──────────────────────────────────────────────────────────── +APP_VERSION: str = "3.0.5" +APP_CODENAME: str = "Ankh of Jah" +APP_DISPLAY_NAME: str = f"AI - Local Stack Control v{APP_VERSION} - http://dcos.net" +CONFIG_FILE: str = "controller_config.json" +APP_ICON_FILE: str = "ai-lsc-logo.png" +STATE_FILE_NAME: str = "pipeline_state.json" +PIPELINE_FILE_NAME: str = "pipeline.json" +STACK_SCHEMA_VERSION: str = "3.0" +MANIFEST_FILE_NAME: str = ".ai-lsc-project.json" +JCL_FILE_NAME: str = ".ai-lsc-jobs.json" + +# ── Required sub-directories under BASE_DIR ──────────────────── +REQUIRED_DIRS: list[str] = [ + "bin", + "tools", + "registry", + "config", + "cache", + "runtime", + "logs", + "skills", + "datasets/raw", + "models/ollama", + "models/chroma", + "workspaces/hermes", + "workspaces/openwebui", + "workspaces/n8n", + "tmp", + "exports", + "data", + "containers", + "configs", + "pipelines", + "dashboards", + "backups", +] + +# ── Default ports for every known tool ─────────────────────────────────── +DEFAULT_PORTS: dict[str, int | None] = { + "postgresql": 5432, "mariadb": 3306, "redis": 6379, + "sqlite3": None, "python": None, "cuda": None, + "ollama": 11434, "llamacpp": 8080, "vllm": 8000, + "litellm": 4000, "chromadb": 8000, "whisper": None, + "docling": None, "aider": None, "claude_code": None, + "fabric": None, "btop": None, "glances": 61208, + "crewai": None, "autogen": None, + "hermes": 17050, "openwebui": 8080, "anythingllm": 3001, + "flowise": 3000, "dify": 80, "stack_exporter": None, + # Agentic OS stack additions + "qdrant": 6333, "librechat": 3080, "n8n": 5678, +} + +# ── UI status label formatting ────────────────────────────────────────── +STATUS_STYLES: dict[bool, tuple[str, str]] = { + True: ("[ LIVE ]", "#2ecc71"), + False: ("[ OFFLINE ]", "#7f8c8d"), +} + +# ── Log source colours for the activity feed ──────────────────────────── +LOG_SOURCE_COLORS: dict[str, str] = { + "Ollama": "#e67e22", "Tmux": "#3498db", + "Installer": "#2ecc71", "Audit": "#f39c12", + "Container": "#9b59b6", "SkillRuntime": "#1abc9c", + "Pipeline": "#e74c3c", "Lifecycle": "#2980b9", + "SelfHeal": "#8e44ad", "Compiler": "#e67e22", +} +LOG_COLOR_DEFAULT: str = "#bdc3c7" + +# ── Service licence notices ──────────────────────────────────────────── +SERVICE_LICENSES: dict[str, str] = { + "Open WebUI": "MIT License: github.com/open-webui/open-webui", + "Aider": "Apache License 2.0: github.com/aider-chat/aider", + "Hermes": "MIT License (Hermes Orchestrator)", + "Odysseus": "MIT License (Local/Proprietary)", + "Dify": "Dify Open Source License: github.com/langgenius/dify", + "Flowise": "Apache License 2.0: github.com/FlowiseAI/Flowise", + "AnythingLLM": "MIT License: github.com/Mintplex-Labs/anything-llm", + "LiteLLM Proxy": "MIT License: github.com/BerriAI/litellm", + "Claude Code": "Anthropic Terms of Service: anthropic.com", + "CrewAI": "MIT License: github.com/joaomdmoura/crewAI", + "AutoGen": "MIT License: github.com/microsoft/autogen", + "LangChain": "MIT License: github.com/langchain-ai/langchain", + "LangFlow": "Apache License 2.0: github.com/langflow-ai/langflow", + "Ollama": "MIT License: github.com/ollama/ollama", + "llama.cpp": "MIT License: github.com/ggerganov/llama.cpp", + "Grafana": "AGPL-3.0: github.com/grafana/grafana", + "Prometheus": "Apache License 2.0: github.com/prometheus/prometheus", + "Qdrant": "Apache License 2.0: github.com/qdrant/qdrant", + "n8n": "Apache License 2.0 (with Fair Code): github.com/n8n-io/n8n", + "LibreChat": "MIT License: github.com/danny-avila/LibreChat", + "InvokeAI": "MIT License: github.com/invoke-ai/InvokeAI", + "Terraform": "BSL-1.1: github.com/hashicorp/terraform", + "Ansible": "GPL-3.0: github.com/ansible/ansible", + "Pulumi": "Apache License 2.0: github.com/pulumi/pulumi", + "OpenTofu": "MPL-2.0: github.com/opentofu/opentofu", +} + +# ── Tree-widget skip patterns ────────────────────────────────────────── +TREE_SKIP_PATTERNS: set[str] = {".", "__pycache__", "node_modules", "vendor"} + +# ── Navigation layer order for the sidebar rack diagram ─────────────── +NAV_LAYER_ORDER: list[str] = [ + "Host Platform", "Development Environment", "GPU Runtime", + "Inference Engines", "Distributed Runtime", "AI Endpoints", + "Data & Knowledge Pipelines", "Automation & Execution", + "Observability", "Intelligent Routing", "User Interfaces", + "DevOps", "Knowledge Management", +] + +# ── Ollama server candidate paths (probed in order) ──────────────── +# The runtime probes these paths to locate the ollama server binary or +# service data. First match wins. +OLLAMA_SERVER_CANDIDATES: list[str] = [ + "ollama", # /mnt/AI/ollama + "tools/ollama", # /mnt/AI/tools/ollama + "runtime/ollama", # /mnt/AI/runtime/ollama + "bin/ollama", # /mnt/AI/bin/ollama +] + +# ── Model tier routing (reserved for v4.0 agentic layer) ────────── +MODEL_TIERS: dict[str, dict] = { + "8b": {"max_vram_gb": 8, "desc": "Classification, routing, intent detection"}, + "14b": {"max_vram_gb": 14, "desc": "Utility, summarization, clarification"}, + "32b": {"max_vram_gb": 32, "desc": "Reasoning, analysis, code generation"}, + "70b": {"max_vram_gb": 70, "desc": "Heavy generation, complex reasoning, documents"}, +} + +# ── Agent runtime constants (reserved for v4.0 agentic layer) ──── +AGENT_DEFAULT_MODEL: str = "qwen2.5:32b" +AGENT_MAX_ROUNDS: int = 20 +CLARIFICATION_SKIP_THRESHOLD: float = 0.95 +CLARIFICATION_CONFIRM_THRESHOLD: float = 0.70 + +# ── Qt Stylesheets ────────────────────────────────────────────────────── +GLOBAL_STYLE: str = """ +QWidget { background-color: #161616; color: #e0e0e0; + font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; } +QGroupBox { border: 1px solid #333; border-radius: 6px; margin-top: 14px; + padding-top: 10px; font-weight: bold; color: #a5d6a7; } +QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; + padding: 0 5px; left: 10px; } +QPushButton { background-color: #2c3e50; color: white; border: 1px solid #1a252f; + border-radius: 4px; padding: 6px 12px; font-weight: bold; } +QPushButton:hover { background-color: #34495e; } +QPushButton:pressed { background-color: #1a252f; } +QLineEdit, QTextEdit, QComboBox, QSpinBox, QDoubleSpinBox { + background-color: #1e1e1e; border: 1px solid #444; + border-radius: 4px; padding: 5px; color: white; } +QTabWidget::pane { border: 1px solid #333; background-color: #1a1a1a; + border-radius: 4px; } +QTabBar::tab { background-color: #222; border: 1px solid #333; padding: 8px 15px; + margin-right: 2px; border-top-left-radius: 4px; border-top-right-radius: 4px; } +QTabBar::tab:selected { background-color: #3498db; color: white; font-weight: bold; } +QTableWidget, QTreeWidget, QListWidget { background-color: #1e1e1e; + gridline-color: #333; border: 1px solid #333; border-radius: 4px; } +QHeaderView::section { background-color: #2c3e50; color: white; padding: 4px; + border: 1px solid #1a252f; font-weight: bold; } +""" + +SIDEBAR_TREE_STYLE: str = """ + QTreeWidget { background-color: #111111; border: none; color: #bdc3c7; + font-family: 'Segoe UI'; font-size: 11px; } + QTreeWidget::item { padding: 6px; border-bottom: 1px solid #161616; } + QTreeWidget::item:hover { background-color: #1c1c1c; color: #fff; } + QTreeWidget::item:selected { background-color: #2c3e50; color: #2ecc71; + font-weight: bold; } +""" diff --git a/src/ai_lsc/guardrails.py b/src/ai_lsc/guardrails.py new file mode 100644 index 0000000..149bb8e --- /dev/null +++ b/src/ai_lsc/guardrails.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""AI-LSC Framework Guardrail Validator. + +Runs after any agent edit to catch: + 1. Bloat — files that grew >200% without architectural reason + 2. Size — files exceeding max_module_lines (default 300) + 3. Subprocess leakage — UI files touching subprocess/psutil directly + 4. Parent coupling — UI files reaching into self.parent instead of protocol + 5. os.path contamination — should use pathlib + 6. Lint — ruff check (if available) + +Usage: + python3 guardrails.py # validate ai_lsc/ in cwd + python3 guardrails.py --baseline # snapshot current sizes + python3 guardrails.py --fix # auto-fix ruff issues +""" + +from __future__ import annotations + +import ast +import json +import os +import sys +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +BASELINE_FILE = BASE_DIR / ".guardrail_baseline.json" +MAX_MODULE_LINES = 300 +MAX_GROWTH_FACTOR = 2.0 + +# Directories where subprocess/psutil calls are ALLOWED +RUNTIME_ALLOWED_DIRS = {"utils", "runtime", "core", "scripts", "agents"} + +# Directories where os.path usage is ALLOWED (legacy tolerance) +OSPATH_ALLOWED_DIRS = {"utils"} + +# Directories where self.parent access is ALLOWED +PARENT_ALLOWED_DIRS = set() + + +def get_file_sizes() -> dict[str, int]: + """Return {relative_path: line_count} for every .py in the package.""" + sizes: dict[str, int] = {} + for py_file in sorted(BASE_DIR.rglob("*.py")): + rel = py_file.relative_to(BASE_DIR) + try: + sizes[str(rel)] = sum(1 for _ in py_file.open(encoding="utf-8")) + except Exception: + sizes[str(rel)] = -1 + return sizes + + +def save_baseline(sizes: dict[str, int]) -> None: + BASELINE_FILE.write_text( + json.dumps(sizes, indent=2, sort_keys=True), encoding="utf-8" + ) + print(f"Baseline saved: {len(sizes)} files tracked in {BASELINE_FILE}") + + +def load_baseline() -> dict[str, int]: + if not BASELINE_FILE.exists(): + return {} + return json.loads(BASELINE_FILE.read_text(encoding="utf-8")) + + +def check_bloat(sizes: dict[str, int], baseline: dict[str, int]) -> list[str]: + """Detect files that grew > MAX_GROWTH_FACTOR without baseline.""" + errors: list[str] = [] + for path, new_size in sizes.items(): + if new_size <= 0: + continue + old_size = baseline.get(path, 0) + if old_size <= 0: + continue # new file, skip + if new_size > old_size * MAX_GROWTH_FACTOR: + growth_pct = (new_size / old_size - 1) * 100 + errors.append( + f"BLOAT: {path} grew {old_size} -> {new_size} lines " + f"(+{growth_pct:.0f}%, limit {MAX_GROWTH_FACTOR}x)" + ) + return errors + + +def check_size_limits(sizes: dict[str, int]) -> list[str]: + """Flag files exceeding max module line count.""" + errors: list[str] = [] + for path, size in sizes.items(): + if size > MAX_MODULE_LINES and not path.endswith("__init__.py"): + errors.append( + f"OVERSIZED: {path} is {size} lines " + f"(limit {MAX_MODULE_LINES})" + ) + return errors + + +def check_subprocess_leakage() -> list[str]: + """Flag UI files that directly call subprocess/psutil.""" + errors: list[str] = [] + dangerous_patterns = [ + "subprocess.run", "subprocess.Popen", "subprocess.call", + "threading.Thread", "os.system", "os.popen", + "psutil.process_iter", "psutil.cpu_percent", + ] + for py_file in sorted(BASE_DIR.rglob("*.py")): + rel = str(py_file.relative_to(BASE_DIR)) + parts = rel.split(os.sep) + # Skip if in allowed directory + if any(part in RUNTIME_ALLOWED_DIRS for part in parts): + continue + try: + source = py_file.read_text(encoding="utf-8") + except Exception: + continue + tree = ast.parse(source, filename=rel) + for node in ast.walk(tree): + if isinstance(node, ast.Attribute): + full = f"{node.value}.{node.attr}" if isinstance( + node.value, ast.Name + ) else None + if full and full in dangerous_patterns: + errors.append( + f"SUBPROCESS_LEAK: {rel}:{node.lineno} " + f"calls {full} (should delegate to runtime/)" + ) + return errors + + +def check_parent_coupling() -> list[str]: + """Flag UI files accessing self.parent.* directly.""" + errors: list[str] = [] + for py_file in sorted(BASE_DIR.rglob("*.py")): + rel = str(py_file.relative_to(BASE_DIR)) + parts = rel.split(os.sep) + if not any(part in PARENT_ALLOWED_DIRS for part in parts): + pass # check all files + try: + source = py_file.read_text(encoding="utf-8") + except Exception: + continue + tree = ast.parse(source, filename=rel) + for node in ast.walk(tree): + if isinstance(node, ast.Attribute): + if ( + isinstance(node.value, ast.Attribute) + and isinstance(node.value.value, ast.Name) + and node.value.value.id == "self" + and node.value.attr == "parent" + ): + errors.append( + f"PARENT_COUPLING: {rel}:{node.lineno} " + f"accesses self.parent.{node.attr} " + f"(use MainWindowProtocol instead)" + ) + return errors + + +def check_ospath_contamination() -> list[str]: + """Flag files using os.path instead of pathlib.""" + errors: list[str] = [] + for py_file in sorted(BASE_DIR.rglob("*.py")): + rel = str(py_file.relative_to(BASE_DIR)) + parts = rel.split(os.sep) + if any(part in OSPATH_ALLOWED_DIRS for part in parts): + continue + try: + source = py_file.read_text(encoding="utf-8") + except Exception: + continue + count = source.count("os.path.") + if count > 0: + errors.append( + f"OSPATH: {rel} has {count} os.path.* calls " + f"(use pathlib.Path)" + ) + return errors + + +def run_ruff(fix: bool = False) -> list[str]: + """Run ruff if available, return error output.""" + import shutil + ruff_bin = shutil.which("ruff") + if not ruff_bin: + try: + import ruff as _ # noqa: F401 + ruff_bin = sys.executable + " -m ruff" + except ImportError: + return ["RUFF: not installed (pip install ruff)"] + import subprocess + cmd = [sys.executable, "-m", "ruff", "check", str(BASE_DIR)] + if fix: + cmd.append("--fix") + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=30 + ) + output = result.stdout.strip() + result.stderr.strip() + if output: + return [f"RUFF:\n{output}"] + return [] + except Exception as e: + return [f"RUFF: failed to run: {e}"] + + +def main() -> int: + args = set(sys.argv[1:]) + + if "--baseline" in args: + sizes = get_file_sizes() + save_baseline(sizes) + return 0 + + sizes = get_file_sizes() + baseline = load_baseline() + + all_errors: list[str] = [] + + print("=" * 60) + print("AI-LSC Framework Guardrail Validation") + print("=" * 60) + + # 1. Bloat check + errors = check_bloat(sizes, baseline) + if errors: + all_errors.extend(errors) + print(f"\n[FAIL] Bloat detection: {len(errors)} violations") + elif baseline: + print("\n[PASS] Bloat detection: no abnormal growth") + else: + print("\n[SKIP] Bloat detection: no baseline (run with --baseline)") + + # 2. Size limits + errors = check_size_limits(sizes) + if errors: + all_errors.extend(errors) + print(f"[FAIL] Size limits: {len(errors)} oversized modules") + else: + print(f"[PASS] Size limits: all modules under {MAX_MODULE_LINES} lines") + + # 3. Subprocess leakage + errors = check_subprocess_leakage() + if errors: + all_errors.extend(errors) + print(f"[FAIL] Subprocess leakage: {len(errors)} violations") + else: + print("[PASS] Subprocess leakage: clean") + + # 4. Parent coupling + errors = check_parent_coupling() + if errors: + all_errors.extend(errors) + print(f"[FAIL] Parent coupling: {len(errors)} violations") + else: + print("[PASS] Parent coupling: clean") + + # 5. os.path contamination + errors = check_ospath_contamination() + if errors: + all_errors.extend(errors) + print(f"[FAIL] os.path: {len(errors)} files with os.path.* calls") + else: + print("[PASS] os.path: clean") + + # 6. Ruff lint + fix_mode = "--fix" in args + errors = run_ruff(fix=fix_mode) + if errors: + all_errors.extend(errors) + print(f"[{'FIXED' if fix_mode else 'FAIL'}] Ruff lint: see above") + else: + print("[PASS] Ruff lint: clean") + + # Summary + print("\n" + "=" * 60) + if all_errors: + print(f"RESULT: {len(all_errors)} guardrail violations") + for e in all_errors: + # Truncate long ruff output + lines = e.split("\n") + for line in lines[:5]: + print(f" {line}") + if len(lines) > 5: + print(f" ... ({len(lines) - 5} more lines)") + return 1 + else: + print("RESULT: ALL GUARDRAILS PASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ai_lsc/manifest/__init__.py b/src/ai_lsc/manifest/__init__.py new file mode 100644 index 0000000..237b84b --- /dev/null +++ b/src/ai_lsc/manifest/__init__.py @@ -0,0 +1 @@ +"""AI-LSC manifest sub-package.""" diff --git a/src/ai_lsc/manifest/support.py b/src/ai_lsc/manifest/support.py new file mode 100644 index 0000000..5324812 --- /dev/null +++ b/src/ai_lsc/manifest/support.py @@ -0,0 +1,155 @@ +""" +AI-LSC — Manifest support. + +Reads and writes ``.ai-lsc-project.json`` and ``.ai-lsc-jobs.json`` +files that provide project-level context for the chat interface. +Pure filesystem + JSON work — no UI. + +Manifest schema (``.ai-lsc-project.json``):: + + { + "project": "my-project", + "description": "Brief description for AI context", + "language": "python", + "entry_point": "src/main.py", + "architecture": "System architecture notes", + "environment_notes": "Runtime environment details", + "dependencies": ["package1", "package2"], + "context_files": ["src/**/*.py", "README.md"], + "exclude": ["__pycache__", "*.pyc", ".git"] + } + +JCL schema (``.ai-lsc-jobs.json``):: + + { + "jobs": [ + {"name": "...", "command": "...", "cwd": "..."}, + ... + ] + } +""" + +from __future__ import annotations + +import glob as glob_mod +import json +from pathlib import Path +from typing import Any + +from ai_lsc.constants import MANIFEST_FILE_NAME, JCL_FILE_NAME + +# Maximum directory traversal depth when searching for manifests. +_MAX_WALK_DEPTH: int = 20 + + +class ManifestSupport: + """Static utility class for manifest and JCL file operations.""" + + @staticmethod + def discover_manifest(directory: str | Path) -> Path | None: + """Walk up from *directory* to find the nearest manifest file. + + Stops after ``_MAX_WALK_DEPTH`` iterations or when the + filesystem root is reached. + """ + current = Path(directory).resolve() + for _ in range(_MAX_WALK_DEPTH): + candidate = current / MANIFEST_FILE_NAME + if candidate.exists(): + return candidate + parent = current.parent + if parent == current: + return None + current = parent + return None + + @staticmethod + def load_manifest(path: str | Path) -> dict[str, Any]: + """Load and return the manifest dict, or ``{}`` on failure.""" + p = Path(path) + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return {} + + @staticmethod + def build_system_context(manifest: dict[str, Any]) -> str: + """Build a flat system-prompt text block from manifest data.""" + project = manifest.get("project", "Unknown Project") + description = manifest.get("description", "") + language = manifest.get("language", "") + entry = manifest.get("entry_point", "") + architecture = manifest.get("architecture", "") + environment = manifest.get("environment_notes", "") + dependencies = manifest.get("dependencies", []) + + parts = [f"Project: {project}"] + if description: + parts.append(f"Description: {description}") + if language: + parts.append(f"Language: {language}") + if entry: + parts.append(f"Entry Point: {entry}") + if architecture: + parts.append(f"Architecture: {architecture}") + if environment: + parts.append(f"Environment: {environment}") + if dependencies: + parts.append(f"Dependencies: {', '.join(dependencies)}") + + return "\n".join(parts) + + @staticmethod + def resolve_context_files( + manifest: dict[str, Any], + base_dir: str | Path, + ) -> list[str]: + """Resolve glob patterns in the manifest to real file paths.""" + base = Path(base_dir) + patterns = manifest.get("context_files", []) + exclude = set(manifest.get("exclude", [])) + files: list[str] = [] + + for pattern in patterns: + full_pattern = str(base / pattern) + matched = glob_mod.glob(full_pattern, recursive=True) + for f in matched: + if Path(f).is_file() and not any(ex in f for ex in exclude): + files.append(f) + return files + + @staticmethod + def load_jcl(path: str | Path) -> list[dict[str, Any]]: + """Load job entries from a JCL file, or ``[]`` on failure.""" + p = Path(path) + if not p.exists(): + return [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + return data.get("jobs", []) + except Exception: + return [] + + @staticmethod + def create_manifest_template(path: str | Path) -> Path: + """Write a starter manifest template to *path*. + + Returns the path of the created file. + """ + p = Path(path) + template = { + "project": "my-project", + "description": "Brief project description for AI context", + "language": "python", + "entry_point": "src/main.py", + "architecture": "Describe the system architecture", + "environment_notes": "Runtime environment details", + "dependencies": ["package1", "package2"], + "context_files": ["src/**/*.py", "README.md"], + "exclude": ["__pycache__", "*.pyc", ".git", "node_modules"], + } + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(template, indent=4), encoding="utf-8") + return p diff --git a/src/ai_lsc/registry/__init__.py b/src/ai_lsc/registry/__init__.py new file mode 100644 index 0000000..0bf00d1 --- /dev/null +++ b/src/ai_lsc/registry/__init__.py @@ -0,0 +1 @@ +"""AI-LSC registry sub-package.""" diff --git a/src/ai_lsc/registry/defaults.py b/src/ai_lsc/registry/defaults.py new file mode 100644 index 0000000..a01fcbe --- /dev/null +++ b/src/ai_lsc/registry/defaults.py @@ -0,0 +1,3287 @@ +""" +AI-LSC — Default registry data (~1 200 lines of pure data). + +This is the *authoritative source of truth* for every tool known to the +ecosystem. On first run ``RegistryManager`` writes this dict to +``ecosystem.json``; on subsequent runs it merges in any new keys so that +the on-disk registry auto-evolves across releases without user action. + +Convention +---------- +Every entry has the same top-level shape:: + + { + "name": , + "level": <1–13 taxonomy level (int)>, + "layer": , + "role": , + "category": , + "installer": {"type": , + "pkg": , + "cmd": }, + "launcher": {"type": , + "cmd": , + "default_port": }, + "deps": [], + "description": , + "flags": {}, + } + +Launcher command placeholders +----------------------------- +``{port}``, ``{tools_root}```, ``{models_root}``, +``{workspaces_root}``, ``{base_dir}`` are resolved at launch time by +the ``ServiceRow`` dispatcher. + +Layer map +--------- +L1 Host Platform — databases, caches +L2 Development Environment — runtimes, search, parsing +L3 GPU Runtime — CUDA, optimisation +L4 Inference Engines — LLM servers +L5 Distributed Runtime — serving at scale +L6 AI Endpoints — proxies, reasoning, vision, TTS +L7 Data & Knowledge Pipelines — vector stores, crawlers, parsing +L8 Automation & Execution — agents, code gen, monitoring +L9 Observability — metrics, dashboards, tracing +L10 Intelligent Routing — reasoning, memory +L11 User Interfaces — web frontends, image gen, chat +L12 DevOps — IaC, config management, containers +L13 Knowledge Management — references, notes, documents +""" + +# NOTE: This dict is intentionally kept as a *literal* so that it can be +# round-tripped through JSON without loss. Do NOT add non-serialisable +# objects (Path, Enum, etc.) here. + +DEFAULT_REGISTRY: dict = { + # ── L1: Host Platform ────────────────────────────────────── + "postgresql": { + "name": "PostgreSQL", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Database", + "installer": { + "type": "pacman", + "pkg": "postgresql" + }, + "launcher": { + "type": "systemd", + "cmd": "postgresql", + "default_port": 5432 + }, + "deps": [], + "description": "Relational database used by many frameworks.", + "flags": {}, + "filesystem": { + "install": "", + "config": "configs/postgresql", + "data": "data/postgresql", + "logs": "logs/postgresql" + } +}, + + "mariadb": { + "name": "MariaDB", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Database", + "installer": { + "type": "pacman", + "pkg": "mariadb" + }, + "launcher": { + "type": "systemd", + "cmd": "mariadb", + "default_port": 3306 + }, + "deps": [], + "description": "Open source relational database.", + "flags": {}, + "filesystem": { + "install": "", + "config": "configs/mariadb", + "data": "data/mariadb", + "logs": "logs/mariadb" + } +}, + + "redis": { + "name": "Redis", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Cache", + "installer": { + "type": "pacman", + "pkg": "redis" + }, + "launcher": { + "type": "systemd", + "cmd": "redis", + "default_port": 6379 + }, + "deps": [], + "description": "In-memory cache and message broker.", + "flags": {}, + "filesystem": { + "install": "", + "config": "configs/redis", + "data": "data/redis", + "logs": "logs/redis" + } +}, + + "sqlite3": { + "name": "SQLite3", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Database", + "installer": { + "type": "pacman", + "pkg": "sqlite" + }, + "launcher": { + "type": "desktop", + "cmd": "sqlite3", + "default_port": None + }, + "deps": [], + "description": "C-language library implementing a SQL database engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "duckdb": { + "name": "DuckDB", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Analytical Database", + "installer": { + "type": "uv", + "pkg": "duckdb" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import duckdb; print(duckdb.__version__)\"", + "default_port": None + }, + "deps": [], + "description": "In-process analytical database with SQL support.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + # ── L2: Development Environment ──────────────────────────── + + "python": { + "name": "Python Environment", + "level": 2, + "layer": "Development Environment", + "role": "Build System", + "category": "Runtime", + "installer": { + "type": "pacman", + "pkg": "python-pip" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 --version", + "default_port": None + }, + "deps": [], + "description": "Python core interpreter and virtual environments.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "cupy": { + "name": "CuPy", + "level": 2, + "layer": "Development Environment", + "role": "GPU Acceleration", + "category": "GPU Computing", + "installer": { + "type": "uv", + "pkg": "cupy-cuda12x" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import cupy; print(cupy.__version__)\"", + "default_port": None + }, + "deps": [ + "cuda" + ], + "description": "NumPy-compatible GPU array computing library.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "ripgrep": { + "name": "ripgrep (rg)", + "level": 2, + "layer": "Development Environment", + "role": "Search", + "category": "Search Tool", + "installer": { + "type": "pacman", + "pkg": "ripgrep" + }, + "launcher": { + "type": "desktop", + "cmd": "rg --version", + "default_port": None + }, + "deps": [], + "description": "Fast recursive search tool (grep replacement).", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "fd": { + "name": "fd", + "level": 2, + "layer": "Development Environment", + "role": "Search", + "category": "Find Tool", + "installer": { + "type": "pacman", + "pkg": "fd" + }, + "launcher": { + "type": "desktop", + "cmd": "fd --version", + "default_port": None + }, + "deps": [], + "description": "Fast find command alternative.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "tree_sitter": { + "name": "tree-sitter", + "level": 2, + "layer": "Development Environment", + "role": "Parsing", + "category": "Parser", + "installer": { + "type": "uv", + "pkg": "tree-sitter" + }, + "launcher": { + "type": "desktop", + "cmd": "tree-sitter --version", + "default_port": None + }, + "deps": [], + "description": "Incremental parsing system for source code.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + # ── L3: GPU Runtime ──────────────────────────────────────── + + "cuda": { + "name": "CUDA Toolkit", + "level": 3, + "layer": "GPU Runtime", + "role": "Acceleration", + "category": "GPU", + "installer": { + "type": "pacman", + "pkg": "cuda" + }, + "launcher": { + "type": "desktop", + "cmd": "nvcc --version", + "default_port": None + }, + "deps": [], + "description": "NVIDIA CUDA parallel computing platform.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "apex": { + "name": "NVIDIA Apex", + "level": 3, + "layer": "GPU Runtime", + "role": "Optimization", + "category": "Mixed Precision", + "installer": { + "type": "pip", + "pkg": "apex" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import apex; print(apex.__version__)\"", + "default_port": None + }, + "deps": [ + "cuda" + ], + "description": "NVIDIA mixed precision and distributed training.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "unsloth": { + "name": "Unsloth", + "level": 3, + "layer": "GPU Runtime", + "role": "Optimization", + "category": "LLM Fine-tuning", + "installer": { + "type": "uv", + "pkg": "unsloth" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import unsloth; print('ok')\"", + "default_port": None + }, + "deps": [ + "cuda" + ], + "description": "2x faster LLM fine-tuning with 80% less memory.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "heretic": { + "name": "Heretic", + "level": 3, + "layer": "GPU Runtime", + "role": "Abliteration", + "category": "LLM Fine-tuning", + "installer": { + "type": "git", + "pkg": "https://github.com/p-e-w/heretic", + "cmd": "pip install -e ." + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import heretic; print('ok')\"", + "default_port": None + }, + "deps": [ + "cuda" + ], + "description": "Fully automatic censorship/safety-alignment removal for transformer-based LLMs via optimized abliteration. Modifies model weights directly.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + # ── L4: Inference Engines ────────────────────────────────── + + "ollama": { + "name": "Ollama", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "LLM Runtime", + "installer": { + "type": "script", + "pkg": "ollama", + "cmd": "curl -fsSL https://ollama.com/install.sh | sh" + }, + "launcher": { + "type": "tmux", + "cmd": "OLLAMA_HOST=0.0.0.0:{port} OLLAMA_MODELS={models_root}/ollama ollama serve", + "default_port": 11434 + }, + "deps": [], + "description": "Local LLM runner and model manager.", + "flags": { + "is_ollama": True, + "has_cli": False, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/ollama", + "models": "models/ollama", + "logs": "logs/ollama" + } +}, + + "llamacpp": { + "name": "llama.cpp", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "LLM Runtime", + "installer": { + "type": "git", + "pkg": "https://github.com/ggerganov/llama.cpp", + "post_install": "make", + "update_cmd": "git pull --ff-only && make clean && make" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/llamacpp && make && ./server --port {port}", + "default_port": 8080 + }, + "deps": [], + "description": "Port of Facebook's LLaMA model in C/C++.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "koboldcpp": { + "name": "KoboldCPP", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "LLM Runtime", + "installer": { + "type": "git", + "pkg": "https://github.com/LostRuins/koboldcpp", + "post_install": "make", + "update_cmd": "git pull --ff-only && make clean && make" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/koboldcpp && make && ./koboldcpp --port {port}", + "default_port": 5001 + }, + "deps": [], + "description": "GGUF-based LLM inference with CUDA/Vulkan.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": True + } +}, + + "llamafile": { + "name": "Llamafile", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "Single-File LLM", + "installer": { + "type": "script", + "pkg": "llamafile", + "cmd": "mkdir -p {tools_root}/llamafile && curl -L -o {tools_root}/bin/llamafile https://github.com/Mozilla-Ocho/llamafile/releases/latest/download/llamafile && chmod +x {tools_root}/bin/llamafile" + }, + "launcher": { + "type": "desktop", + "cmd": "{tools_root}/bin/llamafile", + "default_port": None + }, + "deps": [], + "description": "Distribute and run LLMs in a single file.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "turbollm": { + "name": "TurboLLM", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "LLM Runtime", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/turbollm", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/turbollm && python3 -m turbollm serve --port {port}", + "default_port": 8000 + }, + "deps": [ + "cuda" + ], + "description": "Fast LLM serving with tensor parallelism.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "airllm": { + "name": "AirLLM", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "Efficient LLM", + "installer": { + "type": "git", + "pkg": "https://github.com/liguodongiot/llm-airforce", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/airllm && python3 -m airllm serve --port {port}", + "default_port": 8001 + }, + "deps": [ + "cuda" + ], + "description": "Memory-efficient 70B LLM inference on 4GB GPUs.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "locally_uncensored": { + "name": "Locally-Uncensored", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "Uncensored Models", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/locally-uncensored" + }, + "launcher": { + "type": "desktop", + "cmd": "ollama list", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "Curated uncensored model collection and tooling.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + # ── L5: Distributed Runtime ──────────────────────────────── + + "vllm": { + "name": "vLLM", + "level": 5, + "layer": "Distributed Runtime", + "role": "Scaling", + "category": "LLM Serving", + "installer": { + "type": "uv", + "pkg": "vllm", + "env_overrides": { + "HF_HOME": "{base_dir}/cache/huggingface", + "TRANSFORMERS_CACHE": "{base_dir}/cache/huggingface" + } + }, + "launcher": { + "type": "tmux", + "cmd": "python -m vllm.entrypoints.openai.api_server --port {port}", + "default_port": 8000 + }, + "deps": [ + "cuda" + ], + "description": "High-throughput and memory-efficient LLM serving.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/vllm", + "cache": "cache/vllm", + "config": "configs/vllm", + "logs": "logs/vllm" + } +}, + # ── L6: AI Endpoints ─────────────────────────────────────── + + "litellm": { + "name": "LiteLLM Proxy", + "level": 6, + "layer": "AI Endpoints", + "role": "API Gateway", + "category": "Proxy", + "installer": { + "type": "uv", + "pkg": "litellm", + "env_overrides": { + "LITELLM_CONFIG_DIR": "{base_dir}/configs/litellm" + } + }, + "launcher": { + "type": "tmux", + "cmd": "litellm --port {port}", + "default_port": 4000 + }, + "deps": [], + "description": "Call 100+ LLMs using the OpenAI format.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/litellm", + "config": "configs/litellm", + "logs": "logs/litellm" + } +}, + + "9router_proxy": { + "name": "9Router Proxy", + "level": 6, + "layer": "AI Endpoints", + "role": "API Gateway", + "category": "LLM Router", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/9router", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/9router && python3 main.py --port {port}", + "default_port": 4001 + }, + "deps": [ + "ollama" + ], + "description": "Intelligent LLM request router and load balancer.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "deep_eye": { + "name": "Deep Eye", + "level": 6, + "layer": "AI Endpoints", + "role": "Vision", + "category": "Computer Vision", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/deep-eye", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/deep_eye && python3 serve.py --port {port}", + "default_port": 8100 + }, + "deps": [ + "ollama" + ], + "description": "Local computer vision analysis and description engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "luxtts": { + "name": "LuxTTS", + "level": 6, + "layer": "AI Endpoints", + "role": "Voice", + "category": "Text-to-Speech", + "installer": { + "type": "uv", + "pkg": "luxtts" + }, + "launcher": { + "type": "tmux", + "cmd": "luxtts serve --port {port}", + "default_port": 8500 + }, + "deps": [], + "description": "High-quality local text-to-speech synthesis.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + # ── L7: Data & Knowledge Pipelines ───────────────────────── + + "parakeet": { + "name": "Parakeet.cpp", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Senses", + "category": "Speech Recognition", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/parakeet.cpp", + "post_install": "make" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/parakeet && ./parakeet --port {port}", + "default_port": 8300 + }, + "deps": [ + "cuda" + ], + "description": "C++ speech recognition with transformer architecture.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "chromadb": { + "name": "ChromaDB", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Vector Store", + "installer": { + "type": "uv", + "pkg": "chromadb" + }, + "launcher": { + "type": "tmux", + "cmd": "chroma run --path {models_root}/chroma --port {port}", + "default_port": 8000 + }, + "deps": [], + "description": "AI-native open-source vector database.", + "flags": {}, + "filesystem": { + "install": "tools/chromadb", + "data": "models/chroma", + "logs": "logs/chromadb" + } +}, + + "whisper": { + "name": "Whisper", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Audio Parsing", + "installer": { + "type": "pipx", + "pkg": "openai-whisper" + }, + "launcher": { + "type": "tmux", + "cmd": "whisper", + "default_port": None + }, + "deps": [], + "description": "Robust Speech Recognition via Large-Scale Weak Supervision.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + }, + "filesystem": { + "install": "tools/whisper", + "cache": "cache/whisper" + } +}, + + "docling": { + "name": "Docling", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "File Parsing", + "installer": { + "type": "pipx", + "pkg": "docling" + }, + "launcher": { + "type": "tmux", + "cmd": "docling", + "default_port": None + }, + "deps": [], + "description": "Advanced document parsing and chunking.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "lancedb": { + "name": "LanceDB", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Vector Store", + "installer": { + "type": "uv", + "pkg": "lancedb" + }, + "launcher": { + "type": "tmux", + "cmd": "python3 -m lancedb serve --port {port}", + "default_port": 8484 + }, + "deps": [], + "description": "Serverless vector database for AI applications.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "neo4j": { + "name": "Neo4j", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Graph Database", + "installer": { + "type": "pacman", + "pkg": "neo4j" + }, + "launcher": { + "type": "systemd", + "cmd": "neo4j", + "default_port": 7474 + }, + "deps": [], + "description": "Native graph database and knowledge graph engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "elasticsearch": { + "name": "Elasticsearch", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Search Engine", + "installer": { + "type": "pacman", + "pkg": "elasticsearch" + }, + "launcher": { + "type": "systemd", + "cmd": "elasticsearch", + "default_port": 9200 + }, + "deps": [], + "description": "Distributed search and analytics engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "meilisearch": { + "name": "Meilisearch", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Search Engine", + "installer": { + "type": "script", + "pkg": "meilisearch", + "cmd": "curl -L https://install.meilisearch.com | sed 's|/usr/local/bin|{tools_root}/meilisearch/bin|g' | PREFIX={tools_root}/meilisearch sh" + }, + "launcher": { + "type": "tmux", + "cmd": "meilisearch --port {port}", + "default_port": 7700 + }, + "deps": [], + "description": "Fast, relevant, and typo-tolerant search engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "graphrag": { + "name": "GraphRAG", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Knowledge Synthesis", + "category": "Graph RAG", + "installer": { + "type": "uv", + "pkg": "graphrag" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -m graphrag init", + "default_port": None + }, + "deps": [], + "description": "Microsoft GraphRAG for knowledge graph construction.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "crawl4ai": { + "name": "Crawl4AI", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Data Harvesting", + "category": "Web Crawler", + "installer": { + "type": "pipx", + "pkg": "crawl4ai" + }, + "launcher": { + "type": "desktop", + "cmd": "crawl4ai https://example.com", + "default_port": None + }, + "deps": [], + "description": "LLM-friendly web crawler and data extractor.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "markitdown": { + "name": "MarkItDown", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "File Parsing", + "category": "Document Converter", + "installer": { + "type": "pipx", + "pkg": "markitdown" + }, + "launcher": { + "type": "desktop", + "cmd": "markitdown document.pdf", + "default_port": None + }, + "deps": [], + "description": "Microsoft tool to convert files to Markdown.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "opendataloader": { + "name": "OpenDataLoader", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Ingestion", + "category": "Data Pipeline", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/opendataloader", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -m opendataloader --help", + "default_port": None + }, + "deps": [], + "description": "Universal data loading and preprocessing pipeline.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "turbovec": { + "name": "TurboVec", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Embedding", + "category": "Vector Engine", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/turbovec", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/turbovec && python3 serve.py --port {port}", + "default_port": 8101 + }, + "deps": [ + "cuda" + ], + "description": "High-speed embedding generation and vector engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "airweave": { + "name": "Airweave", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Integration", + "category": "Data Sync", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/airweave", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/airweave && python3 -m airweave serve --port {port}", + "default_port": 8600 + }, + "deps": [], + "description": "Real-time data synchronization and integration layer.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "qdrant": { + "name": "Qdrant", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Vector Store", + "installer": { + "type": "script", + "cmd": "curl -L https://github.com/qdrant/qdrant/releases/latest/download/qdrant-x86_64-unknown-linux-musl.tar.gz | tar xz -C {tools_root}/qdrant && chmod +x {tools_root}/qdrant/qdrant" + }, + "launcher": { + "type": "tmux", + "cmd": "./qdrant --storage-path {models_root}/qdrant --host 127.0.0.1 --port {port}", + "default_port": 6333 + }, + "deps": [], + "description": "High-performance vector database with mmap storage, payload filtering, and multi-vector support.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/qdrant", + "data": "data/qdrant", + "logs": "logs/qdrant" + } +}, + + "fabric": { + "name": "Fabric", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Curation", + "category": "Content Pipeline", + "installer": { + "type": "script", + "pkg": "fabric", + "cmd": "curl -L https://github.com/danielmiessler/fabric/releases/latest/download/fabric-linux-amd64 > {tools_root}/bin/fabric && chmod +x {tools_root}/bin/fabric" + }, + "launcher": { + "type": "tmux", + "cmd": "fabric", + "default_port": None + }, + "deps": [], + "description": "Open-source framework for augmenting humans using AI.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "mirofish": { + "name": "Mirofish", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Transform", + "category": "Data Pipeline", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/mirofish" + }, + "launcher": { + "type": "desktop", + "cmd": "mirofish --help", + "default_port": None + }, + "deps": [], + "description": "Data transformation and ETL pipeline framework.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "opendataloader_pdf": { + "name": "OpenDataLoader PDF", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Extraction", + "category": "PDF Pipeline", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/opendataloader-pdf" + }, + "launcher": { + "type": "desktop", + "cmd": "opendataloader-pdf extract file.pdf", + "default_port": None + }, + "deps": [], + "description": "Specialized PDF extraction and data loading pipeline.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "understand_anything": { + "name": "Understand Anything", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Comprehension", + "category": "Document Understanding", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/understand-anything" + }, + "launcher": { + "type": "desktop", + "cmd": "understand-anything analyze file.pdf", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "Universal document understanding and summarization.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + # ── L8: Automation & Execution ───────────────────────────── + + "langchain": { + "name": "LangChain", + "level": 8, + "layer": "Automation & Execution", + "role": "Framework", + "category": "LLM Framework", + "installer": { + "type": "uv", + "pkg": "langchain" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import langchain; print(langchain.__version__)\"", + "default_port": None + }, + "deps": [], + "description": "Framework for LLM-powered application development.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "nvidia_agent_skills": { + "name": "NVIDIA Agent Skills", + "level": 8, + "layer": "Automation & Execution", + "role": "Tool Integration", + "category": "Agent Toolkit", + "installer": { + "type": "git", + "pkg": "https://github.com/NVIDIA/agent-skills" + }, + "launcher": { + "type": "desktop", + "cmd": "ls {tools_root}/nvidia_agent_skills", + "default_port": None + }, + "deps": [ + "cuda" + ], + "description": "NVIDIA-curated agent skill definitions and tools.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "aider": { + "name": "Aider", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Development", + "installer": { + "type": "pipx", + "pkg": "aider-chat" + }, + "launcher": { + "type": "tmux", + "cmd": "aider --model {model_arg}", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "AI pair programming in your terminal.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "claude_code": { + "name": "Claude Code", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Development", + "installer": { + "type": "npm", + "pkg": "@anthropic-ai/claude-code" + }, + "launcher": { + "type": "tmux", + "cmd": "claude", + "default_port": None + }, + "deps": [], + "description": "Anthropic's terminal assistant.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "agentic_os": { + "name": "Agentic OS", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Agent OS", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/agentic-os", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/agentic_os && python3 main.py", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "Autonomous agent operating system framework.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "loop_engineering": { + "name": "Loop Engineering", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Dev Automation", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/loop-engineering" + }, + "launcher": { + "type": "desktop", + "cmd": "loop-engineering --help", + "default_port": None + }, + "deps": [], + "description": "Development loop automation and CI/CD orchestration.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "algory": { + "name": "Algory", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Algorithm Toolkit", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/algory" + }, + "launcher": { + "type": "desktop", + "cmd": "algory --help", + "default_port": None + }, + "deps": [], + "description": "Algorithm design and benchmarking toolkit.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "hivemind": { + "name": "HiveMind", + "level": 8, + "layer": "Automation & Execution", + "role": "Coordination", + "category": "Multi-Agent", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/hivemind", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/hivemind && python3 -m hivemind serve --port {port}", + "default_port": 8700 + }, + "deps": [ + "ollama" + ], + "description": "Distributed multi-agent coordination framework.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "honcho": { + "name": "Honcho", + "level": 8, + "layer": "Automation & Execution", + "role": "Process Manager", + "category": "Procfile Runner", + "installer": { + "type": "pipx", + "pkg": "honcho" + }, + "launcher": { + "type": "desktop", + "cmd": "honcho start", + "default_port": None + }, + "deps": [], + "description": "Python Procfile manager for multi-process apps.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "nightshift": { + "name": "NightShift", + "level": 8, + "layer": "Automation & Execution", + "role": "Scheduler", + "category": "Task Runner", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/nightshift", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/nightshift && python3 -m nightshift serve --port {port}", + "default_port": 8800 + }, + "deps": [], + "description": "Scheduled task execution and background job runner.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "atlas_os": { + "name": "Atlas OS", + "level": 8, + "layer": "Automation & Execution", + "role": "OS Integration", + "category": "AI Operating System", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/atlas-os" + }, + "launcher": { + "type": "desktop", + "cmd": "atlas --version", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "AI-native operating system integration layer.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "eagle_eye": { + "name": "Eagle-Eye", + "level": 8, + "layer": "Automation & Execution", + "role": "Inspector", + "category": "Code Analysis", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/eagle-eye" + }, + "launcher": { + "type": "desktop", + "cmd": "eagle-eye scan .", + "default_port": None + }, + "deps": [], + "description": "Automated code inspection and quality gate.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "graphify": { + "name": "Graphify", + "level": 8, + "layer": "Automation & Execution", + "role": "Graph Builder", + "category": "Knowledge Graph", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/graphify" + }, + "launcher": { + "type": "desktop", + "cmd": "graphify build .", + "default_port": None + }, + "deps": [], + "description": "Codebase knowledge graph construction tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "promptops": { + "name": "PromptOps.it", + "level": 8, + "layer": "Automation & Execution", + "role": "Prompt Management", + "category": "Prompt Tooling", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/promptops" + }, + "launcher": { + "type": "desktop", + "cmd": "promptops --help", + "default_port": None + }, + "deps": [], + "description": "Prompt versioning, testing, and operations toolkit.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "spec_kit": { + "name": "Spec Kit", + "level": 8, + "layer": "Automation & Execution", + "role": "Documentation", + "category": "Spec Writer", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/spec-kit" + }, + "launcher": { + "type": "desktop", + "cmd": "spec-kit init", + "default_port": None + }, + "deps": [], + "description": "Automated specification and requirement document generator.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "agent_reach": { + "name": "Agent Reach", + "level": 8, + "layer": "Automation & Execution", + "role": "Discovery", + "category": "Agent Network", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/agent-reach" + }, + "launcher": { + "type": "desktop", + "cmd": "agent-reach discover", + "default_port": None + }, + "deps": [], + "description": "Multi-agent service discovery and capability mapping.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "wayland_ai": { + "name": "Wayland AI", + "level": 8, + "layer": "Automation & Execution", + "role": "Agent Orchestrator", + "category": "AI Agent", + "installer": { + "type": "git", + "pkg": "https://github.com/ferroxlabs/wayland", + "cmd": "npx @ferroxlabs/wayland-core" + }, + "launcher": { + "type": "cli", + "cmd": "wayland", + "default_port": None + }, + "deps": [], + "description": "Local-first desktop AI agent that unifies Claude Code, Codex, Gemini, Qwen, and 12+ coding assistants under a single Rust-powered orchestration engine. MCP-native, sandboxed tool execution.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "agno": { + "name": "Agno", + "level": 8, + "layer": "Automation & Execution", + "role": "Multi-Agent", + "category": "Agent Framework", + "installer": { + "type": "git", + "pkg": "https://github.com/agno-agi/agno", + "cmd": "pip install -e ." + }, + "launcher": { + "type": "cli", + "cmd": "agno", + "default_port": None + }, + "deps": [], + "description": "Python framework for building multi-agent platforms with memory, knowledge, tools, and reasoning. Production deployment via AgentOS with tracing, scheduling, and RBAC. Originally Phidata.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "hermes_agent": { + "name": "Hermes Agent", + "level": 8, + "layer": "Automation & Execution", + "role": "Agent", + "category": "AI Agent", + "installer": { + "type": "npm", + "pkg": "hermes-agent" + }, + "launcher": { + "type": "tmux", + "cmd": "hermes agent --port {port}", + "default_port": 17051 + }, + "deps": [ + "ollama" + ], + "description": "Hermes autonomous agent runtime.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "openhands": { + "name": "OpenHands", + "level": 8, + "layer": "Automation & Execution", + "role": "Autonomous Coder", + "category": "AI Coding Agent", + "installer": { + "type": "git_node", + "pkg": "https://github.com/All-Hands-AI/OpenHands.git", + "update_cmd": "git pull --ff-only && pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/openhands && python -m openhands.server --port {port}", + "default_port": 3000 + }, + "deps": [ + "ollama" + ], + "description": "Autonomous AI software engineer. Plans, writes, debugs, and executes code in sandboxed environments with full terminal access, file management, and web browsing.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/openhands", + "config": "workspaces/openhands/config", + "cache": "cache/openhands", + "logs": "logs/openhands" + } +}, + + "ponytail": { + "name": "Ponytail", + "level": 8, + "layer": "Automation & Execution", + "role": "Code Gen", + "category": "Code Generation", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/ponytail" + }, + "launcher": { + "type": "desktop", + "cmd": "ponytail generate", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "AI-powered code generation and refactoring tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "headroom": { + "name": "Headroom", + "level": 8, + "layer": "Automation & Execution", + "role": "Context", + "category": "Context Manager", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/headroom" + }, + "launcher": { + "type": "desktop", + "cmd": "headroom scan", + "default_port": None + }, + "deps": [], + "description": "Codebase context extraction and management.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "skillspector": { + "name": "Skillspector", + "level": 8, + "layer": "Automation & Execution", + "role": "Analysis", + "category": "Skill Inspection", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/skillspector" + }, + "launcher": { + "type": "desktop", + "cmd": "skillspector inspect", + "default_port": None + }, + "deps": [], + "description": "Ollama skill definition inspector and validator.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "n8n": { + "name": "n8n", + "level": 8, + "layer": "Automation & Execution", + "role": "Workflow Orchestrator", + "category": "Workflow Automation", + "installer": { + "type": "npm", + "pkg": "n8n", + "env_overrides": { + "NODE_PATH": "{tools_root}/n8n/node_modules" + } + }, + "launcher": { + "type": "tmux", + "cmd": "npx n8n start --port {port} --data-dir {workspaces_root}/n8n", + "default_port": 5678 + }, + "deps": [], + "description": "Ops-oriented workflow automation for multi-step agent orchestration beyond single-turn tool calls.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/n8n", + "data": "workspaces/n8n", + "logs": "logs/n8n" + } +}, + + "synapscli": { + "name": "SynapsCLI", + "level": 8, + "layer": "Automation & Execution", + "role": "Agent Runtime", + "category": "AI Agent", + "installer": { + "type": "git", + "pkg": "https://github.com/HaseebKhalid1507/SynapsCLI", + "cmd": "cargo build --release" + }, + "launcher": { + "type": "cli", + "cmd": "synapscli", + "default_port": None + }, + "deps": [], + "description": "High-performance terminal-native AI agent runtime in Rust. Interactive LLM chat, parallel agent orchestration, and autonomous supervision.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + # ── L9: Observability ────────────────────────────────────── + + "pulse_ai": { + "name": "Pulse AI", + "level": 9, + "layer": "Observability", + "role": "Health Monitor", + "category": "AI Monitoring", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/pulse-ai", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/pulse_ai && python3 -m pulse serve --port {port}", + "default_port": 8900 + }, + "deps": [], + "description": "AI service health monitoring and auto-recovery.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "latitude": { + "name": "Latitude", + "level": 9, + "layer": "Observability", + "role": "Evaluation", + "category": "LLM Evaluation", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/latitude", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/latitude && python3 -m latitude serve --port {port}", + "default_port": 9300 + }, + "deps": [ + "ollama" + ], + "description": "LLM output evaluation and benchmarking platform.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "glances": { + "name": "Glances", + "level": 9, + "layer": "Observability", + "role": "Dashboard", + "category": "Metrics", + "installer": { + "type": "pacman", + "pkg": "glances" + }, + "launcher": { + "type": "tmux", + "cmd": "glances -w --port {port}", + "default_port": 61208 + }, + "deps": [], + "description": "Cross-platform system monitoring tool.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + + "prometheus": { + "name": "Prometheus", + "level": 9, + "layer": "Observability", + "role": "Metrics Collector", + "category": "Metrics", + "installer": { + "type": "pacman", + "pkg": "prometheus" + }, + "launcher": { + "type": "systemd", + "cmd": "prometheus", + "default_port": 9090 + }, + "deps": [], + "description": "Open-source monitoring and alerting toolkit.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "grafana": { + "name": "Grafana", + "level": 9, + "layer": "Observability", + "role": "Dashboard", + "category": "Visualization", + "installer": { + "type": "pacman", + "pkg": "grafana" + }, + "launcher": { + "type": "systemd", + "cmd": "grafana-server", + "default_port": 3000 + }, + "deps": [], + "description": "Multi-source observability dashboards and visualization.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + + "grafana_alloy": { + "name": "Grafana Alloy", + "level": 9, + "layer": "Observability", + "role": "Collector", + "category": "Telemetry", + "installer": { + "type": "script", + "pkg": "grafana/alloy", + "cmd": "mkdir -p {tools_root}/grafana_alloy/bin && curl -fsSL -o {tools_root}/grafana_alloy/bin/alloy https://github.com/grafana/alloy/releases/latest/download/alloy-linux-amd64 && chmod +x {tools_root}/grafana_alloy/bin/alloy" + }, + "launcher": { + "type": "tmux", + "cmd": "alloy run --server.http.listen-port={port}", + "default_port": 12345 + }, + "deps": [ + "prometheus" + ], + "description": "OpenTelemetry collector with Prometheus integration.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "opik": { + "name": "Opik", + "level": 9, + "layer": "Observability", + "role": "LLM Tracing", + "category": "AI Observability", + "installer": { + "type": "uv", + "pkg": "opik" + }, + "launcher": { + "type": "tmux", + "cmd": "opik serve --port {port}", + "default_port": 3000 + }, + "deps": [], + "description": "Open-source LLM observability and tracing platform.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "hermes_dashboard_page": { + "name": "Hermes Dashboard", + "level": 9, + "layer": "Observability", + "role": "Dashboard", + "category": "Ecosystem Dashboard", + "installer": { + "type": "npm", + "pkg": "hermes-dashboard" + }, + "launcher": { + "type": "tmux", + "cmd": "hermes dashboard --port {port}", + "default_port": 17050 + }, + "deps": [ + "ollama" + ], + "description": "Hermes ecosystem monitoring dashboard.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + # ── L10: Intelligent Routing ─────────────────────────────── + + "odysseus": { + "name": "Odysseus", + "level": 10, + "layer": "Intelligent Routing", + "role": "Reasoning", + "category": "Reasoning Engine", + "installer": { + "type": "pip", + "pkg": "odysseus" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/odysseus/ && ./.venv/bin/uvicorn app:app --host 127.0.0.1 --port {port}", + "default_port": 7000 + }, + "deps": [ + "ollama" + ], + "description": "Local reasoning and orchestration agent.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/odysseus", + "config": "configs/odysseus", + "logs": "logs/odysseus" + } +}, + + "openai_swarm": { + "name": "OpenAI Swarm", + "level": 10, + "layer": "Intelligent Routing", + "role": "Multi-Agent", + "category": "Agent Framework", + "installer": { + "type": "uv", + "pkg": "swarm" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import swarm; print('ok')\"", + "default_port": None + }, + "deps": [], + "description": "OpenAI multi-agent orchestration framework.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "everos_memory": { + "name": "EverOS Memory", + "level": 10, + "layer": "Intelligent Routing", + "role": "Memory", + "category": "Persistent Memory", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/everos-memory", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/everos_memory && python3 -m everos serve --port {port}", + "default_port": 9200 + }, + "deps": [], + "description": "Persistent long-term memory system for AI agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "glassmind": { + "name": "GlassMind", + "level": 10, + "layer": "Intelligent Routing", + "role": "Reasoning", + "category": "Reasoning Engine", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/glassmind", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/glassmind && python3 -m glassmind serve --port {port}", + "default_port": 9400 + }, + "deps": [ + "ollama" + ], + "description": "Transparent reasoning and chain-of-thought engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "crewai": { + "name": "CrewAI", + "level": 10, + "layer": "Intelligent Routing", + "role": "Brain", + "category": "Agent Workflow", + "installer": { + "type": "pipx", + "pkg": "crewai" + }, + "launcher": { + "type": "tmux", + "cmd": "crewai", + "default_port": None + }, + "deps": [], + "description": "Framework for orchestrating role-playing autonomous AI agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "autogen": { + "name": "AutoGen", + "level": 10, + "layer": "Intelligent Routing", + "role": "Brain", + "category": "Agent Workflow", + "installer": { + "type": "pipx", + "pkg": "pyautogen" + }, + "launcher": { + "type": "tmux", + "cmd": "autogen", + "default_port": None + }, + "deps": [], + "description": "Enable next-gen LLM applications with multiple conversable agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "openbrain": { + "name": "OpenBrain", + "level": 10, + "layer": "Intelligent Routing", + "role": "Brain", + "category": "Reasoning Engine", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/openbrain", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/openbrain && python3 -m openbrain serve --port {port}", + "default_port": 7100 + }, + "deps": [ + "ollama" + ], + "description": "Open-source reasoning and cognitive engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "mnemo_cortex": { + "name": "Mnemo Cortex", + "level": 10, + "layer": "Intelligent Routing", + "role": "Memory", + "category": "Cortex Memory", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/mnemo-cortex", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/mnemo_cortex && python3 -m mnemo_cortex serve --port {port}", + "default_port": 7200 + }, + "deps": [ + "ollama" + ], + "description": "Hierarchical cortex memory for AI agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + # ── L11: User Interfaces ─────────────────────────────────── + + "langflow": { + "name": "LangFlow", + "level": 11, + "layer": "User Interfaces", + "role": "Visual Builder", + "category": "Workflow Builder", + "installer": { + "type": "uv", + "pkg": "langflow", + "env_overrides": { + "LANGFLOW_CONFIG_DIR": "{base_dir}/configs/langflow" + } + }, + "launcher": { + "type": "tmux", + "cmd": "langflow run --port {port}", + "default_port": 7860 + }, + "deps": [], + "description": "Visual framework for multi-agent and RAG workflows.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/langflow", + "config": "configs/langflow", + "cache": "cache/langflow", + "logs": "logs/langflow" + } +}, + + "openjarvis": { + "name": "OpenJarvis", + "level": 11, + "layer": "User Interfaces", + "role": "Central Intelligence", + "category": "AI Assistant Platform", + "installer": { + "type": "git_node", + "pkg": "https://github.com/openjarvis/openjarvis.git", + "post_install": "npm install && npm run build" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/openjarvis && npm start -- --port {port}", + "default_port": 17070 + }, + "deps": [ + "ollama", + "qdrant" + ], + "description": "Central AI assistant platform with multi-modal I/O, memory integration, agentic task execution, and unified dashboard. The brain of the intelligent stack.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + }, + "filesystem": { + "install": "tools/openjarvis", + "config": "configs/openjarvis", + "data": "workspaces/openjarvis", + "cache": "cache/openjarvis", + "logs": "logs/openjarvis" + } +}, + + "hermes": { + "name": "Hermes", + "level": 11, + "layer": "User Interfaces", + "role": "Agent", + "category": "AI Agent", + "installer": { + "type": "npm", + "pkg": "hermes-ai" + }, + "launcher": { + "type": "tmux", + "cmd": "hermes dashboard --port {port} --data-dir {workspaces_root}/hermes & hermes desktop --data-dir {workspaces_root}/hermes", + "default_port": 17050 + }, + "deps": [ + "ollama" + ], + "description": "Unified desktop and dashboard environment for the AI ecosystem.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + }, + "filesystem": { + "install": "tools/hermes", + "data": "workspaces/hermes", + "logs": "logs/hermes" + } +}, + + "librechat": { + "name": "LibreChat", + "level": 11, + "layer": "User Interfaces", + "role": "Agent Frontend", + "category": "Chat Agent Platform", + "installer": { + "type": "git_node", + "pkg": "https://github.com/danny-avila/LibreChat.git", + "update_cmd": "git pull --ff-only && yarn install && yarn build" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/librechat && API_PLUGINS=false PORT={port} NODE_ENV=production yarn backend", + "default_port": 3080 + }, + "deps": [ + "ollama" + ], + "description": "Multi-provider chat agent platform with native OpenAI tool-calling, the default agent frontend for AI-LSC's agentic orchestration.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/librechat", + "config": "configs/librechat", + "data": "data/librechat", + "logs": "logs/librechat" + } +}, + + "openwebui": { + "name": "Open WebUI", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Chat Frontend", + "installer": { + "type": "uv", + "pkg": "open-webui", + "env_overrides": { + "OPEN_WEBUI_CONFIG_DIR": "{base_dir}/configs/openwebui" + } + }, + "launcher": { + "type": "tmux", + "cmd": "open-webui serve --port {port} --data-dir {workspaces_root}/openwebui", + "default_port": 8080 + }, + "deps": [ + "ollama" + ], + "description": "Extensible frontend for LLMs.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/openwebui", + "config": "configs/openwebui", + "data": "workspaces/openwebui", + "logs": "logs/openwebui" + } +}, + + "anythingllm": { + "name": "AnythingLLM", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Chat", + "installer": { + "type": "git_node", + "pkg": "https://github.com/Mintplex-Labs/anything-llm.git", + "update_cmd": "git pull --ff-only && yarn install" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/anythingllm && yarn dev", + "default_port": 3001 + }, + "deps": [], + "description": "Full-stack application for conversational AI.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/anythingllm", + "config": "configs/anythingllm", + "data": "data/anythingllm", + "logs": "logs/anythingllm" + } +}, + + "flowise": { + "name": "Flowise", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Workflow", + "installer": { + "type": "npm", + "pkg": "flowise" + }, + "launcher": { + "type": "tmux", + "cmd": "npx flowise start --port {port}", + "default_port": 3000 + }, + "deps": [], + "description": "Drag & drop UI to build customized LLM flows.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/flowise", + "data": "data/flowise", + "logs": "logs/flowise" + } +}, + + "dify": { + "name": "Dify", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Workflow", + "installer": { + "type": "git", + "pkg": "https://github.com/langgenius/dify.git" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/dify/docker && docker compose up", + "default_port": 80 + }, + "deps": [ + "postgresql", + "redis" + ], + "description": "LLM application development platform.", + "flags": { + "is_docker": True, + "has_cli": False, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/dify", + "config": "configs/dify", + "data": "data/dify", + "logs": "logs/dify" + } +}, + + "invokeai": { + "name": "InvokeAI", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Image Generation", + "installer": { + "type": "git", + "pkg": "https://github.com/invoke-ai/InvokeAI", + "env_overrides": { + "HF_HOME": "{base_dir}/cache/huggingface", + "DIFFUSERS_CACHE": "{base_dir}/cache/huggingface" + } + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/invokeai && invokeai --host 0.0.0.0 --port {port}", + "default_port": 9090 + }, + "deps": [ + "cuda" + ], + "description": "Professional AI image generation workspace.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + }, + "filesystem": { + "install": "tools/invokeai", + "config": "configs/invokeai", + "data": "data/invokeai", + "cache": "cache/invokeai", + "logs": "logs/invokeai" + } +}, + + "forge": { + "name": "Forge (A1111)", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Image Generation", + "installer": { + "type": "git", + "pkg": "https://github.com/AUTOMATIC1111/stable-diffusion-webui-forge", + "env_overrides": { + "HF_HOME": "{base_dir}/cache/huggingface", + "DIFFUSERS_CACHE": "{base_dir}/cache/huggingface" + } + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/forge && python3 launch.py --port {port}", + "default_port": 7860 + }, + "deps": [ + "cuda" + ], + "description": "Stable Diffusion WebUI Forge (optimized fork).", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/forge", + "config": "configs/forge", + "data": "data/forge", + "cache": "cache/forge", + "logs": "logs/forge" + } +}, + + "dashy": { + "name": "Dashy", + "level": 11, + "layer": "User Interfaces", + "role": "Dashboard", + "category": "Homepage", + "installer": { + "type": "npm", + "pkg": "dashy" + }, + "launcher": { + "type": "tmux", + "cmd": "dashy --port {port}", + "default_port": 3000 + }, + "deps": [], + "description": "Highly customizable dashboard and homepage.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + + "local_llm_launcher": { + "name": "Local LLM Launcher", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "LLM Runtime", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/local-llm-launcher-gui" + }, + "launcher": { + "type": "desktop", + "cmd": "cd {tools_root}/local_llm_launcher && python3 main.py", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "GUI launcher and manager for local LLMs.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + + "hermes_desktop": { + "name": "Hermes Desktop", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Desktop Agent", + "installer": { + "type": "npm", + "pkg": "hermes-desktop" + }, + "launcher": { + "type": "desktop", + "cmd": "hermes desktop", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "Hermes desktop agent environment.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + # ── L12: DevOps ──────────────────────────────────────────── + + "sst": { + "name": "SST (Serverless Stack)", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "Serverless Framework", + "installer": { + "type": "npm", + "pkg": "sst" + }, + "launcher": { + "type": "desktop", + "cmd": "sst --version", + "default_port": None + }, + "deps": [], + "description": "Framework for building full-stack apps on your own infrastructure.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "container_tool": { + "name": "Container Toolkit", + "level": 12, + "layer": "DevOps", + "role": "Isolation", + "category": "Sandbox", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/container" + }, + "launcher": { + "type": "desktop", + "cmd": "container --help", + "default_port": None + }, + "deps": [], + "description": "Lightweight container sandbox for code execution.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "opensandbox": { + "name": "OpenSandbox", + "level": 12, + "layer": "DevOps", + "role": "Isolation", + "category": "Sandbox", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/opensandbox", + "post_install": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || .venv/bin/pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/opensandbox && python3 serve.py --port {port}", + "default_port": 9100 + }, + "deps": [], + "description": "Secure sandboxed execution environment for AI agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "terraform": { + "name": "Terraform", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "pacman", + "pkg": "terraform" + }, + "launcher": { + "type": "desktop", + "cmd": "terraform version", + "default_port": None + }, + "deps": [], + "description": "Infrastructure as Code provisioning tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "ansible": { + "name": "Ansible", + "level": 12, + "layer": "DevOps", + "role": "Configuration Management", + "category": "Config Management", + "installer": { + "type": "pacman", + "pkg": "ansible" + }, + "launcher": { + "type": "desktop", + "cmd": "ansible --version", + "default_port": None + }, + "deps": [], + "description": "Agentless IT automation and configuration management.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "puppet": { + "name": "Puppet", + "level": 12, + "layer": "DevOps", + "role": "Configuration Management", + "category": "Config Management", + "installer": { + "type": "pacman", + "pkg": "puppet" + }, + "launcher": { + "type": "desktop", + "cmd": "puppet --version", + "default_port": None + }, + "deps": [], + "description": "Declarative configuration management tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "stack_exporter": { + "name": "Stack Container Packager", + "level": 12, + "layer": "DevOps", + "role": "Runtime Packaging", + "category": "OCI Export", + "installer": { + "type": "pacman", + "pkg": "podman" + }, + "launcher": { + "type": "desktop", + "cmd": "podman --version", + "default_port": None + }, + "deps": [], + "description": "Compiles validated pipeline matrices into Podman/Docker specs.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "pulumi": { + "name": "Pulumi", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "npm", + "pkg": "@pulumi/pulumi" + }, + "launcher": { + "type": "desktop", + "cmd": "pulumi version", + "default_port": None + }, + "deps": [], + "description": "IaC platform using real programming languages (Python, TypeScript, Go).", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "bicep": { + "name": "Bicep", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "npm", + "pkg": "@azure/bicep" + }, + "launcher": { + "type": "desktop", + "cmd": "bicep --version", + "default_port": None + }, + "deps": [], + "description": "Azure domain-specific language for declarative infrastructure.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "opentofu": { + "name": "OpenTofu", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "custom", + "pkg": "https://opentofu.org/docs/intro/install/" + }, + "launcher": { + "type": "desktop", + "cmd": "tofu version", + "default_port": None + }, + "deps": [], + "description": "Open-source Terraform fork maintained by the Linux Foundation.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "aws_cdk": { + "name": "AWS CDK", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "npm", + "pkg": "aws-cdk" + }, + "launcher": { + "type": "desktop", + "cmd": "cdk --version", + "default_port": None + }, + "deps": [], + "description": "Cloud Development Kit — define AWS CloudFormation in code.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "crossplane": { + "name": "Crossplane", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC Control Plane", + "installer": { + "type": "custom", + "pkg": "https://docs.crossplane.io/v2/getting-started/install/" + }, + "launcher": { + "type": "desktop", + "cmd": "crossplane --help", + "default_port": None + }, + "deps": [ + "kubectl" + ], + "description": "Kubernetes-native cloud infrastructure control plane.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + + "terragrunt": { + "name": "Terragrunt", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC Wrapper", + "installer": { + "type": "custom", + "pkg": "https://terragrunt.gruntwork.io/docs/getting-started/install/" + }, + "launcher": { + "type": "desktop", + "cmd": "terragrunt --version", + "default_port": None + }, + "deps": [ + "terraform" + ], + "description": "Thin wrapper for Terraform providing DRY config and remote state.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "homelab": { + "name": "Homelab", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "Provisioning", + "installer": { + "type": "git", + "pkg": "https://github.com/khuedoan/homelab", + "cmd": "" + }, + "launcher": { + "type": "cli", + "cmd": "ansible-playbook site.yml", + "default_port": None + }, + "deps": [ + "ansible" + ], + "description": "Fully automated homelab provisioning from empty disk to running services in one command. IaC/GitOps: Packer + Terraform + Ansible + k3s + ArgoCD.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + # ── L13: Knowledge Management ────────────────────────────── + + "kanban": { + "name": "Kanban Board", + "level": 13, + "layer": "Knowledge Management", + "role": "Planning", + "category": "Project Management", + "installer": { + "type": "npm", + "pkg": "kanban-board" + }, + "launcher": { + "type": "desktop", + "cmd": "kanban", + "default_port": None + }, + "deps": [], + "description": "Local kanban board for task and sprint management.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + + "career_ops": { + "name": "Career Ops", + "level": 13, + "layer": "Knowledge Management", + "role": "Assessment", + "category": "Skill Analysis", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/career-ops" + }, + "launcher": { + "type": "desktop", + "cmd": "career-ops analyze", + "default_port": None + }, + "deps": [], + "description": "Career skill assessment and gap analysis tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "pm_skills": { + "name": "PM Skills", + "level": 13, + "layer": "Knowledge Management", + "role": "Management", + "category": "Project Management", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/pm-skills" + }, + "launcher": { + "type": "desktop", + "cmd": "pm-skills plan", + "default_port": None + }, + "deps": [], + "description": "AI-assisted project management and planning skills.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + + "mnemosyne": { + "name": "Mnemosyne", + "level": 13, + "layer": "Knowledge Management", + "role": "Learning", + "category": "Spaced Repetition", + "installer": { + "type": "pipx", + "pkg": "mnemosyne" + }, + "launcher": { + "type": "desktop", + "cmd": "mnemosyne", + "default_port": None + }, + "deps": [], + "description": "Spaced repetition flashcard program with AI integration.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + + "obsidian": { + "name": "Obsidian", + "level": 13, + "layer": "Knowledge Management", + "role": "Knowledge Graph", + "category": "Notes", + "installer": { + "type": "pacman", + "pkg": "obsidian" + }, + "launcher": { + "type": "desktop", + "cmd": "obsidian", + "default_port": None + }, + "deps": [], + "description": "Knowledge graph note-taking and markdown editor.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + + "zotero": { + "name": "Zotero", + "level": 13, + "layer": "Knowledge Management", + "role": "Reference Manager", + "category": "Academic References", + "installer": { + "type": "pacman", + "pkg": "zotero" + }, + "launcher": { + "type": "desktop", + "cmd": "zotero", + "default_port": None + }, + "deps": [], + "description": "Free reference management for researchers.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + + "calibre": { + "name": "Calibre", + "level": 13, + "layer": "Knowledge Management", + "role": "Library Manager", + "category": "Ebook Library", + "installer": { + "type": "pacman", + "pkg": "calibre" + }, + "launcher": { + "type": "desktop", + "cmd": "calibre", + "default_port": None + }, + "deps": [], + "description": "E-book library management and converter.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + } +}, + + "paperlessngx": { + "name": "Paperless-ngx", + "level": 13, + "layer": "Knowledge Management", + "role": "Document Archive", + "category": "Document Management", + "installer": { + "type": "git", + "pkg": "https://github.com/paperless-ngx/paperless-ngx", + "env_overrides": { + "PAPERLESS_DATA_DIR": "{base_dir}/data/paperlessngx" + } + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/paperlessngx && python3 manage.py runserver 0.0.0.0:{port}", + "default_port": 8000 + }, + "deps": [ + "postgresql", + "redis" + ], + "description": "Document management system with OCR.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/paperlessngx", + "config": "configs/paperlessngx", + "data": "data/paperlessngx", + "logs": "logs/paperlessngx" + } +}, + + "logseq": { + "name": "Logseq", + "level": 13, + "layer": "Knowledge Management", + "role": "Knowledge Graph", + "category": "Outliner", + "installer": { + "type": "npm", + "pkg": "logseq" + }, + "launcher": { + "type": "desktop", + "cmd": "logseq", + "default_port": None + }, + "deps": [], + "description": "Privacy-first knowledge graph outliner.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + + "joplin": { + "name": "Joplin", + "level": 13, + "layer": "Knowledge Management", + "role": "Note Taking", + "category": "Notes", + "installer": { + "type": "pacman", + "pkg": "joplin" + }, + "launcher": { + "type": "desktop", + "cmd": "joplin", + "default_port": None + }, + "deps": [], + "description": "Open-source note taking and to-do application.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + } +} +} diff --git a/src/ai_lsc/registry/layers/__init__.py b/src/ai_lsc/registry/layers/__init__.py new file mode 100644 index 0000000..8efceda --- /dev/null +++ b/src/ai_lsc/registry/layers/__init__.py @@ -0,0 +1,6 @@ +"""Registry layers sub-package. + +Each module in this directory exports a ``TOOLS`` list of registry +entries belonging to one 13-Layer stratum. The loader in +:mod:`ai_lsc.registry.loader` discovers and merges them automatically. +""" diff --git a/src/ai_lsc/registry/layers/automation.py b/src/ai_lsc/registry/layers/automation.py new file mode 100644 index 0000000..35d5cd3 --- /dev/null +++ b/src/ai_lsc/registry/layers/automation.py @@ -0,0 +1,785 @@ +"""Registry entries for the Automation & Execution layer (L8). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'aider': { + "name": "Aider", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Development", + "installer": { + "type": "uv", + "pkg": "aider-chat" + }, + "launcher": { + "type": "tmux", + "cmd": "aider --model {model_arg}", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "AI pair programming in your terminal.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'claude_code': { + "name": "Claude Code", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Development", + "installer": { + "type": "npm", + "pkg": "@anthropic-ai/claude-code" + }, + "launcher": { + "type": "tmux", + "cmd": "claude", + "default_port": None + }, + "deps": [], + "description": "Anthropic's terminal assistant.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'fabric': { + "name": "Fabric", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Curation", + "installer": { + "type": "script", + "cmd": "curl -L https://github.com/danielmiessler/fabric/releases/latest/download/fabric-linux-amd64 > {tools_root}/bin/fabric && chmod +x {tools_root}/bin/fabric" + }, + "launcher": { + "type": "tmux", + "cmd": "fabric", + "default_port": None + }, + "deps": [], + "description": "Open-source framework for augmenting humans using AI.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'agentic_os': { + "name": "Agentic OS", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Agent OS", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/agentic-os" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/agentic_os && python3 main.py", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "Autonomous agent operating system framework.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'loop_engineering': { + "name": "Loop Engineering", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Dev Automation", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/loop-engineering" + }, + "launcher": { + "type": "desktop", + "cmd": "loop-engineering --help", + "default_port": None + }, + "deps": [], + "description": "Development loop automation and CI/CD orchestration.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'algory': { + "name": "Algory", + "level": 8, + "layer": "Automation & Execution", + "role": "Hands", + "category": "Algorithm Toolkit", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/algory" + }, + "launcher": { + "type": "desktop", + "cmd": "algory --help", + "default_port": None + }, + "deps": [], + "description": "Algorithm design and benchmarking toolkit.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'hivemind': { + "name": "HiveMind", + "level": 8, + "layer": "Automation & Execution", + "role": "Coordination", + "category": "Multi-Agent", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/hivemind" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/hivemind && python3 -m hivemind serve --port {port}", + "default_port": 8700 + }, + "deps": [ + "ollama" + ], + "description": "Distributed multi-agent coordination framework.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'honcho': { + "name": "Honcho", + "level": 8, + "layer": "Automation & Execution", + "role": "Process Manager", + "category": "Procfile Runner", + "installer": { + "type": "uv", + "pkg": "honcho" + }, + "launcher": { + "type": "desktop", + "cmd": "honcho start", + "default_port": None + }, + "deps": [], + "description": "Python Procfile manager for multi-process apps.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'container_tool': { + "name": "Container Toolkit", + "level": 8, + "layer": "Automation & Execution", + "role": "Isolation", + "category": "Sandbox", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/container" + }, + "launcher": { + "type": "desktop", + "cmd": "container --help", + "default_port": None + }, + "deps": [], + "description": "Lightweight container sandbox for code execution.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'nightshift': { + "name": "NightShift", + "level": 8, + "layer": "Automation & Execution", + "role": "Scheduler", + "category": "Task Runner", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/nightshift" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/nightshift && python3 -m nightshift serve --port {port}", + "default_port": 8800 + }, + "deps": [], + "description": "Scheduled task execution and background job runner.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'atlas_os': { + "name": "Atlas OS", + "level": 8, + "layer": "Automation & Execution", + "role": "OS Integration", + "category": "AI Operating System", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/atlas-os" + }, + "launcher": { + "type": "desktop", + "cmd": "atlas --version", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "AI-native operating system integration layer.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'pulse_ai': { + "name": "Pulse AI", + "level": 8, + "layer": "Automation & Execution", + "role": "Health Monitor", + "category": "AI Monitoring", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/pulse-ai" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/pulse_ai && python3 -m pulse serve --port {port}", + "default_port": 8900 + }, + "deps": [], + "description": "AI service health monitoring and auto-recovery.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'eagle_eye': { + "name": "Eagle-Eye", + "level": 8, + "layer": "Automation & Execution", + "role": "Inspector", + "category": "Code Analysis", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/eagle-eye" + }, + "launcher": { + "type": "desktop", + "cmd": "eagle-eye scan .", + "default_port": None + }, + "deps": [], + "description": "Automated code inspection and quality gate.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'graphify': { + "name": "Graphify", + "level": 8, + "layer": "Automation & Execution", + "role": "Graph Builder", + "category": "Knowledge Graph", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/graphify" + }, + "launcher": { + "type": "desktop", + "cmd": "graphify build .", + "default_port": None + }, + "deps": [], + "description": "Codebase knowledge graph construction tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'mirofish': { + "name": "Mirofish", + "level": 8, + "layer": "Automation & Execution", + "role": "Transform", + "category": "Data Pipeline", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/mirofish" + }, + "launcher": { + "type": "desktop", + "cmd": "mirofish --help", + "default_port": None + }, + "deps": [], + "description": "Data transformation and ETL pipeline framework.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'promptops': { + "name": "PromptOps.it", + "level": 8, + "layer": "Automation & Execution", + "role": "Prompt Management", + "category": "Prompt Tooling", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/promptops" + }, + "launcher": { + "type": "desktop", + "cmd": "promptops --help", + "default_port": None + }, + "deps": [], + "description": "Prompt versioning, testing, and operations toolkit.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'opensandbox': { + "name": "OpenSandbox", + "level": 8, + "layer": "Automation & Execution", + "role": "Isolation", + "category": "Sandbox", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/opensandbox" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/opensandbox && python3 serve.py --port {port}", + "default_port": 9100 + }, + "deps": [], + "description": "Secure sandboxed execution environment for AI agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'kanban': { + "name": "Kanban Board", + "level": 8, + "layer": "Automation & Execution", + "role": "Planning", + "category": "Project Management", + "installer": { + "type": "npm", + "pkg": "kanban-board" + }, + "launcher": { + "type": "desktop", + "cmd": "kanban", + "default_port": None + }, + "deps": [], + "description": "Local kanban board for task and sprint management.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + 'spec_kit': { + "name": "Spec Kit", + "level": 8, + "layer": "Automation & Execution", + "role": "Documentation", + "category": "Spec Writer", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/spec-kit" + }, + "launcher": { + "type": "desktop", + "cmd": "spec-kit init", + "default_port": None + }, + "deps": [], + "description": "Automated specification and requirement document generator.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'agent_reach': { + "name": "Agent Reach", + "level": 8, + "layer": "Automation & Execution", + "role": "Discovery", + "category": "Agent Network", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/agent-reach" + }, + "launcher": { + "type": "desktop", + "cmd": "agent-reach discover", + "default_port": None + }, + "deps": [], + "description": "Multi-agent service discovery and capability mapping.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'career_ops': { + "name": "Career Ops", + "level": 8, + "layer": "Automation & Execution", + "role": "Assessment", + "category": "Skill Analysis", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/career-ops" + }, + "launcher": { + "type": "desktop", + "cmd": "career-ops analyze", + "default_port": None + }, + "deps": [], + "description": "Career skill assessment and gap analysis tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'pm_skills': { + "name": "PM Skills", + "level": 8, + "layer": "Automation & Execution", + "role": "Management", + "category": "Project Management", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/pm-skills" + }, + "launcher": { + "type": "desktop", + "cmd": "pm-skills plan", + "default_port": None + }, + "deps": [], + "description": "AI-assisted project management and planning skills.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'everos_memory': { + "name": "EverOS Memory", + "level": 8, + "layer": "Automation & Execution", + "role": "Memory", + "category": "Persistent Memory", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/everos-memory" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/everos_memory && python3 -m everos serve --port {port}", + "default_port": 9200 + }, + "deps": [], + "description": "Persistent long-term memory system for AI agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'hermes_agent': { + "name": "Hermes Agent", + "level": 8, + "layer": "Automation & Execution", + "role": "Agent", + "category": "AI Agent", + "installer": { + "type": "npm", + "pkg": "hermes-agent" + }, + "launcher": { + "type": "tmux", + "cmd": "hermes agent --port {port}", + "default_port": 17051 + }, + "deps": [ + "ollama" + ], + "description": "Hermes autonomous agent runtime.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'openhands': { + "name": "OpenHands", + "level": 8, + "layer": "Automation & Execution", + "role": "Autonomous Coder", + "category": "AI Coding Agent", + "installer": { + "type": "git_node", + "pkg": "https://github.com/All-Hands-AI/OpenHands.git", + "update_cmd": "git pull --ff-only && pip install -e ." + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/openhands && python -m openhands.server --port {port}", + "default_port": 3000 + }, + "deps": [ + "ollama" + ], + "description": "Autonomous AI software engineer. Plans, writes, debugs, and executes code in sandboxed environments with full terminal access, file management, and web browsing.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + }, + "filesystem": { + "install": "tools/openhands", + "config": "workspaces/openhands/config", + "cache": "cache/openhands", + "logs": "logs/openhands" + } +}, + 'ponytail': { + "name": "Ponytail", + "level": 8, + "layer": "Automation & Execution", + "role": "Code Gen", + "category": "Code Generation", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/ponytail" + }, + "launcher": { + "type": "desktop", + "cmd": "ponytail generate", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "AI-powered code generation and refactoring tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'headroom': { + "name": "Headroom", + "level": 8, + "layer": "Automation & Execution", + "role": "Context", + "category": "Context Manager", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/headroom" + }, + "launcher": { + "type": "desktop", + "cmd": "headroom scan", + "default_port": None + }, + "deps": [], + "description": "Codebase context extraction and management.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'latitude': { + "name": "Latitude", + "level": 8, + "layer": "Automation & Execution", + "role": "Evaluation", + "category": "LLM Evaluation", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/latitude" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/latitude && python3 -m latitude serve --port {port}", + "default_port": 9300 + }, + "deps": [ + "ollama" + ], + "description": "LLM output evaluation and benchmarking platform.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'skillspector': { + "name": "Skillspector", + "level": 8, + "layer": "Automation & Execution", + "role": "Analysis", + "category": "Skill Inspection", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/skillspector" + }, + "launcher": { + "type": "desktop", + "cmd": "skillspector inspect", + "default_port": None + }, + "deps": [], + "description": "Ollama skill definition inspector and validator.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'glassmind': { + "name": "GlassMind", + "level": 8, + "layer": "Automation & Execution", + "role": "Reasoning", + "category": "Reasoning Engine", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/glassmind" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/glassmind && python3 -m glassmind serve --port {port}", + "default_port": 9400 + }, + "deps": [ + "ollama" + ], + "description": "Transparent reasoning and chain-of-thought engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'opendataloader_pdf': { + "name": "OpenDataLoader PDF", + "level": 8, + "layer": "Automation & Execution", + "role": "Extraction", + "category": "PDF Pipeline", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/opendataloader-pdf" + }, + "launcher": { + "type": "desktop", + "cmd": "opendataloader-pdf extract file.pdf", + "default_port": None + }, + "deps": [], + "description": "Specialized PDF extraction and data loading pipeline.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'understand_anything': { + "name": "Understand Anything", + "level": 8, + "layer": "Automation & Execution", + "role": "Comprehension", + "category": "Document Understanding", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/understand-anything" + }, + "launcher": { + "type": "desktop", + "cmd": "understand-anything analyze file.pdf", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "Universal document understanding and summarization.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, +} diff --git a/src/ai_lsc/registry/layers/containers.py b/src/ai_lsc/registry/layers/containers.py new file mode 100644 index 0000000..1e9cdcb --- /dev/null +++ b/src/ai_lsc/registry/layers/containers.py @@ -0,0 +1,256 @@ +"""Registry entries for the DevOps layer (L12). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'terraform': { + "name": "Terraform", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "pacman", + "pkg": "terraform" + }, + "launcher": { + "type": "desktop", + "cmd": "terraform version", + "default_port": None + }, + "deps": [], + "description": "Infrastructure as Code provisioning tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'ansible': { + "name": "Ansible", + "level": 12, + "layer": "DevOps", + "role": "Configuration Management", + "category": "Config Management", + "installer": { + "type": "pacman", + "pkg": "ansible" + }, + "launcher": { + "type": "desktop", + "cmd": "ansible --version", + "default_port": None + }, + "deps": [], + "description": "Agentless IT automation and configuration management.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'puppet': { + "name": "Puppet", + "level": 12, + "layer": "DevOps", + "role": "Configuration Management", + "category": "Config Management", + "installer": { + "type": "pacman", + "pkg": "puppet" + }, + "launcher": { + "type": "desktop", + "cmd": "puppet --version", + "default_port": None + }, + "deps": [], + "description": "Declarative configuration management tool.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'stack_exporter': { + "name": "Stack Container Packager", + "level": 12, + "layer": "DevOps", + "role": "Runtime Packaging", + "category": "OCI Export", + "installer": { + "type": "pacman", + "pkg": "podman" + }, + "launcher": { + "type": "desktop", + "cmd": "podman --version", + "default_port": None + }, + "deps": [], + "description": "Compiles validated pipeline matrices into Podman/Docker specs.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'pulumi': { + "name": "Pulumi", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "npm", + "pkg": "@pulumi/pulumi" + }, + "launcher": { + "type": "desktop", + "cmd": "pulumi version", + "default_port": None + }, + "deps": [], + "description": "IaC platform using real programming languages (Python, TypeScript, Go).", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'bicep': { + "name": "Bicep", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "npm", + "pkg": "@azure/bicep" + }, + "launcher": { + "type": "desktop", + "cmd": "bicep --version", + "default_port": None + }, + "deps": [], + "description": "Azure domain-specific language for declarative infrastructure.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'opentofu': { + "name": "OpenTofu", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "custom", + "pkg": "https://opentofu.org/docs/intro/install/" + }, + "launcher": { + "type": "desktop", + "cmd": "tofu version", + "default_port": None + }, + "deps": [], + "description": "Open-source Terraform fork maintained by the Linux Foundation.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'aws_cdk': { + "name": "AWS CDK", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC", + "installer": { + "type": "npm", + "pkg": "aws-cdk" + }, + "launcher": { + "type": "desktop", + "cmd": "cdk --version", + "default_port": None + }, + "deps": [], + "description": "Cloud Development Kit — define AWS CloudFormation in code.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'crossplane': { + "name": "Crossplane", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC Control Plane", + "installer": { + "type": "custom", + "pkg": "https://docs.crossplane.io/v2/getting-started/install/" + }, + "launcher": { + "type": "desktop", + "cmd": "crossplane --help", + "default_port": None + }, + "deps": [ + "kubectl" + ], + "description": "Kubernetes-native cloud infrastructure control plane.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'terragrunt': { + "name": "Terragrunt", + "level": 12, + "layer": "DevOps", + "role": "Infrastructure as Code", + "category": "IaC Wrapper", + "installer": { + "type": "custom", + "pkg": "https://terragrunt.gruntwork.io/docs/getting-started/install/" + }, + "launcher": { + "type": "desktop", + "cmd": "terragrunt --version", + "default_port": None + }, + "deps": [ + "terraform" + ], + "description": "Thin wrapper for Terraform providing DRY config and remote state.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, +} diff --git a/src/ai_lsc/registry/layers/data_knowledge.py b/src/ai_lsc/registry/layers/data_knowledge.py new file mode 100644 index 0000000..8bd501e --- /dev/null +++ b/src/ai_lsc/registry/layers/data_knowledge.py @@ -0,0 +1,319 @@ +"""Registry entries for the Data & Knowledge Pipelines layer (L7). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'chromadb': { + "name": "ChromaDB", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Vector Store", + "installer": { + "type": "uv", + "pkg": "chromadb" + }, + "launcher": { + "type": "tmux", + "cmd": "chroma run --path {models_root}/chroma --port {port}", + "default_port": 8000 + }, + "deps": [], + "description": "AI-native open-source vector database.", + "flags": {} +}, + 'whisper': { + "name": "Whisper", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Audio Parsing", + "installer": { + "type": "uv", + "pkg": "openai-whisper" + }, + "launcher": { + "type": "tmux", + "cmd": "whisper", + "default_port": None + }, + "deps": [], + "description": "Robust Speech Recognition via Large-Scale Weak Supervision.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'docling': { + "name": "Docling", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "File Parsing", + "installer": { + "type": "uv", + "pkg": "docling" + }, + "launcher": { + "type": "tmux", + "cmd": "docling", + "default_port": None + }, + "deps": [], + "description": "Advanced document parsing and chunking.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'lancedb': { + "name": "LanceDB", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Vector Store", + "installer": { + "type": "uv", + "pkg": "lancedb" + }, + "launcher": { + "type": "tmux", + "cmd": "python3 -m lancedb serve --port {port}", + "default_port": 8484 + }, + "deps": [], + "description": "Serverless vector database for AI applications.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'neo4j': { + "name": "Neo4j", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Graph Database", + "installer": { + "type": "pacman", + "pkg": "neo4j" + }, + "launcher": { + "type": "systemd", + "cmd": "neo4j", + "default_port": 7474 + }, + "deps": [], + "description": "Native graph database and knowledge graph engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'elasticsearch': { + "name": "Elasticsearch", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Search Engine", + "installer": { + "type": "pacman", + "pkg": "elasticsearch" + }, + "launcher": { + "type": "systemd", + "cmd": "elasticsearch", + "default_port": 9200 + }, + "deps": [], + "description": "Distributed search and analytics engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'meilisearch': { + "name": "Meilisearch", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Memory / Senses", + "category": "Search Engine", + "installer": { + "type": "script", + "cmd": "curl -L https://install.meilisearch.com | sh" + }, + "launcher": { + "type": "tmux", + "cmd": "meilisearch --port {port}", + "default_port": 7700 + }, + "deps": [], + "description": "Fast, relevant, and typo-tolerant search engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'graphrag': { + "name": "GraphRAG", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Knowledge Synthesis", + "category": "Graph RAG", + "installer": { + "type": "uv", + "pkg": "graphrag" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -m graphrag init", + "default_port": None + }, + "deps": [], + "description": "Microsoft GraphRAG for knowledge graph construction.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'crawl4ai': { + "name": "Crawl4AI", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Data Harvesting", + "category": "Web Crawler", + "installer": { + "type": "uv", + "pkg": "crawl4ai" + }, + "launcher": { + "type": "desktop", + "cmd": "crawl4ai https://example.com", + "default_port": None + }, + "deps": [], + "description": "LLM-friendly web crawler and data extractor.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'markitdown': { + "name": "MarkItDown", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "File Parsing", + "category": "Document Converter", + "installer": { + "type": "uv", + "pkg": "markitdown" + }, + "launcher": { + "type": "desktop", + "cmd": "markitdown document.pdf", + "default_port": None + }, + "deps": [], + "description": "Microsoft tool to convert files to Markdown.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'opendataloader': { + "name": "OpenDataLoader", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Ingestion", + "category": "Data Pipeline", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/opendataloader" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -m opendataloader --help", + "default_port": None + }, + "deps": [], + "description": "Universal data loading and preprocessing pipeline.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'turbovec': { + "name": "TurboVec", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Embedding", + "category": "Vector Engine", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/turbovec" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/turbovec && python3 serve.py --port {port}", + "default_port": 8101 + }, + "deps": [ + "cuda" + ], + "description": "High-speed embedding generation and vector engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'airweave': { + "name": "Airweave", + "level": 7, + "layer": "Data & Knowledge Pipelines", + "role": "Integration", + "category": "Data Sync", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/airweave" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/airweave && python3 -m airweave serve --port {port}", + "default_port": 8600 + }, + "deps": [], + "description": "Real-time data synchronization and integration layer.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, +} diff --git a/src/ai_lsc/registry/layers/development.py b/src/ai_lsc/registry/layers/development.py new file mode 100644 index 0000000..357295d --- /dev/null +++ b/src/ai_lsc/registry/layers/development.py @@ -0,0 +1,118 @@ +"""Registry entries for the Development Environment layer (L2). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'python': { + "name": "Python Environment", + "level": 2, + "layer": "Development Environment", + "role": "Build System", + "category": "Runtime", + "installer": { + "type": "pacman", + "pkg": "python-pip" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 --version", + "default_port": None + }, + "deps": [], + "description": "Python core interpreter and virtual environments.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'cupy': { + "name": "CuPy", + "level": 2, + "layer": "Development Environment", + "role": "GPU Acceleration", + "category": "GPU Computing", + "installer": { + "type": "uv", + "pkg": "cupy-cuda12x" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import cupy; print(cupy.__version__)\"", + "default_port": None + }, + "deps": [ + "cuda" + ], + "description": "NumPy-compatible GPU array computing library.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'unsloth': { + "name": "Unsloth", + "level": 2, + "layer": "Development Environment", + "role": "Training", + "category": "Model Training", + "installer": { + "type": "uv", + "pkg": "unsloth" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import unsloth; print('ok')\"", + "default_port": None + }, + "deps": [ + "cuda" + ], + "description": "2x faster LLM fine-tuning with 80% less memory.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'sst': { + "name": "SST (Serverless Stack)", + "level": 2, + "layer": "Development Environment", + "role": "Full-Stack Framework", + "category": "Serverless Framework", + "installer": { + "type": "npm", + "pkg": "sst" + }, + "launcher": { + "type": "desktop", + "cmd": "sst --version", + "default_port": None + }, + "deps": [], + "description": "Framework for building full-stack apps on your own infrastructure (AWS, Cloudflare, etc).", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, +} diff --git a/src/ai_lsc/registry/layers/distributed.py b/src/ai_lsc/registry/layers/distributed.py new file mode 100644 index 0000000..fe47fd8 --- /dev/null +++ b/src/ai_lsc/registry/layers/distributed.py @@ -0,0 +1,141 @@ +"""Registry entries for the Distributed Runtime layer (L5). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'vllm': { + "name": "vLLM", + "level": 5, + "layer": "Distributed Runtime", + "role": "Scaling", + "category": "LLM Serving", + "installer": { + "type": "uv", + "pkg": "vllm" + }, + "launcher": { + "type": "tmux", + "cmd": "python -m vllm.entrypoints.openai.api_server --port {port}", + "default_port": 8000 + }, + "deps": [ + "cuda" + ], + "description": "High-throughput and memory-efficient LLM serving.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'distcc': { + "name": "DistCC", + "level": 5, + "layer": "Distributed Runtime", + "role": "Distribution", + "category": "Distributed Compilation", + "installer": { + "type": "pacman", + "pkg": "distcc" + }, + "launcher": { + "type": "desktop", + "cmd": "distcc --version", + "default_port": 3632 + }, + "deps": [], + "description": "Distributed C/C++ compilation across multiple machines.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'dma': { + "name": "DMA (Distcc Monitor Agent)", + "level": 5, + "layer": "Distributed Runtime", + "role": "Monitoring", + "category": "Build Monitoring", + "installer": { + "type": "git", + "pkg": "https://github.com/distcc/dma" + }, + "launcher": { + "type": "desktop", + "cmd": "dma --version", + "default_port": None + }, + "deps": [ + "distcc" + ], + "description": "Monitor for distributed compilation with distcc.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'ray': { + "name": "Ray", + "level": 5, + "layer": "Distributed Runtime", + "role": "Scaling", + "category": "Distributed Compute", + "installer": { + "type": "uv", + "pkg": "ray" + }, + "launcher": { + "type": "tmux", + "cmd": "ray start --head --port {port}", + "default_port": 8265 + }, + "deps": [], + "description": "Unified framework for scaling AI and Python applications.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'pssh': { + "name": "PSSH (Parallel SSH)", + "level": 5, + "layer": "Distributed Runtime", + "role": "Coordination", + "category": "Cluster SSH", + "installer": { + "type": "pacman", + "pkg": "pssh" + }, + "launcher": { + "type": "desktop", + "cmd": "pssh --version", + "default_port": None + }, + "deps": [], + "description": "Parallel SSH tool for running commands on multiple hosts.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, +} diff --git a/src/ai_lsc/registry/layers/endpoints.py b/src/ai_lsc/registry/layers/endpoints.py new file mode 100644 index 0000000..57ece73 --- /dev/null +++ b/src/ai_lsc/registry/layers/endpoints.py @@ -0,0 +1,285 @@ +"""Registry entries for the AI Endpoints layer (L6). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'litellm': { + "name": "LiteLLM Proxy", + "level": 6, + "layer": "AI Endpoints", + "role": "API Gateway", + "category": "Proxy", + "installer": { + "type": "uv", + "pkg": "litellm" + }, + "launcher": { + "type": "tmux", + "cmd": "litellm --port {port}", + "default_port": 4000 + }, + "deps": [], + "description": "Call 100+ LLMs using the OpenAI format.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'odysseus': { + "name": "Odysseus", + "level": 6, + "layer": "AI Endpoints", + "role": "Reasoning", + "category": "Agent Workflow", + "installer": { + "type": "uv", + "pkg": "odysseus" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/odysseus/ && ./.venv/bin/uvicorn app:app --host 127.0.0.1 --port {port}", + "default_port": 7000 + }, + "deps": [ + "ollama" + ], + "description": "Local reasoning and orchestration agent.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + '9router_proxy': { + "name": "9Router Proxy", + "level": 6, + "layer": "AI Endpoints", + "role": "API Gateway", + "category": "LLM Router", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/9router" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/9router && python3 main.py --port {port}", + "default_port": 4001 + }, + "deps": [ + "ollama" + ], + "description": "Intelligent LLM request router and load balancer.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'langchain': { + "name": "LangChain", + "level": 6, + "layer": "AI Endpoints", + "role": "Orchestration", + "category": "LLM Framework", + "installer": { + "type": "uv", + "pkg": "langchain" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import langchain; print(langchain.__version__)\"", + "default_port": None + }, + "deps": [], + "description": "Framework for LLM-powered application development.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'langflow': { + "name": "LangFlow", + "level": 6, + "layer": "AI Endpoints", + "role": "Visual Builder", + "category": "Workflow", + "installer": { + "type": "uv", + "pkg": "langflow" + }, + "launcher": { + "type": "tmux", + "cmd": "langflow run --port {port}", + "default_port": 7860 + }, + "deps": [], + "description": "Visual framework for multi-agent and RAG workflows.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'openai_swarm': { + "name": "OpenAI Swarm", + "level": 6, + "layer": "AI Endpoints", + "role": "Multi-Agent", + "category": "Agent Framework", + "installer": { + "type": "uv", + "pkg": "swarm" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import swarm; print('ok')\"", + "default_port": None + }, + "deps": [], + "description": "OpenAI multi-agent orchestration framework.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'nvidia_agent_skills': { + "name": "NVIDIA Agent Skills", + "level": 6, + "layer": "AI Endpoints", + "role": "Tool Integration", + "category": "Agent Toolkit", + "installer": { + "type": "git", + "pkg": "https://github.com/NVIDIA/agent-skills" + }, + "launcher": { + "type": "desktop", + "cmd": "ls {tools_root}/nvidia_agent_skills", + "default_port": None + }, + "deps": [ + "cuda" + ], + "description": "NVIDIA-curated agent skill definitions and tools.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'deep_eye': { + "name": "Deep Eye", + "level": 6, + "layer": "AI Endpoints", + "role": "Vision", + "category": "Computer Vision", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/deep-eye" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/deep_eye && python3 serve.py --port {port}", + "default_port": 8100 + }, + "deps": [ + "ollama" + ], + "description": "Local computer vision analysis and description engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'parakeet': { + "name": "Parakeet.cpp", + "level": 6, + "layer": "AI Endpoints", + "role": "Senses", + "category": "Speech Recognition", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/parakeet.cpp" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/parakeet && ./parakeet --port {port}", + "default_port": 8300 + }, + "deps": [ + "cuda" + ], + "description": "C++ speech recognition with transformer architecture.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'luxtts': { + "name": "LuxTTS", + "level": 6, + "layer": "AI Endpoints", + "role": "Voice", + "category": "Text-to-Speech", + "installer": { + "type": "uv", + "pkg": "luxtts" + }, + "launcher": { + "type": "tmux", + "cmd": "luxtts serve --port {port}", + "default_port": 8500 + }, + "deps": [], + "description": "High-quality local text-to-speech synthesis.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'agno': { + "name": "Agno", + "level": 6, + "layer": "AI Endpoints", + "role": "Agent Framework", + "category": "AI Agent", + "installer": { + "type": "uv", + "pkg": "agno" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import agno; print(agno.__version__)\"", + "default_port": None + }, + "deps": [], + "description": "Framework for building AI agents with tool calling and memory.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, +} diff --git a/src/ai_lsc/registry/layers/gpu.py b/src/ai_lsc/registry/layers/gpu.py new file mode 100644 index 0000000..eb2e570 --- /dev/null +++ b/src/ai_lsc/registry/layers/gpu.py @@ -0,0 +1,70 @@ +"""Registry entries for the GPU Runtime layer (L3). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'cuda': { + "name": "CUDA Toolkit", + "level": 3, + "layer": "GPU Runtime", + "role": "Acceleration", + "category": "GPU", + "installer": { + "type": "pacman", + "pkg": "cuda" + }, + "launcher": { + "type": "desktop", + "cmd": "nvcc --version", + "default_port": None + }, + "deps": [], + "description": "NVIDIA CUDA parallel computing platform.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'apex': { + "name": "NVIDIA Apex", + "level": 3, + "layer": "GPU Runtime", + "role": "Optimization", + "category": "Mixed Precision", + "installer": { + "type": "uv", + "pkg": "apex" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import apex; print(apex.__version__)\"", + "default_port": None + }, + "deps": [ + "cuda" + ], + "description": "NVIDIA mixed precision and distributed training.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, +} diff --git a/src/ai_lsc/registry/layers/host_platform.py b/src/ai_lsc/registry/layers/host_platform.py new file mode 100644 index 0000000..bdec5f5 --- /dev/null +++ b/src/ai_lsc/registry/layers/host_platform.py @@ -0,0 +1,217 @@ +"""Registry entries for the Host Platform layer (L1). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'tmux': { + "name": "Tmux", + "level": 1, + "layer": "Host Platform", + "role": "Multiplexer", + "category": "Terminal", + "installer": { + "type": "pacman", + "pkg": "tmux" + }, + "launcher": { + "type": "desktop", + "cmd": "tmux -V", + "default_port": None + }, + "deps": [], + "description": "Terminal multiplexer for persistent sessions.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'git': { + "name": "Git", + "level": 1, + "layer": "Host Platform", + "role": "Version Control", + "category": "VCS", + "installer": { + "type": "pacman", + "pkg": "git" + }, + "launcher": { + "type": "desktop", + "cmd": "git --version", + "default_port": None + }, + "deps": [], + "description": "Distributed version control system.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'podman': { + "name": "Podman", + "level": 1, + "layer": "Host Platform", + "role": "Container Runtime", + "category": "Containers", + "installer": { + "type": "pacman", + "pkg": "podman" + }, + "launcher": { + "type": "desktop", + "cmd": "podman --version", + "default_port": None + }, + "deps": [], + "description": "Daemonless container engine for OCI containers.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'docker': { + "name": "Docker", + "level": 1, + "layer": "Host Platform", + "role": "Container Runtime", + "category": "Containers", + "installer": { + "type": "pacman", + "pkg": "docker" + }, + "launcher": { + "type": "systemd", + "cmd": "docker", + "default_port": None + }, + "deps": [], + "description": "Container platform for building and running containers.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'postgresql': { + "name": "PostgreSQL", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Database", + "installer": { + "type": "pacman", + "pkg": "postgresql" + }, + "launcher": { + "type": "systemd", + "cmd": "postgresql", + "default_port": 5432 + }, + "deps": [], + "description": "Relational database used by many frameworks.", + "flags": {} +}, + 'mariadb': { + "name": "MariaDB", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Database", + "installer": { + "type": "pacman", + "pkg": "mariadb" + }, + "launcher": { + "type": "systemd", + "cmd": "mariadb", + "default_port": 3306 + }, + "deps": [], + "description": "Open source relational database.", + "flags": {} +}, + 'redis': { + "name": "Redis", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Cache", + "installer": { + "type": "pacman", + "pkg": "redis" + }, + "launcher": { + "type": "systemd", + "cmd": "redis", + "default_port": 6379 + }, + "deps": [], + "description": "In-memory cache and message broker.", + "flags": {} +}, + 'sqlite3': { + "name": "SQLite3", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Database", + "installer": { + "type": "pacman", + "pkg": "sqlite" + }, + "launcher": { + "type": "desktop", + "cmd": "sqlite3", + "default_port": None + }, + "deps": [], + "description": "C-language library implementing a SQL database engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'duckdb': { + "name": "DuckDB", + "level": 1, + "layer": "Host Platform", + "role": "Foundation", + "category": "Analytical Database", + "installer": { + "type": "uv", + "pkg": "duckdb" + }, + "launcher": { + "type": "desktop", + "cmd": "python3 -c \"import duckdb; print(duckdb.__version__)\"", + "default_port": None + }, + "deps": [], + "description": "In-process analytical database with SQL support.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, +} diff --git a/src/ai_lsc/registry/layers/inference.py b/src/ai_lsc/registry/layers/inference.py new file mode 100644 index 0000000..e43cbc2 --- /dev/null +++ b/src/ai_lsc/registry/layers/inference.py @@ -0,0 +1,190 @@ +"""Registry entries for the Inference Engines layer (L4). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'ollama': { + "name": "Ollama", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "LLM Runtime", + "installer": { + "type": "script", + "cmd": "curl -fsSL https://ollama.com/install.sh | sh" + }, + "launcher": { + "type": "tmux", + "cmd": "OLLAMA_HOST=0.0.0.0:{port} OLLAMA_MODELS={models_root}/ollama ollama serve", + "default_port": 11434 + }, + "deps": [], + "description": "Local LLM runner and model manager.", + "flags": { + "is_ollama": True, + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + 'llamacpp': { + "name": "llama.cpp", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "LLM Runtime", + "installer": { + "type": "git", + "pkg": "https://github.com/ggerganov/llama.cpp" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/llamacpp && make && ./server --port {port}", + "default_port": 8080 + }, + "deps": [], + "description": "Port of Facebook's LLaMA model in C/C++.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'koboldcpp': { + "name": "KoboldCPP", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "LLM Runtime", + "installer": { + "type": "git", + "pkg": "https://github.com/LostRuins/koboldcpp" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/koboldcpp && make && ./koboldcpp --port {port}", + "default_port": 5001 + }, + "deps": [], + "description": "GGUF-based LLM inference with CUDA/Vulkan.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": True + } +}, + 'llamafile': { + "name": "Llamafile", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "Single-File LLM", + "installer": { + "type": "script", + "cmd": "curl -LO https://github.com/Mozilla-Ocho/llamafile/releases/latest/download/llamafile && chmod +x llamafile" + }, + "launcher": { + "type": "desktop", + "cmd": "{tools_root}/bin/llamafile", + "default_port": None + }, + "deps": [], + "description": "Distribute and run LLMs in a single file.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'turbollm': { + "name": "TurboLLM", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "LLM Runtime", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/turbollm" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/turbollm && python3 -m turbollm serve --port {port}", + "default_port": 8000 + }, + "deps": [ + "cuda" + ], + "description": "Fast LLM serving with tensor parallelism.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'airllm': { + "name": "AirLLM", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "Efficient LLM", + "installer": { + "type": "git", + "pkg": "https://github.com/liguodongiot/llm-airforce" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/airllm && python3 -m airllm serve --port {port}", + "default_port": 8001 + }, + "deps": [ + "cuda" + ], + "description": "Memory-efficient 70B LLM inference on 4GB GPUs.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'locally_uncensored': { + "name": "Locally-Uncensored", + "level": 4, + "layer": "Inference Engines", + "role": "Engine", + "category": "Uncensored Models", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/locally-uncensored" + }, + "launcher": { + "type": "desktop", + "cmd": "ollama list", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "Curated uncensored model collection and tooling.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, +} diff --git a/src/ai_lsc/registry/layers/intelligent_routing.py b/src/ai_lsc/registry/layers/intelligent_routing.py new file mode 100644 index 0000000..8e3d103 --- /dev/null +++ b/src/ai_lsc/registry/layers/intelligent_routing.py @@ -0,0 +1,141 @@ +"""Registry entries for the Intelligent Routing layer (L10). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'crewai': { + "name": "CrewAI", + "level": 10, + "layer": "Intelligent Routing", + "role": "Brain", + "category": "Agent Workflow", + "installer": { + "type": "uv", + "pkg": "crewai" + }, + "launcher": { + "type": "tmux", + "cmd": "crewai", + "default_port": None + }, + "deps": [], + "description": "Framework for orchestrating role-playing autonomous AI agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'autogen': { + "name": "AutoGen", + "level": 10, + "layer": "Intelligent Routing", + "role": "Brain", + "category": "Agent Workflow", + "installer": { + "type": "uv", + "pkg": "pyautogen" + }, + "launcher": { + "type": "tmux", + "cmd": "autogen", + "default_port": None + }, + "deps": [], + "description": "Enable next-gen LLM applications with multiple conversable agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'openbrain': { + "name": "OpenBrain", + "level": 10, + "layer": "Intelligent Routing", + "role": "Brain", + "category": "Reasoning Engine", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/openbrain" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/openbrain && python3 -m openbrain serve --port {port}", + "default_port": 7100 + }, + "deps": [ + "ollama" + ], + "description": "Open-source reasoning and cognitive engine.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'mnemosyne': { + "name": "Mnemosyne", + "level": 10, + "layer": "Intelligent Routing", + "role": "Memory", + "category": "Spaced Repetition", + "installer": { + "type": "uv", + "pkg": "mnemosyne" + }, + "launcher": { + "type": "desktop", + "cmd": "mnemosyne", + "default_port": None + }, + "deps": [], + "description": "Spaced repetition flashcard program with AI integration.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + 'mnemo_cortex': { + "name": "Mnemo Cortex", + "level": 10, + "layer": "Intelligent Routing", + "role": "Memory", + "category": "Cortex Memory", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/mnemo-cortex" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/mnemo_cortex && python3 -m mnemo_cortex serve --port {port}", + "default_port": 7200 + }, + "deps": [ + "ollama" + ], + "description": "Hierarchical cortex memory for AI agents.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, +} diff --git a/src/ai_lsc/registry/layers/knowledge_management.py b/src/ai_lsc/registry/layers/knowledge_management.py new file mode 100644 index 0000000..17583b3 --- /dev/null +++ b/src/ai_lsc/registry/layers/knowledge_management.py @@ -0,0 +1,140 @@ +"""Registry entries for the Knowledge Management layer (L13). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'zotero': { + "name": "Zotero", + "level": 13, + "layer": "Knowledge Management", + "role": "Reference Manager", + "category": "Academic References", + "installer": { + "type": "pacman", + "pkg": "zotero" + }, + "launcher": { + "type": "desktop", + "cmd": "zotero", + "default_port": None + }, + "deps": [], + "description": "Free reference management for researchers.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + 'calibre': { + "name": "Calibre", + "level": 13, + "layer": "Knowledge Management", + "role": "Library Manager", + "category": "Ebook Library", + "installer": { + "type": "pacman", + "pkg": "calibre" + }, + "launcher": { + "type": "desktop", + "cmd": "calibre", + "default_port": None + }, + "deps": [], + "description": "E-book library management and converter.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + } +}, + 'paperlessngx': { + "name": "Paperless-ngx", + "level": 13, + "layer": "Knowledge Management", + "role": "Document Archive", + "category": "Document Management", + "installer": { + "type": "git", + "pkg": "https://github.com/paperless-ngx/paperless-ngx" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/paperlessngx && python3 manage.py runserver 0.0.0.0:{port}", + "default_port": 8000 + }, + "deps": [ + "postgresql", + "redis" + ], + "description": "Document management system with OCR.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'logseq': { + "name": "Logseq", + "level": 13, + "layer": "Knowledge Management", + "role": "Knowledge Graph", + "category": "Outliner", + "installer": { + "type": "npm", + "pkg": "logseq" + }, + "launcher": { + "type": "desktop", + "cmd": "logseq", + "default_port": None + }, + "deps": [], + "description": "Privacy-first knowledge graph outliner.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + 'joplin': { + "name": "Joplin", + "level": 13, + "layer": "Knowledge Management", + "role": "Note Taking", + "category": "Notes", + "installer": { + "type": "pacman", + "pkg": "joplin" + }, + "launcher": { + "type": "desktop", + "cmd": "joplin", + "default_port": None + }, + "deps": [], + "description": "Open-source note taking and to-do application.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + } +}, +} diff --git a/src/ai_lsc/registry/layers/observability.py b/src/ai_lsc/registry/layers/observability.py new file mode 100644 index 0000000..69ffcfb --- /dev/null +++ b/src/ai_lsc/registry/layers/observability.py @@ -0,0 +1,185 @@ +"""Registry entries for the Observability layer (L9). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'btop': { + "name": "Btop", + "level": 9, + "layer": "Observability", + "role": "Dashboard", + "category": "Metrics", + "installer": { + "type": "pacman", + "pkg": "btop" + }, + "launcher": { + "type": "desktop", + "cmd": "x-terminal-emulator -e btop", + "default_port": None + }, + "deps": [], + "description": "Resource monitor that shows usage and stats.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": False + } +}, + 'glances': { + "name": "Glances", + "level": 9, + "layer": "Observability", + "role": "Dashboard", + "category": "Metrics", + "installer": { + "type": "pacman", + "pkg": "glances" + }, + "launcher": { + "type": "tmux", + "cmd": "glances -w --port {port}", + "default_port": 61208 + }, + "deps": [], + "description": "Cross-platform system monitoring tool.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + 'prometheus': { + "name": "Prometheus", + "level": 9, + "layer": "Observability", + "role": "Metrics Collector", + "category": "Metrics", + "installer": { + "type": "pacman", + "pkg": "prometheus" + }, + "launcher": { + "type": "systemd", + "cmd": "prometheus", + "default_port": 9090 + }, + "deps": [], + "description": "Open-source monitoring and alerting toolkit.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'grafana': { + "name": "Grafana", + "level": 9, + "layer": "Observability", + "role": "Dashboard", + "category": "Visualization", + "installer": { + "type": "pacman", + "pkg": "grafana" + }, + "launcher": { + "type": "systemd", + "cmd": "grafana-server", + "default_port": 3000 + }, + "deps": [], + "description": "Multi-source observability dashboards and visualization.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + 'grafana_alloy': { + "name": "Grafana Alloy", + "level": 9, + "layer": "Observability", + "role": "Collector", + "category": "Telemetry", + "installer": { + "type": "script", + "cmd": "curl -fsSL https://raw.githubusercontent.com/grafana/alloy/main/install.sh | sh" + }, + "launcher": { + "type": "tmux", + "cmd": "alloy run --server.http.listen-port={port}", + "default_port": 12345 + }, + "deps": [ + "prometheus" + ], + "description": "OpenTelemetry collector with Prometheus integration.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'opik': { + "name": "Opik", + "level": 9, + "layer": "Observability", + "role": "LLM Tracing", + "category": "AI Observability", + "installer": { + "type": "uv", + "pkg": "opik" + }, + "launcher": { + "type": "tmux", + "cmd": "opik serve --port {port}", + "default_port": 3000 + }, + "deps": [], + "description": "Open-source LLM observability and tracing platform.", + "flags": { + "has_cli": True, + "has_gui": False, + "has_web": True + } +}, + 'wayland': { + "name": "wayland.ai", + "level": 9, + "layer": "Observability", + "role": "AI Compositor", + "category": "AI Desktop", + "installer": { + "type": "pacman", + "pkg": "wayland" + }, + "launcher": { + "type": "desktop", + "cmd": "weston", + "default_port": None + }, + "deps": [], + "description": "AI-native Wayland compositor for integrated AI desktop environments.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": False + } +}, +} diff --git a/src/ai_lsc/registry/layers/user_interfaces.py b/src/ai_lsc/registry/layers/user_interfaces.py new file mode 100644 index 0000000..b07e8e9 --- /dev/null +++ b/src/ai_lsc/registry/layers/user_interfaces.py @@ -0,0 +1,350 @@ +"""Registry entries for the User Interfaces layer (L11). + +Auto-extracted from the monolithic ``defaults.py``. Each entry +follows the standard registry schema: + +- ``name``: human-readable tool name +- ``level``: 13-layer taxonomy level (1-13) +- ``layer``: this layer name +- ``role``: role within the layer +- ``category``: functional category +- ``installer``: installation method +- ``launcher``: process launcher specification +- ``deps``: list of required tool IDs +- ``description``: short description +- ``flags``: optional boolean flags + +This module is consumed by +:mod:`ai_lsc.registry.loader`. +""" + +TOOLS: dict[str, dict] = { + 'hermes': { + "name": "Hermes", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Ecosystem Dashboard", + "installer": { + "type": "npm", + "pkg": "hermes-ai" + }, + "launcher": { + "type": "tmux", + "cmd": "hermes dashboard --port {port} --data-dir {workspaces_root}/hermes & hermes desktop --data-dir {workspaces_root}/hermes", + "default_port": 17050 + }, + "deps": [ + "ollama" + ], + "description": "Unified desktop and dashboard environment for the AI ecosystem.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + } +}, + 'openwebui': { + "name": "Open WebUI", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Chat Frontend", + "installer": { + "type": "uv", + "pkg": "open-webui" + }, + "launcher": { + "type": "tmux", + "cmd": "open-webui serve --port {port} --data-dir {workspaces_root}/openwebui", + "default_port": 8080 + }, + "deps": [ + "ollama" + ], + "description": "Extensible frontend for LLMs.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + 'anythingllm': { + "name": "AnythingLLM", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Chat", + "installer": { + "type": "git_node", + "pkg": "https://github.com/Mintplex-Labs/anything-llm.git" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/anythingllm && yarn dev", + "default_port": 3001 + }, + "deps": [], + "description": "Full-stack application for conversational AI.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + 'flowise': { + "name": "Flowise", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Workflow", + "installer": { + "type": "npm", + "pkg": "flowise" + }, + "launcher": { + "type": "tmux", + "cmd": "npx flowise start --port {port}", + "default_port": 3000 + }, + "deps": [], + "description": "Drag & drop UI to build customized LLM flows.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + 'dify': { + "name": "Dify", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Workflow", + "installer": { + "type": "git", + "pkg": "https://github.com/langgenius/dify.git" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/dify/docker && docker compose up", + "default_port": 80 + }, + "deps": [ + "postgresql", + "redis" + ], + "description": "LLM application development platform.", + "flags": { + "is_docker": True, + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + 'invokeai': { + "name": "InvokeAI", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Image Generation", + "installer": { + "type": "git", + "pkg": "https://github.com/invoke-ai/InvokeAI" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/invokeai && invokeai --host 0.0.0.0 --port {port}", + "default_port": 9090 + }, + "deps": [ + "cuda" + ], + "description": "Professional AI image generation workspace.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + } +}, + 'forge': { + "name": "Forge (A1111)", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Image Generation", + "installer": { + "type": "git", + "pkg": "https://github.com/AUTOMATIC1111/stable-diffusion-webui-forge" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/forge && python3 launch.py --port {port}", + "default_port": 7860 + }, + "deps": [ + "cuda" + ], + "description": "Stable Diffusion WebUI Forge (optimized fork).", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + 'dashy': { + "name": "Dashy", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Homepage", + "installer": { + "type": "npm", + "pkg": "dashy" + }, + "launcher": { + "type": "tmux", + "cmd": "dashy --port {port}", + "default_port": 3000 + }, + "deps": [], + "description": "Highly customizable dashboard and homepage.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + 'obsidian': { + "name": "Obsidian", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Knowledge Graph Notes", + "installer": { + "type": "pacman", + "pkg": "obsidian" + }, + "launcher": { + "type": "desktop", + "cmd": "obsidian", + "default_port": None + }, + "deps": [], + "description": "Knowledge graph note-taking and markdown editor.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + 'local_llm_launcher': { + "name": "Local LLM Launcher", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "LLM GUI", + "installer": { + "type": "git", + "pkg": "https://github.com/nicely-done/local-llm-launcher-gui" + }, + "launcher": { + "type": "desktop", + "cmd": "cd {tools_root}/local_llm_launcher && python3 main.py", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "GUI launcher and manager for local LLMs.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + 'hermes_desktop': { + "name": "Hermes Desktop", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Desktop Agent", + "installer": { + "type": "npm", + "pkg": "hermes-desktop" + }, + "launcher": { + "type": "desktop", + "cmd": "hermes desktop", + "default_port": None + }, + "deps": [ + "ollama" + ], + "description": "Hermes desktop agent environment.", + "flags": { + "has_cli": False, + "has_gui": True, + "has_web": False + } +}, + 'hermes_dashboard_page': { + "name": "Hermes Dashboard", + "level": 11, + "layer": "User Interfaces", + "role": "Face", + "category": "Dashboard", + "installer": { + "type": "npm", + "pkg": "hermes-dashboard" + }, + "launcher": { + "type": "tmux", + "cmd": "hermes dashboard --port {port}", + "default_port": 17050 + }, + "deps": [ + "ollama" + ], + "description": "Hermes ecosystem monitoring dashboard.", + "flags": { + "has_cli": False, + "has_gui": False, + "has_web": True + } +}, + "openjarvis": { + "name": "OpenJarvis", + "level": 11, + "layer": "User Interfaces", + "role": "Central Intelligence", + "category": "AI Assistant Platform", + "installer": { + "type": "git_node", + "pkg": "https://github.com/openjarvis/openjarvis.git", + "post_install": "npm install && npm run build" + }, + "launcher": { + "type": "tmux", + "cmd": "cd {tools_root}/openjarvis && npm start -- --port {port}", + "default_port": 17070 + }, + "deps": [ + "ollama", + "qdrant" + ], + "description": "Central AI assistant platform with multi-modal I/O, memory integration, agentic task execution, and unified dashboard. The brain of the intelligent stack.", + "flags": { + "has_cli": True, + "has_gui": True, + "has_web": True + }, + "filesystem": { + "install": "tools/openjarvis", + "config": "configs/openjarvis", + "data": "workspaces/openjarvis", + "cache": "cache/openjarvis", + "logs": "logs/openjarvis" + } +}, +} diff --git a/src/ai_lsc/registry/loader.py b/src/ai_lsc/registry/loader.py new file mode 100644 index 0000000..13a6007 --- /dev/null +++ b/src/ai_lsc/registry/loader.py @@ -0,0 +1,89 @@ +"""Registry loader -- discovers and merges per-layer registry modules. + +On startup the loader scans ``ai_lsc.registry.layers`` for every +``.py`` file that exports a ``TOOLS`` dict, and merges them into a +single unified registry dict. + +This replaces the monolithic ``DEFAULT_REGISTRY`` dict with a +zero-merge-conflict modular approach: each layer lives in its own +file and is independently editable. +""" + +from __future__ import annotations + +import importlib +import pkgutil +from pathlib import Path +from typing import Any + +_LAYERS_DIR = Path(__file__).resolve().parent / "layers" + + +def load_merged_registry() -> dict[str, dict[str, Any]]: + """Discover and merge all layer TOOLS dicts into one registry dict. + + Each layer module should export ``TOOLS: dict[str, dict]`` where + keys are tool IDs and values are the full metadata dicts. + + Returns the merged ``{tool_id: metadata}`` dictionary, with later + files overriding earlier ones on key collision (which should never + happen if layers are well-separated). + """ + merged: dict[str, dict[str, Any]] = {} + + # Import the layers package + try: + layers_pkg = importlib.import_module("ai_lsc.registry.layers") + except ImportError: + return merged + + for importer, modname, ispkg in pkgutil.iter_modules( + layers_pkg.__path__, prefix=layers_pkg.__name__ + "." + ): + if ispkg: + continue + try: + mod = importlib.import_module(modname) + except Exception: + continue + + tools = getattr(mod, "TOOLS", None) + + if isinstance(tools, dict): + # Dict format: {tool_id: metadata, ...} + _merge_tools_dict(merged, tools) + elif isinstance(tools, list): + # Legacy list format: [{...}, ...] + _merge_tools_list(merged, tools) + + return merged + + +def _merge_tools_dict( + target: dict[str, dict[str, Any]], + tools: dict[str, dict[str, Any]], +) -> None: + """Merge a ``TOOLS`` dict into the target registry dict.""" + for tool_id, entry in tools.items(): + if not isinstance(entry, dict): + continue + # Inject tool_id into entry if missing + if "tool_id" not in entry: + entry = {**entry, "tool_id": tool_id} + target[tool_id] = entry + + +def _merge_tools_list( + target: dict[str, dict[str, Any]], + tools: list[dict[str, Any]], +) -> None: + """Merge a legacy ``TOOLS`` list into the target registry dict.""" + for entry in tools: + if not isinstance(entry, dict): + continue + tool_id = entry.get("tool_id") + if tool_id is None: + name = entry.get("name", "") + tool_id = name.lower().replace(" ", "_").replace("/", "_") + entry["tool_id"] = tool_id + target[tool_id] = entry diff --git a/src/ai_lsc/registry/manager.py b/src/ai_lsc/registry/manager.py new file mode 100644 index 0000000..686ccd3 --- /dev/null +++ b/src/ai_lsc/registry/manager.py @@ -0,0 +1,90 @@ +""" +AI-LSC — Registry manager. + +Load / merge / query the on-disk ecosystem registry. On first run the +default registry is written to ``/registry/ecosystem.json``; +on subsequent runs any *new* keys from ``DEFAULT_REGISTRY`` are merged +in so the registry auto-evolves across releases without user action. + +All path operations use ``pathlib.Path`` instead of ``os.path``. +""" + +from __future__ import annotations + +import json +from itertools import chain +from pathlib import Path +from typing import Any + +from ai_lsc.registry.defaults import DEFAULT_REGISTRY + + +class RegistryManager: + """Knowledge-graph engine backed by a JSON file on disk. + + Parameters + ---------- + registry_dir: + Absolute path to the directory containing ``ecosystem.json``. + """ + + def __init__(self, registry_dir: str | Path) -> None: + self.registry_dir = Path(registry_dir) + self.registry_file: Path = self.registry_dir / "ecosystem.json" + self.data: dict[str, dict[str, Any]] = {} + self._bootstrap() + + # ── Bootstrap / merge ──────────────────────────────────────────── + + def _bootstrap(self) -> None: + self.registry_dir.mkdir(parents=True, exist_ok=True) + if not self.registry_file.exists(): + self.registry_file.write_text( + json.dumps(DEFAULT_REGISTRY, indent=4), encoding="utf-8" + ) + self.data = json.loads( + self.registry_file.read_text(encoding="utf-8") + ) + self._merge_with_defaults() + + def _merge_with_defaults(self) -> None: + new_keys = { + k: v for k, v in DEFAULT_REGISTRY.items() + if k not in self.data + } + if not new_keys: + return + self.data.update(new_keys) + self.registry_file.write_text( + json.dumps(self.data, indent=4), encoding="utf-8" + ) + + # ── Queries ────────────────────────────────────────────────────── + + def get_all_tools(self) -> dict[str, dict[str, Any]]: + """Return the full registry dict.""" + return self.data + + def get_tool(self, tool_id: str) -> dict[str, Any]: + """Return a single tool's raw dict, or ``{}`` if unknown.""" + return self.data.get(tool_id, {}) + + def get_grouped_by_layer(self) -> dict[str, list[tuple[str, dict]]]: + """Return tools grouped and sorted by their ``layer`` field.""" + layers: dict[str, list[tuple[str, dict]]] = {} + for t_id, meta in self.data.items(): + layers.setdefault( + meta.get("layer", "Uncategorized"), [] + ).append((t_id, meta)) + return dict(sorted(layers.items())) + + def check_dependencies( + self, selected: list[str], + ) -> list[str]: + """Return tool IDs that are required but missing from *selected*.""" + all_deps = list(chain.from_iterable( + self.get_tool(t).get("deps", []) + for t in selected + if not t.startswith("skill:") + )) + return list({d for d in all_deps if d not in selected}) diff --git a/src/ai_lsc/registry/stack_templates/__init__.py b/src/ai_lsc/registry/stack_templates/__init__.py new file mode 100644 index 0000000..10d5166 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/__init__.py @@ -0,0 +1,8 @@ +"""AI-LSC stack templates sub-package. + +Pre-configured tool stacks that can be applied via the StackWizard +instead of manually selecting individual tools. + +Each template is a JSON file in this directory defining a curated set +of tools (by registry ID or git source URL) for a specific use case. +""" diff --git a/src/ai_lsc/registry/stack_templates/agentic-os-stack.json b/src/ai_lsc/registry/stack_templates/agentic-os-stack.json new file mode 100644 index 0000000..77b9ded --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/agentic-os-stack.json @@ -0,0 +1,33 @@ +{ + "id": "agentic-os-stack", + "name": "Agentic OS — Full Orchestration Stack", + "description": "Production agentic orchestration stack: Ollama inference + LibreChat agent frontend + Qdrant vector memory + Redis task queue + LiteLLM multi-provider routing. All LLM endpoints point to localhost — no external API keys needed.", + "version": "2.0", + "author": "ai-lsc", + "tags": ["agentic", "orchestration", "function-calling", "rag", "multi-model", "production", "local-first"], + "endpoints": { + "litellm_base": "http://localhost:4000", + "litellm_model": "ollama/qwen2.5:32b", + "librechat_endpoint": "http://localhost:3080", + "qdrant_endpoint": "http://localhost:6333", + "redis_endpoint": "localhost:6379" + }, + "tools": [ + "ollama", + "redis", + "qdrant", + "litellm", + "n8n", + "librechat", + "mariadb", + "glances", + "fabric" + ], + "notes": { + "architecture": "The agents/ package bridges LibreChat's function-calling to AI-LSC's RuntimeExecutor. The LLM can start/stop services, pull models, and inject skills through natural language.", + "model_routing": "LiteLLM proxy at localhost:4000 normalizes all local Ollama models into a single OpenAI-compatible endpoint. Ollama serves at localhost:11434. LiteLLM config points: model_list: [{model_name: 'qwen2.5', litellm_params: {model: 'ollama/qwen2.5:32b', api_base: 'http://localhost:11434/v1'}}].", + "memory_layers": "Redis (localhost:6379) = hot path (task queue, pub/sub, status cache). MariaDB = cold path (audit logs, persisted memory, config). Qdrant (localhost:6333) = semantic index (RAG, skill matching, vector search).", + "agent_frontend": "LibreChat at localhost:3080 provides multi-provider support with native OpenAI tool-calling format. Configure it to point at LiteLLM as the assistant endpoint.", + "workflow": "n8n at localhost:5678 orchestrates complex multi-step agent workflows beyond single-turn tool calls." + } +} diff --git a/src/ai_lsc/registry/stack_templates/ai-image-gen-local.json b/src/ai_lsc/registry/stack_templates/ai-image-gen-local.json new file mode 100644 index 0000000..951d762 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/ai-image-gen-local.json @@ -0,0 +1,24 @@ +{ + "id": "ai-image-gen-local", + "name": "AI Image Generation — Local Creative Studio", + "description": "Run Stable Diffusion, FLUX, and ComfyUI entirely on your GPU. No DALL-E subscriptions, no Midjourney fees. Generate images, edit photos, and build automated generation pipelines — the homelab creative AI stack that's exploding on YouTube.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["image-gen", "stable-diffusion", "flux", "comfyui", "creative", "youtube-trending", "local-first", "gpu"], + "endpoints": { + "comfyui": "http://localhost:8188", + "forge": "http://localhost:7860" + }, + "tools": [ + "forge", + "invokeai", + "fabric" + ], + "notes": { + "youtube_context": "Local AI image generation has massive YouTube presence. Channels like @mreflow, @aaronweikle, and @flyingjunior run FLUX.1 and SDXL locally. ComfyUI tutorials consistently hit 1M+ views. This stack covers the three most popular workflows.", + "recommended_models": "FLUX.1-dev (12B, best quality), FLUX.1-schnell (fast generation), stable-diffusion-xl-base-1.0 (classic SDXL), juggernaut-xl (fine-tuned SDXL), dreamshaper-xl (lightweight)", + "setup": "Forge (optimized WebUI) at localhost:7860 is the easiest entry point. InvokeAI provides a more polished creative workflow. Both auto-download models on first run. Set --listen 0.0.0.0 to access from other devices.", + "workflow": "Quick gen: Forge with FLUX.1-schnell (~2s per image on 4090). Quality gen: FLUX.1-dev with refined prompts. Batch processing: Forge API + Fabric for automated pipeline workflows. ControlNet for pose/depth/edge-guided generation.", + "tips": "8GB VRAM minimum for FLUX.1-schnell (FP8). 24GB for FLUX.1-dev. Use xformers and sdp attention for speed. Torch compile adds ~30% throughput after first warmup. Batch size 1 with tiled VAE for large resolutions on limited VRAM." + } +} diff --git a/src/ai_lsc/registry/stack_templates/aider-ollama-vibe-coding.json b/src/ai_lsc/registry/stack_templates/aider-ollama-vibe-coding.json new file mode 100644 index 0000000..25546f8 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/aider-ollama-vibe-coding.json @@ -0,0 +1,27 @@ +{ + "id": "aider-ollama-vibe-coding", + "name": "Aider + Ollama Vibe Coding Stack", + "description": "The #1 YouTube AI coding stack of 2025. Aider pair-programmer wired to local Ollama models. Zero API keys, zero cloud — vibe code entirely offline with 32B+ models that rival GPT-4 for coding tasks.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["vibe-coding", "aider", "ollama", "coding", "youtube-trending", "local-first", "offline"], + "endpoints": { + "ollama_base": "http://localhost:11434/v1", + "aider_model": "ollama/qwen2.5-coder:32b" + }, + "tools": [ + "ollama", + "aider", + "fabric", + "ripgrep", + "fd", + "tree_sitter" + ], + "notes": { + "youtube_context": "This is the exact stack from the viral 'Vibe Coding with Local AI' videos: Aider + Ollama + Qwen2.5-Coder. Creators like @networkchuck, @cbarks, and @techwithtim have built full projects live on stream with this combo.", + "recommended_models": "qwen2.5-coder:32b (best balance), deepseek-coder-v2:236b (strongest), codestral:22b (fast), llama3.1:70b (general), phi-4:14b (lightweight)", + "setup": "Run: ollama pull qwen2.5-coder:32b && aider --model ollama/qwen2.5-coder:32b. Aider auto-discovers Ollama on localhost:11434.", + "workflow": "Aider edits code in your git repo using local LLM. Fabric transforms text/prompts. ripgrep + fd + tree-sitter power Aider's repository map for large codebase awareness.", + "tips": "Use aider --message 'implement X' for single tasks. Use aider --chat for interactive sessions. Add .aider.conf.yml to your project root for per-project model config." + } +} diff --git a/src/ai_lsc/registry/stack_templates/claude-code-setup.json b/src/ai_lsc/registry/stack_templates/claude-code-setup.json new file mode 100644 index 0000000..4231723 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/claude-code-setup.json @@ -0,0 +1,46 @@ +{ + "id": "claude-code-setup", + "name": "Claude Code Local Stack", + "description": "Claude Code development environment wired to local Ollama inference. No cloud API keys required — all LLM calls route through localhost:11434. Includes memory, prompt engineering, and multi-agent coordination.", + "version": "2.0", + "author": "ai-lsc", + "tags": ["claude", "development", "ai-coding", "agent", "memory", "local-first"], + "endpoints": { + "claude_api_base": "http://localhost:11434/v1", + "anthropic_api_key": "ollama", + "anthropic_model": "claude-4-sonnet" + }, + "tools": [ + "claude_code", + "ollama", + "aider", + "fabric", + { + "id": "claude_mem", + "name": "Claude Mem", + "source": "https://github.com/nicely-done/claude-mem", + "category": "Memory", + "role": "Context Memory", + "description": "Persistent conversation memory layer for Claude Code sessions", + "installer": {"type": "git", "pkg": "https://github.com/nicely-done/claude-mem"}, + "launcher": {"type": "tmux", "cmd": "claude-mem serve --port {port}", "default_port": 9600}, + "flags": {"has_cli": true, "has_gui": false, "has_web": true} + }, + { + "id": "claude_squad", + "name": "Claude Squad", + "source": "https://github.com/nicely-done/claude-squad", + "category": "Multi-Agent", + "role": "Team Coordination", + "description": "Multi-agent team orchestration for parallel Claude Code instances", + "installer": {"type": "git", "pkg": "https://github.com/nicely-done/claude-squad"}, + "launcher": {"type": "tmux", "cmd": "claude-squad coordinate --port {port}", "default_port": 9603}, + "flags": {"has_cli": true, "has_gui": false, "has_web": true} + } + ], + "notes": { + "setup": "Set CLAUDE_CODE_USE_BEDROCK=1 or configure claude-code to point at the Ollama endpoint. Anthropic Claude models are proxied through LiteLLM or Ollama's OpenAI-compatible API at localhost:11434.", + "recommended_models": "claude-4-sonnet (14B+ local), qwen2.5-coder:32b, deepseek-coder-v2:236b", + "workflow": "Claude Code → Ollama (localhost:11434) → local weights. Aider handles pair programming with the same endpoint. Fabric provides CLI text-processing pipelines." + } +} diff --git a/src/ai_lsc/registry/stack_templates/deepseek-r1-local-reasoning.json b/src/ai_lsc/registry/stack_templates/deepseek-r1-local-reasoning.json new file mode 100644 index 0000000..84f0df0 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/deepseek-r1-local-reasoning.json @@ -0,0 +1,29 @@ +{ + "id": "deepseek-r1-local-reasoning", + "name": "DeepSeek R1 — Local Reasoning Engine", + "description": "Run DeepSeek R1 (the open-source reasoning model that challenged OpenAI o1) entirely locally. vLLM or llama.cpp serves the 70B distilled model with chain-of-thought reasoning visible in real-time. The most hyped local AI setup of early 2025.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["deepseek", "reasoning", "r1", "vllm", "llama.cpp", "youtube-trending", "local-first", "math", "coding"], + "endpoints": { + "ollama_base": "http://localhost:11434", + "vllm_base": "http://localhost:8000", + "litellm_base": "http://localhost:4000" + }, + "tools": [ + "ollama", + "llamacpp", + "vllm", + "litellm", + "openwebui", + "aider", + "fabric" + ], + "notes": { + "youtube_context": "DeepSeek R1 broke the internet in Jan 2025 with reasoning capabilities matching OpenAI o1 at a fraction of the cost. YouTubers like @fmateo09, @aaronweikle, and @marcusrbrown showed how to run the distilled 32B/70B models locally for free. This template recreates that exact setup.", + "recommended_models": "deepseek-r1:32b (24GB VRAM via vLLM), deepseek-r1:70b (2x 24GB GPUs or quantized), deepseek-r1-distill-qwen:32b (fastest reasoning), deepseek-coder-v2:236b (split across GPUs)", + "setup": "Fast path: ollama pull deepseek-r1:32b && open-webui (auto-connects). High-throughput: vLLM serves with speculative decoding at localhost:8000. LiteLLM at 4000 normalizes the endpoint for any OpenAI-compatible client.", + "workflow": "Reasoning queries hit the model through Open WebUI or any API client. The chain-of-thought (thinking tokens) is visible in real-time. Aider uses the same endpoint for reasoning-powered code generation. Fabric pipes reasoning output through text-transform chains.", + "tips": "vLLM with --enable-chunked-prefill handles long contexts better. For <24GB VRAM, use deepseek-r1:14b or the distilled Qwen variants. Set temperature=0.6 for best reasoning quality — higher temps degrade chain-of-thought coherence." + } +} diff --git a/src/ai_lsc/registry/stack_templates/hermes-ai-coder-stack.json b/src/ai_lsc/registry/stack_templates/hermes-ai-coder-stack.json new file mode 100644 index 0000000..a7513fe --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/hermes-ai-coder-stack.json @@ -0,0 +1,41 @@ +{ + "id": "hermes-ai-coder-stack", + "name": "Hermes — AI Coder & Agent Stack", + "description": "Maximum coding intelligence: Aider pair programming + Hermes agent orchestration + Agno multi-agent framework + CrewAI team collaboration + Ollama local inference + OpenWebUI chat frontend + full codebase awareness tools. The everything-stack for serious AI-assisted development.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["hermes", "coding", "agent", "multi-agent", "agno", "crewai", "aider", "premium", "local-first"], + "endpoints": { + "ollama_base": "http://localhost:11434/v1", + "litellm_base": "http://localhost:4000", + "openwebui": "http://localhost:3000", + "hermes_agent": "http://localhost:17051", + "hermes_dashboard": "http://localhost:17050" + }, + "tools": [ + "ollama", + "aider", + "litellm", + "openwebui", + "hermes", + "hermes_agent", + "hermes_desktop", + "agno", + "crewai", + "autogen", + "fabric", + "chromadb", + "ripgrep", + "fd", + "tree_sitter" + ], + "notes": { + "philosophy": "Hermes is the messenger of the gods — this stack routes every coding task through the best available local AI pathway. Aider for pair programming, Agno for structured agent pipelines, CrewAI for team-based task decomposition, Hermes Desktop for the unified agent GUI, and Hermes Agent for the autonomous runtime. LiteLLM normalizes all model access so every tool talks to Ollama on localhost:11434.", + "recommended_models": "qwen2.5-coder:32b (primary coding, beats GPT-4 on SWE-bench), deepseek-coder-v2:236b (complex reasoning), codestral:22b (fast edits), llama3.1:70b (architectural planning), phi-4:14b (quick tasks), nomic-embed-text (codebase embeddings for RAG)", + "coding_interface": "Aider is the primary coding interface — it has the best git integration, whole-repo awareness, and cost-efficient token usage with local models. For interactive exploration, Hermes Desktop provides a visual agent environment. For conversational coding, OpenWebUI connects to the same Ollama endpoint.", + "agentic_hierarchy": "Single tasks → Aider (fast, direct). Multi-step pipelines → Agno (sequential agents with memory). Team projects → CrewAI (role-based: Architect, Coder, Reviewer, Tester). Full autonomy → Hermes Agent (persistent background agent with tool access). Cross-framework orchestration → AutoGen (Microsoft's framework for heterogeneous agent teams).", + "codebase_awareness": "rigrep finds symbols, fd navigates directories, tree-sitter parses AST. Aider uses repo-map to build a compressed representation of your entire codebase. ChromaDB indexes code chunks for semantic search. This gives every agent in the stack full awareness of your project structure.", + "setup": "1) Pull models: ollama pull qwen2.5-coder:32b && ollama pull nomic-embed-text. 2) Start Ollama (systemd or tmux). 3) Start LiteLLM pointing at localhost:11434. 4) Launch Hermes Desktop for the GUI agent. 5) Run aider in your project repo. 6) OpenWebUI for browser-based chat with your code.", + "tips": "Use LiteLLM to switch between models per-task without restarting tools. Aider's /ask command lets you query the model without editing files — perfect for quick questions. Agno agents can call Aider as a tool for code modifications. ChromaDB codebase indexing runs once, then every agent benefits from semantic search. Keep Hermes Dashboard open on a second monitor to monitor all agent activity." + } +} diff --git a/src/ai_lsc/registry/stack_templates/local-llm-lab.json b/src/ai_lsc/registry/stack_templates/local-llm-lab.json new file mode 100644 index 0000000..a60cb47 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/local-llm-lab.json @@ -0,0 +1,31 @@ +{ + "id": "local-llm-lab", + "name": "Local LLM Lab", + "description": "Self-hosted LLM playground with multiple inference backends, model management, vector store, and chat interface. Everything runs on localhost — zero cloud dependencies.", + "version": "2.0", + "author": "ai-lsc", + "tags": ["llm", "local-ai", "inference", "chat", "rag", "local-first"], + "endpoints": { + "ollama_base": "http://localhost:11434", + "openwebui": "http://localhost:3000", + "chromadb": "http://localhost:8000", + "litellm_base": "http://localhost:4000" + }, + "tools": [ + "ollama", + "llamacpp", + "vllm", + "litellm", + "openwebui", + "chromadb", + "whisper", + "docling", + "aider", + "fabric" + ], + "notes": { + "setup": "Ollama at 11434 is the primary inference engine. vLLM and llama.cpp are alternative backends for GGUF/exl2 formats. LiteLLM at 4000 provides a unified OpenAI-compatible API over all backends.", + "recommended_models": "llama3.1:70b, qwen2.5-coder:32b, mistral-nemo:12b, codestral:22b, phi-4:14b, gemma2:27b", + "workflow": "OpenWebUI provides the browser chat frontend at localhost:3000 connected to Ollama. ChromaDB handles RAG document embeddings. Whisper does local speech-to-text. Docling converts PDFs to Markdown for ingestion. Aider connects to the same Ollama endpoint for pair programming. Fabric provides CLI text-transform pipelines." + } +} diff --git a/src/ai_lsc/registry/stack_templates/manager.py b/src/ai_lsc/registry/stack_templates/manager.py new file mode 100644 index 0000000..228ba44 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/manager.py @@ -0,0 +1,263 @@ +"""Stack template manager -- loads, lists, and resolves templates. + +Templates are JSON files in ``ai_lsc/registry/stack_templates/``. Each +template defines: + +* ``name``: human-readable template name +* ``description``: one-line summary +* ``tags``: searchable category labels +* ``version``: template version string +* ``tools``: list of tool references (registry IDs or git-source dicts) + +The manager can resolve a template into a flat list of tool IDs by +merging registry lookups with git-source entries (which are auto-registered +as new tools on the fly). +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +_TEMPLATES_DIR = Path(__file__).resolve().parent + + +class StackTemplateManager: + """Discover and resolve stack templates. + + Parameters + ---------- + extra_dirs : + Additional directories to scan for template files + (e.g. user-supplied ``~/.config/ai-lsc/stack_templates/``). + """ + + def __init__(self, extra_dirs: list[str | Path] | None = None) -> None: + self._templates: dict[str, dict[str, Any]] = {} + self._scan_dirs = [_TEMPLATES_DIR] + if extra_dirs: + self._scan_dirs.extend( + Path(d) for d in extra_dirs if Path(d).is_dir() + ) + self._load_all() + + # ── Discovery ──────────────────────────────────────────────────── + + def _load_all(self) -> None: + """Scan all template directories and load valid templates.""" + for directory in self._scan_dirs: + for fname in sorted(directory.iterdir()): + if fname.suffix in (".json", ".yaml", ".yml"): + try: + tpl = self._load_file(fname) + except Exception: + continue + if tpl: + self._templates[tpl["id"]] = tpl + + @staticmethod + def _load_file(path: Path) -> dict[str, Any] | None: + """Load and validate a single template file.""" + suffix = path.suffix.lower() + + if suffix == ".json": + raw = json.loads(path.read_text(encoding="utf-8")) + else: + # YAML support -- optional dependency + try: + import yaml # noqa: F401 + except ImportError: + return None + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + + if not isinstance(raw, dict): + return None + if "name" not in raw or "tools" not in raw: + return None + + # Synthesise a stable ID from the filename if not given + raw.setdefault("id", path.stem) + raw.setdefault("tags", []) + raw.setdefault("version", "1.0") + raw.setdefault("description", "") + raw.setdefault("author", "ai-lsc") + return raw + + # ── Queries ────────────────────────────────────────────────────── + + def list_templates(self) -> list[dict[str, Any]]: + """Return all loaded templates as summary dicts.""" + return [ + { + "id": tpl["id"], + "name": tpl["name"], + "description": tpl.get("description", ""), + "tags": tpl.get("tags", []), + "version": tpl.get("version", "1.0"), + "tool_count": len(tpl.get("tools", [])), + "source": "builtin" if self._is_builtin(tpl["id"]) else "custom", + } + for tpl in sorted( + self._templates.values(), key=lambda t: t["name"] + ) + ] + + def get_template(self, template_id: str) -> dict[str, Any] | None: + """Return the full template dict, or ``None``.""" + return self._templates.get(template_id) + + def _is_builtin(self, template_id: str) -> bool: + return any( + (d / f"{template_id}.json").exists() + or (d / f"{template_id}.yaml").exists() + or (d / f"{template_id}.yml").exists() + for d in self._scan_dirs + ) + + def filter_by_tag(self, tag: str) -> list[dict[str, Any]]: + """Return templates matching a given tag.""" + return [ + t for t in self.list_templates() + if tag.lower() in [x.lower() for x in t["tags"]] + ] + + # ── Resolution ──────────────────────────────────────────────────── + + def resolve_tool_ids( + self, + template_id: str, + registry: object | None = None, + ) -> tuple[list[str], list[dict[str, Any]]]: + """Resolve a template into registry tool IDs + new-tool entries. + + Parameters + ---------- + template_id : + The template to resolve. + registry : + Optional ``RegistryManager`` used to validate existing + tool IDs and look up dependency chains. + + Returns + ------- + (known_ids, new_entries) : + *known_ids* are tool IDs already in the registry. + *new_entries* are raw dicts for tools that need to be + auto-registered (git-source entries). + + Example + ------- + >>> ids, new = mgr.resolve_tool_ids("claude-code-setup", registry) + >>> # ids = ["claude_code", "ollama", "aider", ...] + >>> # new = [{"name": "Godmod3", "source": "https://...", ...}] + """ + tpl = self._templates.get(template_id) + if not tpl: + return [], [] + + known_ids: list[str] = [] + new_entries: list[dict[str, Any]] = [] + + for tool_ref in tpl.get("tools", []): + if isinstance(tool_ref, str): + # Plain registry ID reference + known_ids.append(tool_ref) + elif isinstance(tool_ref, dict): + # Structured reference + if "id" in tool_ref: + known_ids.append(tool_ref["id"]) + elif "source" in tool_ref: + # Git-source: new tool to auto-register + new_entries.append(tool_ref) + # Synthesise an ID from the source URL + known_ids.append(tool_ref.get( + "id", + _derive_id_from_source(tool_ref["source"]), + )) + + # Deduplicate while preserving order + seen: set[str] = set() + deduped_ids: list[str] = [] + for tid in known_ids: + if tid not in seen: + seen.add(tid) + deduped_ids.append(tid) + + return deduped_ids, new_entries + + # ── Creation ───────────────────────────────────────────────────── + + def create_template( + self, + name: str, + tools: list[str | dict[str, Any]], + description: str = "", + tags: list[str] | None = None, + template_id: str | None = None, + save_dir: str | Path | None = None, + ) -> dict[str, Any]: + """Create a new template and optionally save it to disk. + + Parameters + ---------- + name : + Human-readable template name. + tools : + List of tool IDs (str) or git-source dicts. + description : + One-line description. + tags : + Category labels for searchability. + template_id : + Override the auto-derived ID (defaults to slugified name). + save_dir : + Directory to write the JSON file. If ``None`` the + template is only kept in memory. + + Returns + ------- + The created template dict. + """ + tpl: dict[str, Any] = { + "id": template_id or name.lower().replace(" ", "-").replace("_", "-"), + "name": name, + "description": description, + "tags": tags or [], + "version": "1.0", + "author": "user", + "tools": tools, + } + + self._templates[tpl["id"]] = tpl + + if save_dir: + out = Path(save_dir) + out.mkdir(parents=True, exist_ok=True) + (out / f"{tpl['id']}.json").write_text( + json.dumps(tpl, indent=4, ensure_ascii=False), + encoding="utf-8", + ) + + return tpl + + def delete_template(self, template_id: str) -> bool: + """Remove a template (memory only, does not delete files).""" + return self._templates.pop(template_id, None) is not None + + +def _derive_id_from_source(source: str) -> str: + """Derive a tool ID from a git URL. + + Examples + -------- + >>> _derive_id_from_source("https://github.com/user/my-tool") + 'my_tool' + >>> _derive_id_from_source("https://github.com/user/my-tool.git") + 'my_tool' + """ + # Strip trailing .git and get last path segment + url = source.rstrip("/").removesuffix(".git") + slug = url.rsplit("/", 1)[-1].lower() + return slug.replace("-", "_") diff --git a/src/ai_lsc/registry/stack_templates/multi-agent-crewai-local.json b/src/ai_lsc/registry/stack_templates/multi-agent-crewai-local.json new file mode 100644 index 0000000..af0a8c4 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/multi-agent-crewai-local.json @@ -0,0 +1,28 @@ +{ + "id": "multi-agent-crewai-local", + "name": "Multi-Agent CrewAI — Local Team Stack", + "description": "Build AI agent teams that collaborate on complex tasks, all running on local models. CrewAI orchestrates specialized agents (researcher, coder, reviewer) powered by Ollama. The exact stack from 'Building an AI Company with Local Models' YouTube series.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["multi-agent", "crewai", "autogen", "agent-team", "youtube-trending", "local-first", "automation"], + "endpoints": { + "ollama_base": "http://localhost:11434/v1", + "litellm_base": "http://localhost:4000" + }, + "tools": [ + "ollama", + "litellm", + "crewai", + "autogen", + "chromadb", + "fabric", + "n8n" + ], + "notes": { + "youtube_context": "Multi-agent AI teams are the #2 trending AI coding topic. Videos by @aiaborde, @promptengineering, and @johnhampton010 show agents delegating tasks to each other. CrewAI is the most beginner-friendly framework for this — define roles, give them tools, and watch them collaborate.", + "recommended_models": "qwen2.5:32b (agent reasoning), llama3.1:70b (complex tasks), codestral:22b (coding agents), mistral-nemo:12b (lightweight agents), nomic-embed-text (agent memory embeddings)", + "setup": "pip install crewai crewai-tools. Set OPENAI_API_BASE=http://localhost:11434/v1 and OPENAI_API_KEY=ollama. LiteLLM at 4000 normalizes if you mix Ollama with other backends.", + "workflow": "Define a Crew with Agent roles (Researcher, Coder, Reviewer). Each agent gets tools (web search, file read/write, code execution). Ollama serves all LLM calls locally. ChromaDB stores agent memory between sessions. n8n can trigger crews from external events (webhooks, schedules, git pushes).", + "tips": "Use sequential process for step-by-step tasks. Use hierarchical process for complex planning (manager agent delegates). Keep agent backstories short and role-focused — verbose context wastes tokens on local models. Embedding quality matters more than model size for agent memory tasks." + } +} diff --git a/src/ai_lsc/registry/stack_templates/n8n-ai-workflow-automation.json b/src/ai_lsc/registry/stack_templates/n8n-ai-workflow-automation.json new file mode 100644 index 0000000..5e7f558 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/n8n-ai-workflow-automation.json @@ -0,0 +1,30 @@ +{ + "id": "n8n-ai-workflow-automation", + "name": "n8n AI Workflow Automation Hub", + "description": "Visual AI workflow automation with n8n at the center. Connect local Ollama models to webhooks, email, databases, and scheduling. Build AI-powered automations without writing code — the exact setup from the viral 'Automate Everything with Local AI' videos.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["n8n", "workflow", "automation", "no-code", "youtube-trending", "local-first", "integration"], + "endpoints": { + "n8n": "http://localhost:5678", + "ollama_base": "http://localhost:11434/v1", + "redis": "localhost:6379", + "postgresql": "localhost:5432" + }, + "tools": [ + "ollama", + "litellm", + "n8n", + "redis", + "postgresql", + "fabric", + "whisper" + ], + "notes": { + "youtube_context": "n8n + local AI is the most viewed AI automation content on YouTube. Channels like @n8n_io (official), @techwithtim, and @lainzworld show workflows like: auto-summarize emails with local LLM, transcribe meetings with Whisper, classify support tickets with Ollama, and generate reports on schedule.", + "recommended_models": "llama3.1:8b (classification, fast), qwen2.5:14b (summarization), mistral-nemo:12b (general), phi-4:14b (structured output), nomic-embed-text (semantic search)", + "setup": "n8n runs at localhost:5678 with PostgreSQL for persistence and Redis for queue management. Add the Ollama node (built-in) pointing at localhost:11434. LiteLLM at 4000 provides fallback model routing. n8n's AI Agent node chains multiple LLM calls together in a visual flow.", + "workflow": "Trigger (webhook/cron/email) → n8n AI Agent → Ollama (localhost:11434) → process result → action (email/slack/database write). Whisper node for audio. Fabric node for text transforms. Multiple agents can collaborate in a single workflow.", + "tips": "Use n8n's sub-workflow feature to reuse AI processing across multiple automations. Set Ollama keep_alive=5m in n8n config to avoid cold starts between workflow triggers. The AI Agent node's memory feature persists conversation context across workflow runs using Redis." + } +} diff --git a/src/ai_lsc/registry/stack_templates/open-webui-full-rag.json b/src/ai_lsc/registry/stack_templates/open-webui-full-rag.json new file mode 100644 index 0000000..615b0e3 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/open-webui-full-rag.json @@ -0,0 +1,28 @@ +{ + "id": "open-webui-full-rag", + "name": "Open WebUI — Full RAG Knowledge Stack", + "description": "The most popular self-hosted ChatGPT replacement. Open WebUI + Ollama + ChromaDB + Whisper for voice input + Docling for document ingestion. The stack thousands of homelabbers and YouTubers run for private AI chat with full document understanding.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["open-webui", "chatgpt-alternative", "rag", "homelab", "youtube-trending", "document-ai", "local-first"], + "endpoints": { + "ollama_base": "http://localhost:11434", + "openwebui": "http://localhost:3000", + "chromadb": "http://localhost:8000" + }, + "tools": [ + "ollama", + "openwebui", + "chromadb", + "whisper", + "docling", + "markitdown" + ], + "notes": { + "youtube_context": "The #1 homelab AI setup across YouTube. Every self-hosted AI tutorial covers this exact stack. Channels like @techhut, @crosstalksolutions, and @networkchuck have dedicated videos with 500K+ views on this combo.", + "recommended_models": "llama3.1:8b (fast chat), llama3.1:70b (quality), qwen2.5:32b (multilingual), mistral-nemo:12b (lightweight), nomic-embed-text (embeddings)", + "setup": "Open WebUI auto-detects Ollama on localhost:11434. Upload PDFs/docs in the UI — they get chunked and embedded into ChromaDB automatically. Whisper enables the microphone button for voice-to-text input.", + "workflow": "Documents → Docling/MarkItDown → Markdown → Open WebUI RAG pipeline → ChromaDB vector store. Whisper handles voice queries. All inference through Ollama on local GPU.", + "tips": "Set OLLAMA_NUM_PARALLEL=4 for concurrent chat requests. Use Open WebUI's built-in model switching to route simple queries to 8B and complex ones to 70B. Create workspaces per project for isolated document collections." + } +} diff --git a/src/ai_lsc/registry/stack_templates/openhands-autonomous-coder.json b/src/ai_lsc/registry/stack_templates/openhands-autonomous-coder.json new file mode 100644 index 0000000..09c6972 --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/openhands-autonomous-coder.json @@ -0,0 +1,30 @@ +{ + "id": "openhands-autonomous-coder", + "name": "OpenHands — Autonomous AI Software Engineer", + "description": "Run the OpenHands autonomous coding agent entirely locally. Sandboxed code execution + terminal access + file management + web browsing + Ollama inference. The most popular open-source autonomous software engineer on GitHub.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["openhands", "autonomous", "coding-agent", "sandbox", "youtube-trending", "local-first", "terminal"], + "endpoints": { + "ollama_base": "http://localhost:11434/v1", + "litellm_base": "http://localhost:4000", + "openhands": "http://localhost:3000" + }, + "tools": [ + "openhands", + "ollama", + "litellm", + "fabric", + "ripgrep", + "fd", + "tree_sitter" + ], + "notes": { + "youtube_context": "The autonomous coding agent concept went viral when open-source alternatives appeared. OpenHands, maintained by All-Hands-AI, is the most starred and actively developed project in this space. Channels like @NicholasRenotte, @aiaborde, and @codingwithadam have full build-along videos with 200K+ views.", + "about_openhands": "OpenHands is an autonomous AI software engineer that can plan, write, debug, and execute code in sandboxed Docker environments. It has full terminal access, file management, web browsing capability, and supports any LLM backend. GitHub: https://github.com/All-Hands-AI/OpenHands — 40K+ stars.", + "recommended_models": "qwen2.5-coder:32b (primary coding, best SWE-bench), deepseek-coder-v2:236b (complex reasoning), codestral:22b (fast edits), llama3.1:70b (architectural planning), claude-4-sonnet (if available via proxy)", + "setup": "1) OpenHands installs via git clone + pip. 2) Configure LLM backend in config.yaml to point at Ollama (localhost:11434) or LiteLLM (localhost:4000). 3) Start the server: python -m openhands.server. 4) Open the web UI at localhost:3000. 5) Give it a task and watch it plan, code, test, and iterate autonomously.", + "workflow": "User describes task in web UI → OpenHands plans approach → Agent writes code in sandbox → Code executes in Docker container → Agent reads output → Agent iterates until tests pass. Fabric can transform error messages into structured context. ripgrep + fd + tree_sitter give the agent codebase awareness when pointed at a repo.", + "tips": "OpenHands uses Docker sandboxes by default for safe code execution. For local-only setups, configure SANDBOX_TYPE=local in the environment. The coding model matters more than reasoning quality — Qwen2.5-Coder:32b consistently outperforms larger general models on SWE-bench. Use LiteLLM as a middleman if you want to hot-swap models between tasks without restarting OpenHands." + } +} diff --git a/src/ai_lsc/registry/stack_templates/openjarvis-intelligence-stack.json b/src/ai_lsc/registry/stack_templates/openjarvis-intelligence-stack.json new file mode 100644 index 0000000..ed018cf --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/openjarvis-intelligence-stack.json @@ -0,0 +1,71 @@ +{ + "id": "openjarvis-intelligence-stack", + "name": "OpenJarvis — Full Intelligence Stack", + "description": "The everything-intelligent stack. OpenJarvis as the central brain with dual inference engines (vLLM + Ollama), full memory hierarchy (Qdrant, ChromaDB, LanceDB, Redis, MariaDB), all RAG/OCR/document tools, audio I/O (Whisper + LuxTTS + Parakeet), computer vision, knowledge graphs, semantic search, and Obsidian knowledge base. 30+ tools working in concert.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["openjarvis", "intelligence", "multi-modal", "rag", "vision", "audio", "knowledge-graph", "memory", "full-stack", "local-first"], + "endpoints": { + "openjarvis": "http://localhost:17070", + "ollama_base": "http://localhost:11434/v1", + "vllm_base": "http://localhost:8000", + "litellm_base": "http://localhost:4000", + "qdrant": "http://localhost:6333", + "chromadb": "http://localhost:8000", + "lancedb": "http://localhost:8100", + "redis": "localhost:6379", + "mariadb": "localhost:3306", + "elasticsearch": "http://localhost:9200", + "meilisearch": "http://localhost:7700", + "openwebui": "http://localhost:3000" + }, + "tools": [ + "openjarvis", + "ollama", + "vllm", + "litellm", + "qdrant", + "chromadb", + "lancedb", + "redis", + "mariadb", + "turbovec", + "graphrag", + "elasticsearch", + "meilisearch", + "airweave", + "markitdown", + "opendataloader", + "opendataloader_pdf", + "docling", + "whisper", + "luxtts", + "parakeet", + "deep_eye", + "understand_anything", + "fabric", + "openwebui", + "openhands", + "obsidian", + "aider", + "hermes_agent", + "glances", + "langchain" + ], + "notes": { + "architecture": "OpenJarvis sits at L11 (User Interfaces) as the central brain. It orchestrates all layers below: L5/L6 inference engines, L7 data/knowledge pipelines, L8 automation, L10 intelligent routing, and L13 knowledge management. Every tool in this stack feeds into or is controlled by OpenJarvis.", + "inference_tier": "Ollama at 11434 handles lightweight and medium models (8B-32B). vLLM at 8000 serves heavy models (70B+) with PagedAttention for max throughput. LiteLLM at 4000 provides a unified OpenAI-compatible API over both backends — OpenJarvis and all sub-agents route through LiteLLM for seamless model switching.", + "memory_hierarchy": "Five-tier memory system: (1) Redis 6379 = hot cache / pub-sub / session state (ms latency). (2) Qdrant 6333 = semantic vector memory for RAG and agent recall (us). (3) ChromaDB = document chunk embeddings (dedicated RAG pipeline). (4) LanceDB = fast local vector DB for code and small corpus. (5) MariaDB 3306 = persistent structured storage (audit logs, user prefs, conversation history, task state). TurboVec provides accelerated embedding generation across all vector stores.", + "rag_document_pipeline": "Documents enter through four ingestion paths: MarkItDown converts Office docs to Markdown. OpenDataLoader handles web scraping and structured data. Docling performs deep PDF extraction with layout analysis. OpenDataLoader PDF specializes in scanned/image PDFs. All output flows to Fabric for text transformation, then into vector stores via TurboVec embeddings.", + "audio_i_o": "Whisper (OpenAI) provides speech-to-text for voice commands and meeting transcription. LuxTTS generates natural speech output for responses and notifications. Parakeet is a lightweight alternative TTS for quick alerts. Together they give OpenJarvis full voice I/O capability.", + "vision": "Deep Eye provides computer vision — image description, object detection, scene understanding. Understand Anything handles universal document understanding (charts, diagrams, mixed content). These feed into the RAG pipeline so OpenJarvis can reason about visual content.", + "knowledge_graph_search": "GraphRag builds knowledge graphs from document collections — entities, relationships, community detection. Elasticsearch at 9200 provides full-text search with BM25 ranking. Meilisearch at 7700 provides typo-tolerant instant search. Airweave syncs data across all stores in real-time.", + "knowledge_management": "Obsidian serves as the human-facing knowledge base — markdown notes, bi-directional links, graph view. OpenJarvis can read/write to the Obsidian vault, making the LLM's knowledge graph accessible to humans through a familiar note-taking interface. Logseq and Joplin are alternative knowledge tools in the stack.", + "coding_agents": "OpenHands provides autonomous software engineering. Aider handles interactive pair programming. Hermes Agent runs persistent background agent tasks. All three route through the same LiteLLM/Ollama inference path.", + "monitoring": "Glances provides real-time system resource monitoring. OpenJarvis dashboard exposes all service health, log aggregation, and resource metrics in one view.", + "recommended_models": "Primary reasoning: qwen2.5:72b or llama3.1:70b (via vLLM). Fast tasks: qwen2.5:14b, mistral-nemo:12b (via Ollama). Coding: qwen2.5-coder:32b. Embeddings: nomic-embed-text (fast), bge-large (quality). Vision: llava:13b. Audio: whisper-large-v3.", + "setup": "Start services in order: (1) Redis, MariaDB, Elasticsearch (infrastructure). (2) Qdrant, ChromaDB, LanceDB, Meilisearch (memory/search). (3) Ollama, then vLLM for heavy models. (4) LiteLLM pointing at both backends. (5) TurboVec for embedding acceleration. (6) Document tools (Docling, MarkItDown). (7) Audio tools (Whisper, LuxTTS). (8) OpenJarvis as the central brain. (9) Agent tools (OpenHands, Aider, Hermes Agent). (10) Obsidian for knowledge base. AI-LSC manages all of this through the stack template.", + "resource_requirements": "Minimum: 32GB RAM, 12GB VRAM (6B models). Recommended: 64GB RAM, 24GB VRAM (32B models). Full stack: 128GB RAM, 2x 24GB VRAM (70B models + vLLM + embedding). MariaDB needs 4GB. Elasticsearch needs 4GB. Redis needs 2GB. Plan 16GB+ RAM just for infrastructure services.", + "tips": "Don't start everything at once — use AI-LSC's service manager to bring up tiers incrementally. The inference tier (Ollama + vLLM + LiteLLM) is the foundation. Memory services (Redis + Qdrant + MariaDB) come next. Then document/audio tools. OpenJarvis last. Use the LiteLLM model list to hot-swap models per task without restarting downstream tools." + } +} diff --git a/src/ai_lsc/registry/stack_templates/privacy-first-ai-laptop.json b/src/ai_lsc/registry/stack_templates/privacy-first-ai-laptop.json new file mode 100644 index 0000000..14caf4e --- /dev/null +++ b/src/ai_lsc/registry/stack_templates/privacy-first-ai-laptop.json @@ -0,0 +1,30 @@ +{ + "id": "privacy-first-ai-laptop", + "name": "Privacy-First AI Laptop Setup", + "description": "The complete privacy-respecting AI stack for your laptop. All processing on-device, no telemetry, no cloud APIs. Ollama + Whisper + Obsidian + Paperless-NGX + local search. Popular with privacy-focused YouTubers and FOSS advocates.", + "version": "1.0", + "author": "ai-lsc", + "tags": ["privacy", "offline", "laptop", "document-management", "youtube-trending", "local-first", "foss"], + "endpoints": { + "ollama_base": "http://localhost:11434", + "openwebui": "http://localhost:3000", + "paperlessngx": "http://localhost:8000" + }, + "tools": [ + "ollama", + "openwebui", + "whisper", + "docling", + "markitdown", + "obsidian", + "paperlessngx", + "fabric" + ], + "notes": { + "youtube_context": "Privacy-focused AI content has exploded. Channels like @TheLinuxExperiment, @crosstalksolutions, and @braveouterweb showcase fully local AI setups. The message: 'Your AI should stay on your machine.' This template builds that exact vision.", + "recommended_models": "llama3.1:8b (daily driver, 4GB VRAM), phi-4:14b (quality on 8GB), mistral-nemo:12b (sweet spot), gemma2:9b (fast), nomic-embed-text (document embeddings)", + "setup": "Ollama runs as a systemd service. Open WebUI provides the chat frontend. Paperless-NGX ingests scanned documents. Docling/MarkItDown converts them for RAG. Whisper handles voice memos. Obsidian links everything with local markdown notes.", + "workflow": "Paper documents → scan → Paperless-NGX (OCR + tagging) → Docling (extract text) → Open WebUI RAG (chat with your documents). Voice notes → Whisper → text → Fabric → summarized notes → Obsidian vault. All data stays on your NVMe.", + "tips": "For laptops with <8GB VRAM, use 4-bit quantized models. Set OLLAMA_NUM_PARALLEL=1 to prevent VRAM thrashing. Paperless-NGX works great with 2GB RAM allocated. Use Obsidian's local graph view to visualize connections between your AI-generated notes and source documents." + } +} diff --git a/src/ai_lsc/registry/validator.py b/src/ai_lsc/registry/validator.py new file mode 100644 index 0000000..888f4eb --- /dev/null +++ b/src/ai_lsc/registry/validator.py @@ -0,0 +1,147 @@ +""" +AI-LSC — Registry schema validation. + +Validates that registry entries conform to the expected schema. This is +intended for CI / developer tooling, not for hot-path runtime checks +(so a small upfront cost is acceptable). + +Usage:: + + from ai_lsc.registry.validator import validate_registry + + errors = validate_registry(registry_data) + if errors: + for e in errors: + print(f" - {e}") +""" + +from __future__ import annotations + +from typing import Any + +# Fields that every registry entry must contain. +_REQUIRED_FIELDS: set[str] = { + "name", "level", "layer", "role", "category", + "installer", "launcher", "deps", "description", "flags", +} + +# Valid values for certain fields — must match InstallerType / LauncherType enums. +_VALID_INSTALLER_TYPES: set[str] = { + "ollama", "uv", "pipx", "pip", "npm", + "git", "git_node", "pacman", "dnf", "apt", + "script", "custom", +} +_VALID_LAUNCHER_TYPES: set[str] = { + "systemd", "tmux", "desktop", "lxc", +} + +# Optional installer fields (allowed but not required). +_OPTIONAL_INSTALLER_FIELDS: set[str] = { + "type", "pkg", "cmd", "post_install", "update_cmd", "env_overrides", +} +_OPTIONAL_LAUNCHER_FIELDS: set[str] = { + "type", "cmd", "default_port", +} +_OPTIONAL_FILESYSTEM_FIELDS: set[str] = { + "install", "config", "cache", "data", "logs", "runtime", "models", +} + + +def _check_entry(tool_id: str, entry: dict[str, Any]) -> list[str]: + """Return a list of validation error strings for a single entry.""" + errors: list[str] = [] + + # Missing required fields + missing = _REQUIRED_FIELDS - entry.keys() + if missing: + errors.append(f"{tool_id}: missing fields {sorted(missing)}") + + # Level must be 1–13 + level = entry.get("level") + if isinstance(level, int) and not (1 <= level <= 13): + errors.append(f"{tool_id}: level {level} out of range 1-13") + elif not isinstance(level, int): + errors.append(f"{tool_id}: level is not an int ({level!r})") + + # Installer type + inst = entry.get("installer", {}) + if isinstance(inst, dict): + itype = inst.get("type") + if itype and itype not in _VALID_INSTALLER_TYPES: + errors.append( + f"{tool_id}: unknown installer type {itype!r} " + f"(valid: {sorted(_VALID_INSTALLER_TYPES)})" + ) + # Script-type installers must include cmd + if itype == "script" and not inst.get("cmd"): + errors.append( + f"{tool_id}: installer type 'script' requires 'cmd'" + ) + # Warn if script cmd doesn't contain {tools_root} + if itype == "script" and inst.get("cmd"): + cmd_str = inst["cmd"] + if "{tools_root}" not in cmd_str and tool_id != "ollama": + errors.append( + f"{tool_id}: script installer cmd should reference " + f"{{{{tools_root}}}} to avoid polluting system dirs" + ) + + # Launcher type + launch = entry.get("launcher", {}) + if isinstance(launch, dict): + ltype = launch.get("type") + if ltype and ltype not in _VALID_LAUNCHER_TYPES: + errors.append( + f"{tool_id}: unknown launcher type {ltype!r} " + f"(valid: {sorted(_VALID_LAUNCHER_TYPES)})" + ) + + # Filesystem spec (optional but validated if present) + fs = entry.get("filesystem", {}) + if isinstance(fs, dict): + unknown_fs = set(fs.keys()) - _OPTIONAL_FILESYSTEM_FIELDS + if unknown_fs: + errors.append( + f"{tool_id}: unknown filesystem fields {sorted(unknown_fs)}" + ) + + # deps must be a list of strings + deps = entry.get("deps") + if not isinstance(deps, list): + errors.append(f"{tool_id}: deps is not a list") + else: + non_str = [d for d in deps if not isinstance(d, str)] + if non_str: + errors.append( + f"{tool_id}: deps contains non-string items: {non_str}" + ) + + # flags must be a dict of bools + flags = entry.get("flags", {}) + if not isinstance(flags, dict): + errors.append(f"{tool_id}: flags is not a dict") + else: + non_bool = {k: v for k, v in flags.items() + if not isinstance(v, bool)} + if non_bool: + errors.append( + f"{tool_id}: flags contain non-bool values: " + f"{list(non_bool.keys())}" + ) + + return errors + + +def validate_registry(data: dict[str, Any]) -> list[str]: + """Validate an entire registry dict. + + Returns a (possibly empty) list of human-readable error strings. + An empty list means the registry is valid. + """ + errors: list[str] = [] + for tool_id, entry in data.items(): + if not isinstance(entry, dict): + errors.append(f"{tool_id}: entry is not a dict ({type(entry)})") + continue + errors.extend(_check_entry(tool_id, entry)) + return errors diff --git a/src/ai_lsc/runtime/__init__.py b/src/ai_lsc/runtime/__init__.py new file mode 100644 index 0000000..b3f4fd8 --- /dev/null +++ b/src/ai_lsc/runtime/__init__.py @@ -0,0 +1,20 @@ +"""AI-LSC runtime sub-package. + +Process management abstraction layer. All subprocess / psutil calls +live here so that UI code never touches the OS directly. + +UI code calls :class:`RuntimeExecutor`, which delegates to backend- +specific managers (tmux, systemd, process, installer). +""" + +from ai_lsc.runtime.executor import RuntimeExecutor +from ai_lsc.runtime.installer import InstallerManager +from ai_lsc.runtime.lxc import LxcManager +from ai_lsc.runtime.status import StatusChecker + +__all__ = [ + "RuntimeExecutor", + "InstallerManager", + "LxcManager", + "StatusChecker", +] diff --git a/src/ai_lsc/runtime/executor.py b/src/ai_lsc/runtime/executor.py new file mode 100644 index 0000000..c2bdf5d --- /dev/null +++ b/src/ai_lsc/runtime/executor.py @@ -0,0 +1,300 @@ +"""Runtime executor -- the single entry point for all process management. + +UI code calls ``RuntimeExecutor`` methods instead of touching +``subprocess`` directly. This is the *only* class the UI should +import from ``ai_lsc.runtime``. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any + +from ai_lsc.runtime.installer import InstallerManager +from ai_lsc.runtime.lxc import LxcManager +from ai_lsc.runtime.process import ProcessManager +from ai_lsc.runtime.status import StatusChecker +from ai_lsc.runtime.systemd import SystemdManager +from ai_lsc.runtime.tmux import TmuxManager +from ai_lsc.utils.process import enriched_env + + +class RuntimeExecutor: + """Unified runtime facade for UI-layer delegation. + + Parameters + ---------- + tools_root: + Base directory for tool installations. + models_root: + Base directory for model files. + workspaces_root: + Base directory for workspace data. + logs_root: + Base directory for service log files. + base_bin_dir: + Colon-separated PATH string to prepend to all commands. + dtach_bin: + Path to the ``dtach`` binary (or ``None``). + """ + + def __init__( + self, + tools_root: str, + models_root: str, + workspaces_root: str, + logs_root: str, + base_bin_dir: str = "", + dtach_bin: str | None = None, + ) -> None: + self.tools_root = tools_root + self.models_root = models_root + self.workspaces_root = workspaces_root + self.logs_root = logs_root + self.base_bin_dir = base_bin_dir + self.dtach_bin = dtach_bin + + self._tmux = TmuxManager() + self._systemd = SystemdManager() + self._lxc = LxcManager(tools_root, logs_root) + self._process = ProcessManager() + self._installer = InstallerManager(tools_root, base_bin_dir) + self._status = StatusChecker() + + # -- context formatting ----------------------------------------------- + + def format_context( + self, + port: str = "", + model_arg: str = "", + ) -> dict[str, str]: + """Build the ``{placeholders}`` dict used by launcher commands.""" + from ai_lsc.constants import BASE_DIR + return { + "base_dir": BASE_DIR, + "tools_root": self.tools_root, + "models_root": self.models_root, + "workspaces_root": self.workspaces_root, + "port": port, + "model_arg": model_arg, + } + + # -- service lifecycle ----------------------------------------------- + + def start_service( + self, + tool_id: str, + launcher_cmd: str, + launcher_type: str, + port: str = "", + model_arg: str = "", + ) -> str: + """Start a service via the appropriate backend. + + Returns a description of what was done. + """ + ctx = self.format_context(port=port, model_arg=model_arg) + final_cmd = launcher_cmd.format(**ctx) + + if launcher_type == "systemd": + self._systemd.start(final_cmd) + return f"Systemd activated for {tool_id}" + + if launcher_type == "desktop": + self._process.launch_desktop(final_cmd) + return f"Desktop spawned for {tool_id}" + + if launcher_type == "lxc": + log_file = str(Path(self.logs_root) / f"{tool_id}.log") + return self._lxc.launch_service( + tool_id=tool_id, + command=final_cmd, + log_file=log_file, + dtach_bin=self.dtach_bin, + base_bin_dir=self.base_bin_dir, + ) + + # default: tmux (with optional dtach) + log_file = str(Path(self.logs_root) / f"{tool_id}.log") + self._tmux.launch_service( + tool_id=tool_id, + command=final_cmd, + log_file=log_file, + dtach_bin=self.dtach_bin, + base_bin_dir=self.base_bin_dir, + ) + return f"Component {tool_id} isolated in Tmux." + + def stop_service( + self, + tool_id: str, + launcher_type: str, + launcher_cmd: str = "", + search_term: str = "", + is_docker: bool = False, + ) -> str: + """Stop a service via the appropriate backend. + + Returns a description of what was done. + """ + if launcher_type == "systemd": + self._systemd.stop(launcher_cmd) + return f"Systemd stop signal sent for {tool_id}" + + if launcher_type == "tmux": + self._tmux.stop_service(tool_id) + return f"Tmux window killed for {tool_id}" + + if launcher_type == "lxc": + return self._lxc.stop_service(tool_id) + + # default: pkill + self._process.kill_by_name(search_term) + + if is_docker: + # Derive compose path from tool_id (tool-specific docker dir) + compose_path = ( + f"{self.tools_root}/{tool_id}/docker/docker-compose.yaml" + ) + self._process.docker_compose_down(compose_path) + + return f"Termination signal sent to {tool_id}." + + def is_service_running( + self, + launcher_type: str, + tool_id: str = "", + service_cmd: str = "", + search_term: str = "", + ) -> bool: + """Check whether a service is currently live.""" + if launcher_type == "lxc": + return self._lxc.is_running(f"ai-lsc-{tool_id}") + return self._status.is_running( + launcher_type=launcher_type, + tool_id=tool_id, + service_cmd=service_cmd, + search_term=search_term, + ) + + # -- installation ---------------------------------------------------- + + def install_tool( + self, + inst_type: str, + pkg: str, + cmd: str = "", + tool_id: str = "", + ctx: dict[str, str] | None = None, + force: bool = False, + post_install: str | None = None, + env_overrides: dict[str, str] | None = None, + filesystem: dict[str, str] | None = None, + ) -> str: + """Dispatch tool installation to the correct installer. + + If *tool_id* is provided, the installer uses preflight detection + and routes artifacts to ``tools_root//``. + If *force* is True, skips preflight and installs unconditionally. + + *post_install* runs a shell command inside ``tools_root/`` + after clone (e.g. ``pip install -r requirements.txt``, ``make``). + + *env_overrides* remaps upstream environment variables (HF_HOME, + TRANSFORMERS_CACHE, etc.) into ``/mnt/AI/`` paths. + + *filesystem* declares per-tool path mappings for the verification + checklist (install, config, cache, logs). + + Returns a description of the result. + """ + if tool_id: + return self._installer.install_with_preflight( + tool_id=tool_id, + inst_type=inst_type, + pkg=pkg, + cmd=cmd, + ctx=ctx, + force=force, + post_install=post_install, + env_overrides=env_overrides, + ) + return self._installer.run( + inst_type=inst_type, + pkg=pkg, + cmd=cmd, + ctx=ctx, + tool_id=tool_id, + post_install=post_install, + env_overrides=env_overrides, + ) + + # -- verification --------------------------------------------------- + + def verify_tool( + self, + tool_id: str, + inst_type: str, + pkg: str, + cmd: str = "", + filesystem: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Run the installation compliance checklist for a tool. + + Returns a dict with ``score``, ``checks``, and ``install_location``. + """ + return self._installer.verify( + tool_id=tool_id, + inst_type=inst_type, + pkg=pkg, + cmd=cmd, + filesystem=filesystem, + ) + + # -- model management ------------------------------------------------ + + def pull_model(self, model_name: str) -> subprocess.Popen: + """Start an ``ollama pull`` and return the live process.""" + from ai_lsc.utils.ollama import ollama_env + env = enriched_env(self.base_bin_dir) + ollama_env_overrides = ollama_env(self.models_root) + env.update(ollama_env_overrides) + return subprocess.Popen( + ["ollama", "pull", model_name], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + + # -- CLI launch ------------------------------------------------------- + + def launch_cli( + self, + tool_id: str, + launcher_type: str, + ) -> str: + """Open a terminal for the tool's CLI interface.""" + cmd = "" + if launcher_type == "tmux": + cmd = self._tmux.attach_cli(tool_id) + elif launcher_type == "lxc": + return self._lxc.launch_cli(f"ai-lsc-{tool_id}") + env = enriched_env(self.base_bin_dir) + from ai_lsc.constants import BASE_DIR + self._process.launch_terminal( + f"{cmd}cd {BASE_DIR} && echo 'Spawning CLI...' && exec bash", + env=env, + ) + return f"Spawned CLI terminal for {tool_id}" + + # -- web launch ------------------------------------------------------- + + @staticmethod + def open_web_url(port: str) -> str: + """Open a browser tab for the given port. Returns the URL.""" + import webbrowser + url = f"http://127.0.0.1:{port}" + webbrowser.open(url) + return url diff --git a/src/ai_lsc/runtime/installer.py b/src/ai_lsc/runtime/installer.py new file mode 100644 index 0000000..aaba2ba --- /dev/null +++ b/src/ai_lsc/runtime/installer.py @@ -0,0 +1,807 @@ +"""Installer manager -- dispatch-table-driven tool installation. + +Handles pacman, dnf, apt, uv, pipx, pip, ollama, npm, git, git_node, +script, and custom installer types. Every ``subprocess`` / ``os.makedirs`` +call is confined here. + +Key capabilities +--------------- +1. **Step-down containment**: Each Python tool tries the most isolated + install method first (ollama -> uv -> pipx -> pip). If the preferred + method fails, it steps down to the next one automatically. + +2. **Working directory enforcement**: All tool artifacts are installed + under ``tools_root//`` (or ``tools_root/npm_globals/`` for + npm). This keeps the host system clean and makes tools portable. + +3. **``~/.local`` remap**: Environment variables are set so that + ``uv``, ``pip``, and ``pipx`` install into ``tools_root`` instead + of the user's home directory. + +4. **Per-tool env overrides**: Tools like vLLM, huggingface tools, etc. + can declare ``env_overrides`` in the registry to redirect + HF_HOME, TRANSFORMERS_CACHE, and other upstream paths into + ``/mnt/AI/cache/`` or ``/mnt/AI/data/``. + +5. **Post-install hooks**: Git-cloned tools can declare ``post_install`` + commands (e.g. ``pip install -r requirements.txt``, ``make``) + that run automatically after clone. + +6. **Preflight detection**: ``preflight()`` checks whether a tool is + already installed (via ``which``, directory existence, or pacman + query) and returns a ``PreflightResult`` so the UI can offer + "update to latest" instead of blindly reinstalling. + +7. **Installation verification**: ``verify()`` runs a compliance + checklist against a single tool and returns a ``VerificationResult`` + with a quality score (0-100%). + +8. **Version detection**: Attempts to extract the installed version + for comparison with the latest available version. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from ai_lsc.utils.logging import get_logger +from ai_lsc.utils.process import enriched_env + +logger = get_logger(__name__) + +# Step-down containment order (most isolated first) +STEP_DOWN_ORDER: list[str] = [ + "ollama", "uv", "pipx", "pip", + "git", "git_node", "npm", "pacman", "dnf", "apt", "script", "custom", +] + +# Version extraction commands per installer type +_VERSION_CMDS: dict[str, str] = { + "pacman": "pacman -Qi {pkg} 2>/dev/null | grep Version", + "dnf": "dnf info {pkg} 2>/dev/null | grep Version", + "apt": "dpkg -s {pkg} 2>/dev/null | grep Version", + "uv": "{cmd} --version 2>/dev/null", + "npm": "npm list -g {pkg} --depth=0 2>/dev/null", + "pip": "pip show {pkg} 2>/dev/null | grep Version", + "pipx": "pipx list 2>/dev/null | grep {pkg}", +} + +# Known upstream env vars that tools commonly use for data/cache. +# Format: env_var -> (human_label, default_subdir_under_base) +_UPSTREAM_ENV_VARS: dict[str, tuple[str, str]] = { + "HF_HOME": ("HuggingFace cache", "cache/huggingface"), + "TRANSFORMERS_CACHE": ("Transformers cache", "cache/huggingface"), + "DIFFUSERS_CACHE": ("Diffusers cache", "cache/huggingface"), + "RUST_BACKTRACE": ("Rust backtrace", None), + "NODE_PATH": ("Node modules", None), + "npm_config_prefix": ("npm prefix", None), +} + + +class InstallerManager: + """Install or sync tools via the appropriate package manager. + + Parameters + ---------- + tools_root : + Base directory for tool installations (default ``/mnt/AI/tools``). + base_dir : + Top-level AI-LSC directory (``/mnt/AI``). Used to expand + per-tool filesystem paths. + base_bin_dir : + Colon-separated PATH string to prepend to all commands. + """ + + def __init__( + self, + tools_root: str, + base_dir: str = "", + base_bin_dir: str = "", + ) -> None: + from ai_lsc.constants import BASE_DIR + self.tools_root = tools_root + self.base_dir = base_dir or BASE_DIR + self.base_bin_dir = base_bin_dir + + # ── Environment construction ───────────────────────────────────── + + def _env( + self, + tool_id: str = "", + env_overrides: dict[str, str] | None = None, + ) -> dict[str, str]: + """Build an enriched environment with ``~/.local`` remapped. + + For Python tools, we redirect uv/pip/pipx directories into + ``tools_root`` so that artifacts do not leak into the user's + home directory. Per-tool ``env_overrides`` (from the registry) + are applied last so they take precedence. + """ + env = enriched_env(self.base_bin_dir) + + # ── Global XDG remap: ~/.local -> tools_root/.local ──────────── + env["LOCAL_BIN"] = os.path.join(self.tools_root, ".local", "bin") + env["XDG_DATA_HOME"] = os.path.join(self.tools_root, ".local", "share") + env["XDG_CONFIG_HOME"] = os.path.join(self.tools_root, ".local", "config") + env["XDG_CACHE_HOME"] = os.path.join(self.tools_root, ".local", "cache") + + # ── uv-specific: force tool installs into tools_root ─────────── + if tool_id: + uv_tool_dir = os.path.join(self.tools_root, tool_id, ".uv", "tools") + uv_bin_dir = os.path.join(self.tools_root, tool_id, ".uv", "bin") + else: + uv_tool_dir = os.path.join(self.tools_root, ".uv", "tools") + uv_bin_dir = os.path.join(self.tools_root, ".uv", "bin") + env["UV_TOOL_DIR"] = uv_tool_dir + env["UV_TOOL_BIN_DIR"] = uv_bin_dir + env["UV_CACHE_DIR"] = os.path.join(self.tools_root, ".uv", "cache") + + # ── pipx-specific: force installs into tools_root ──────────────── + if tool_id: + env["PIPX_BIN_DIR"] = os.path.join( + self.tools_root, tool_id, ".pipx", "bin", + ) + env["PIPX_HOME"] = os.path.join( + self.tools_root, tool_id, ".pipx", + ) + else: + env["PIPX_BIN_DIR"] = os.path.join(self.tools_root, ".pipx", "bin") + env["PIPX_HOME"] = os.path.join(self.tools_root, ".pipx") + + # ── Per-tool env overrides from registry ──────────────────────── + # Keys may contain {tools_root}, {base_dir} placeholders. + if env_overrides: + for key, raw_val in env_overrides.items(): + expanded = raw_val.replace( + "{tools_root}", self.tools_root, + ).replace( + "{base_dir}", self.base_dir, + ) + env[key] = expanded + logger.debug( + "env override: %s=%s (tool %s)", key, expanded, tool_id, + ) + + # ── Prepend managed bin dirs to PATH ─────────────────────────── + managed_bins = [ + env.get("PIPX_BIN_DIR", ""), + env.get("UV_TOOL_BIN_DIR", ""), + os.path.join(self.tools_root, "bin"), + os.path.join(self.tools_root, ".local", "bin"), + ] + extra = ":".join(d for d in managed_bins if d) + env["PATH"] = f"{extra}:{env.get('PATH', '')}" + return env + + # ── Preflight detection ───────────────────────────────────────── + + def preflight( + self, + tool_id: str, + inst_type: str, + pkg: str, + cmd: str = "", + ) -> dict[str, Any]: + """Check whether a tool is already installed before installing. + + Returns a dict matching ``PreflightResult`` fields. + """ + result: dict[str, Any] = { + "tool_id": tool_id, + "found": False, + "install_type": inst_type, + "location": "", + "version": "", + "is_update_available": False, + "suggested_action": "install", + } + + location, version = self._detect_installation( + tool_id, inst_type, pkg, cmd, + ) + if location: + result["found"] = True + result["location"] = location + result["version"] = version or "" + result["suggested_action"] = "update" + + return result + + def _detect_installation( + self, + tool_id: str, + inst_type: str, + pkg: str, + cmd: str = "", + ) -> tuple[str, str]: + """Detect existing installation. Returns (location, version).""" + + # 1. Check tools_root/ directory existence + tool_dir = os.path.join(self.tools_root, tool_id) + if os.path.isdir(tool_dir): + ver = self._detect_version(inst_type, pkg, cmd, tool_dir) + return tool_dir, ver + + # 2. Check tools_root/.pipx, tools_root/.uv, tools_root/.local + for subdir in [".pipx", ".uv", ".local"]: + check = os.path.join(self.tools_root, subdir, "bin", pkg) + if os.path.exists(check): + return os.path.dirname(check), "" + + # 3. Check tools_root/bin + bin_check = os.path.join(self.tools_root, "bin", pkg) + if os.path.exists(bin_check): + return os.path.dirname(bin_check), "" + + # 4. Check system PATH via shutil.which + binary_name = self._binary_name(pkg, inst_type) + system_path = shutil.which(binary_name) + if system_path: + ver = self._detect_version(inst_type, pkg, cmd) + return system_path, ver + + # 5. OS package manager query (pacman / dnf / apt) + for pkg_mgr, query_cmd in [ + ("pacman", f"pacman -Qi {pkg} 2>/dev/null | grep Version"), + ("dnf", f"dnf info {pkg} 2>/dev/null | grep Version"), + ("apt", f"dpkg -s {pkg} 2>/dev/null | grep Version"), + ]: + if inst_type == pkg_mgr: + try: + proc = subprocess.run( + query_cmd, + shell=True, capture_output=True, text=True, timeout=10, + ) + if proc.returncode == 0 and proc.stdout.strip(): + ver_line = proc.stdout.strip() + ver = ( + ver_line.split(":")[-1].strip() + if ":" in ver_line else ver_line + ) + return f"{pkg_mgr}:{pkg}", ver + except Exception: + pass + + return "", "" + + def _binary_name(self, pkg: str, inst_type: str) -> str: + """Map a package name to its likely binary name.""" + if inst_type == "npm": + return pkg if "/" not in pkg else pkg.split("/")[-1] + if inst_type in ("uv", "pip"): + return pkg.replace("-", "_").replace(".", "_") + return pkg + + def _detect_version( + self, + inst_type: str, + pkg: str, + cmd: str, + cwd: str = "", + ) -> str: + """Try to extract the installed version.""" + if inst_type == "git": + git_dir = os.path.join(self.tools_root, pkg.split("/")[-1] + .replace(".git", "")) + if os.path.isdir(os.path.join(git_dir, ".git")): + try: + proc = subprocess.run( + ("git describe --tags --abbrev=0 2>/dev/null " + "|| git rev-parse --short HEAD"), + shell=True, capture_output=True, text=True, + timeout=10, cwd=git_dir, + ) + if proc.returncode == 0: + return proc.stdout.strip() + except Exception: + pass + return "" + + # Try the launcher command for version + ver_cmd = f"{cmd} --version" if cmd else "" + if not ver_cmd: + tmpl = _VERSION_CMDS.get(inst_type, "") + if tmpl: + ver_cmd = tmpl.format(pkg=pkg, cmd=pkg) + + if not ver_cmd: + return "" + + try: + proc = subprocess.run( + f"{ver_cmd} 2>/dev/null", + shell=True, capture_output=True, text=True, + timeout=10, cwd=cwd or None, + ) + if proc.returncode == 0: + return proc.stdout.strip().split("\n")[0] + except Exception: + pass + return "" + + # ── Post-install hooks ────────────────────────────────────────── + + def _run_post_install( + self, + tool_id: str, + post_install_cmd: str, + ) -> str: + """Run a post-install hook inside ``tools_root/``.""" + if not post_install_cmd: + return "" + dest = os.path.join(self.tools_root, tool_id) + env = self._env(tool_id) + # Replace {tools_root} in the command + cmd = post_install_cmd.replace("{tools_root}", self.tools_root) + logger.info("Running post-install for %s: %s", tool_id, cmd) + try: + subprocess.run( + cmd, shell=True, check=True, env=env, + timeout=300, cwd=dest, + ) + return f"Post-install completed for {tool_id}." + except subprocess.CalledProcessError as exc: + logger.warning( + "Post-install failed for %s: %s", tool_id, exc, + ) + return f"Post-install FAILED for {tool_id}: {exc}" + + # ── Strategy methods ──────────────────────────────────────────── + + def install_ollama(self, pkg: str, tool_id: str) -> str: + """Pull an Ollama model or install the ollama binary.""" + if tool_id == "ollama": + dest = os.path.join(self.tools_root, "ollama") + os.makedirs(dest, exist_ok=True) + subprocess.run( + "curl -fsSL https://ollama.com/install.sh | sh", + shell=True, check=True, env=self._env("ollama"), + ) + return "Ollama binary installed to system (managed by ollama)." + return f"Ollama model '{pkg}' queued for pull." + + def install_uv(self, pkg: str, tool_id: str, + env_overrides: dict[str, str] | None = None) -> str: + """Install a Python tool via ``uv tool install`` pinned to tools_root.""" + dest = os.path.join(self.tools_root, tool_id) + os.makedirs(dest, exist_ok=True) + env = self._env(tool_id, env_overrides) + try: + subprocess.run( + f"uv tool install {pkg}", + shell=True, check=True, env=env, timeout=300, + ) + return f"UV tool '{pkg}' installed to {env['UV_TOOL_DIR']}." + except subprocess.CalledProcessError: + logger.info("uv install failed for %s, stepping down to pipx", pkg) + return self.install_pipx(pkg, tool_id, env_overrides) + + def install_pipx(self, pkg: str, tool_id: str, + env_overrides: dict[str, str] | None = None) -> str: + """Install a Python CLI tool via ``pipx`` pinned to tools_root.""" + dest = os.path.join(self.tools_root, tool_id) + os.makedirs(dest, exist_ok=True) + env = self._env(tool_id, env_overrides) + try: + subprocess.run( + f"pipx install {pkg}", + shell=True, check=True, env=env, timeout=300, + ) + return f"pipx '{pkg}' installed to {env['PIPX_HOME']}." + except subprocess.CalledProcessError: + logger.info("pipx install failed for %s, stepping down to pip", pkg) + return self.install_pip(pkg, tool_id, env_overrides) + + def install_pip(self, pkg: str, tool_id: str, + env_overrides: dict[str, str] | None = None) -> str: + """Install a Python tool via ``pip`` into a per-tool venv.""" + dest = os.path.join(self.tools_root, tool_id) + venv_dir = os.path.join(dest, ".venv") + os.makedirs(dest, exist_ok=True) + env = self._env(tool_id, env_overrides) + if not os.path.isdir(venv_dir): + subprocess.run( + f"python3 -m venv {venv_dir}", + shell=True, check=True, env=env, timeout=60, + ) + pip_bin = os.path.join(venv_dir, "bin", "pip") + subprocess.run( + f"{pip_bin} install {pkg}", + shell=True, check=True, env=env, timeout=300, + ) + self._symlink_venv_bin(tool_id, venv_dir, pkg) + return f"pip '{pkg}' installed to {venv_dir}." + + def _symlink_venv_bin( + self, tool_id: str, venv_dir: str, pkg: str, + ) -> None: + """Create symlinks from the venv bin to tools_root/bin.""" + bin_dir = os.path.join(self.tools_root, "bin") + os.makedirs(bin_dir, exist_ok=True) + venv_bin = os.path.join(venv_dir, "bin") + if os.path.isdir(venv_bin): + for entry in os.listdir(venv_bin): + src = os.path.join(venv_bin, entry) + dst = os.path.join(bin_dir, entry) + if os.path.isfile(src) and not os.path.exists(dst): + os.symlink(src, dst) + + def install_pacman(self, pkg: str) -> str: + """Open a terminal for ``pacman -S`` (Arch system package).""" + subprocess.Popen( + f"x-terminal-emulator -e bash -c " + f"'sudo pacman -S --noconfirm {pkg}; sleep 2'", + shell=True, + ) + return f"Dispatched pacman for {pkg}." + + def install_dnf(self, pkg: str) -> str: + """Open a terminal for ``dnf install`` (Fedora / RHEL).""" + subprocess.Popen( + f"x-terminal-emulator -e bash -c " + f"'sudo dnf install -y {pkg}; sleep 2'", + shell=True, + ) + return f"Dispatched dnf for {pkg}." + + def install_apt(self, pkg: str) -> str: + """Open a terminal for ``apt install`` (Debian / Ubuntu).""" + subprocess.Popen( + f"x-terminal-emulator -e bash -c " + f"'sudo apt-get install -y {pkg}; sleep 2'", + shell=True, + ) + return f"Dispatched apt for {pkg}." + + def install_npm(self, pkg: str, tool_id: str, + env_overrides: dict[str, str] | None = None) -> str: + """Install an npm package to an isolated prefix under tools_root.""" + dest = os.path.join(self.tools_root, tool_id) + os.makedirs(dest, exist_ok=True) + env = self._env(tool_id, env_overrides) + subprocess.run( + f"npm install --prefix {dest} {pkg}", + shell=True, check=True, env=env, timeout=300, + ) + return f"NPM '{pkg}' installed to {dest}." + + def install_git( + self, + pkg: str, + tool_id: str, + post_install: str | None = None, + env_overrides: dict[str, str] | None = None, + ) -> str: + """Clone a git repository into ``tools_root/``.""" + dest = os.path.join(self.tools_root, tool_id) + if os.path.exists(dest): + subprocess.run( + f"cd {dest} && git pull --ff-only", + shell=True, check=True, + ) + msg = f"Git source updated: {dest}" + else: + os.makedirs(dest, exist_ok=True) + subprocess.run( + f"git clone {pkg} {dest}", + shell=True, check=True, + ) + msg = f"Git source cloned: {dest}" + + if post_install: + self._run_post_install(tool_id, post_install) + return msg + + def install_git_node( + self, + pkg: str, + tool_id: str, + post_install: str | None = None, + ) -> str: + """Clone a git repo and run ``yarn setup``.""" + dest = os.path.join(self.tools_root, tool_id) + if os.path.exists(dest): + subprocess.run( + f"cd {dest} && git pull --ff-only && yarn install", + shell=True, check=True, + ) + msg = f"Git+Node source updated: {dest}" + else: + os.makedirs(dest, exist_ok=True) + subprocess.run( + f"git clone {pkg} {dest}", + shell=True, check=True, + ) + subprocess.run( + f"cd {dest} && yarn install", + shell=True, check=True, + ) + msg = f"Git+Node source synchronized: {dest}" + + if post_install: + self._run_post_install(tool_id, post_install) + return msg + + def install_script( + self, + cmd: str, + ctx: dict[str, str], + tool_id: str = "", + env_overrides: dict[str, str] | None = None, + ) -> str: + """Execute an arbitrary shell script (installer type ``"script"``). + + The ``{tools_root}`` placeholder is resolved so scripts can + direct output to the correct directory. + """ + if "tools_root" not in ctx: + ctx["tools_root"] = self.tools_root + env = self._env(tool_id, env_overrides) + subprocess.run(cmd.format(**ctx), shell=True, check=True, env=env) + return "Shell script deployment completed." + + def install_custom(self, pkg: str, tool_id: str) -> str: + """Open the install URL in the browser for manual installation.""" + import webbrowser + url = pkg + if not url.startswith("http"): + url = f"https://{url}" + webbrowser.open(url) + return ( + f"Opened {url} in browser for manual installation " + f"of {tool_id}. Follow the instructions on the page." + ) + + # ── Dispatcher ───────────────────────────────────────────────── + + def run( + self, + inst_type: str, + pkg: str, + cmd: str = "", + ctx: dict[str, str] | None = None, + tool_id: str = "", + post_install: str | None = None, + env_overrides: dict[str, str] | None = None, + ) -> str: + """Dispatch to the correct installer strategy. + + Returns a human-readable description of what happened. + + Raises + ------ + ValueError + If *inst_type* is not recognized. + subprocess.CalledProcessError + If the underlying command fails. + """ + ctx = ctx or {} + if not tool_id: + if "github.com" in pkg: + tool_id = (pkg.rstrip("/").rsplit("/", 1)[-1] + .replace(".git", "")) + else: + tool_id = pkg.split("/")[-1].split(":")[0] + + strategies: dict[str, Any] = { + "ollama": lambda: self.install_ollama(pkg, tool_id), + "uv": lambda: self.install_uv(pkg, tool_id, env_overrides), + "pipx": lambda: self.install_pipx(pkg, tool_id, env_overrides), + "pip": lambda: self.install_pip(pkg, tool_id, env_overrides), + "script": lambda: self.install_script( + cmd, ctx, tool_id, env_overrides, + ), + "pacman": lambda: self.install_pacman(pkg), + "dnf": lambda: self.install_dnf(pkg), + "apt": lambda: self.install_apt(pkg), + "npm": lambda: self.install_npm(pkg, tool_id, env_overrides), + "git": lambda: self.install_git( + pkg, tool_id, post_install, env_overrides, + ), + "git_node": lambda: self.install_git_node( + pkg, tool_id, post_install, + ), + "custom": lambda: self.install_custom(pkg, tool_id), + } + handler = strategies.get(inst_type) + if handler is None: + raise ValueError(f"Unknown installer type '{inst_type}'") + return handler() + + # ── Batch operations ──────────────────────────────────────────── + + def preflight_batch( + self, + tools: dict[str, dict[str, Any]], + ) -> dict[str, dict[str, Any]]: + """Run preflight checks for multiple tools at once. + + Parameters + ---------- + tools : + Dict of ``{tool_id: registry_entry}`` from the registry. + + Returns + ------- + Dict of ``{tool_id: preflight_result_dict}``. + """ + results: dict[str, dict[str, Any]] = {} + for tid, meta in tools.items(): + installer = meta.get("installer", {}) + results[tid] = self.preflight( + tool_id=tid, + inst_type=installer.get("type", "pacman"), + pkg=installer.get("pkg", ""), + cmd=installer.get("cmd", ""), + ) + return results + + def install_with_preflight( + self, + tool_id: str, + inst_type: str, + pkg: str, + cmd: str = "", + ctx: dict[str, str] | None = None, + force: bool = False, + post_install: str | None = None, + env_overrides: dict[str, str] | None = None, + ) -> str: + """Install a tool with preflight detection. + + If the tool is already installed and *force* is False, returns + a message saying the tool exists and suggesting an update. + If *force* is True, proceeds with installation regardless. + """ + check = self.preflight(tool_id, inst_type, pkg, cmd) + if check["found"] and not force: + return ( + f"Tool '{tool_id}' already installed at {check['location']}. " + f"Version: {check['version'] or 'unknown'}. " + f"Use force=True to update." + ) + return self.run( + inst_type, pkg, cmd, ctx, tool_id, + post_install, env_overrides, + ) + + # ── Installation verification ─────────────────────────────────── + + def verify( + self, + tool_id: str, + inst_type: str, + pkg: str, + cmd: str = "", + filesystem: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Run a compliance checklist against a single tool installation. + + Checks: + 1. Native install detected + 2. Installed entirely under /mnt/AI (no ~/.local leak) + 3. Config redirected from $HOME + 4. Cache redirected + 5. Logs redirected + 6. Launcher binary accessible + 7. Update command available + 8. Version detection works + 9. Health check (binary --version or --help) + + Returns a dict matching ``VerificationResult`` fields. + """ + from ai_lsc.types import VerifyCheck + + checks: list[VerifyCheck] = [] + fs = filesystem or {} + tool_dir = os.path.join(self.tools_root, tool_id) + + # 1. Native install detected + location, version = self._detect_installation( + tool_id, inst_type, pkg, cmd, + ) + checks.append(VerifyCheck( + name="Native Install", + passed=bool(location), + detail=location or "not found", + )) + + # 2. Installed under /mnt/AI (no system leak) + is_managed = ( + location and location.startswith(self.base_dir) + ) or inst_type == "pacman" or inst_type in ("dnf", "apt") + checks.append(VerifyCheck( + name="Filesystem Compliance", + passed=is_managed, + detail=location or "N/A", + )) + + # 3. Config path (if declared in filesystem spec) + config_path = fs.get("config", "") + if config_path: + full = os.path.join(self.base_dir, config_path) + exists = os.path.isdir(full) + checks.append(VerifyCheck( + name="Config Redirect", + passed=exists or not location, + detail=full, + )) + + # 4. Cache path + cache_path = fs.get("cache", "") + if cache_path: + full = os.path.join(self.base_dir, cache_path) + checks.append(VerifyCheck( + name="Cache Redirect", + passed=os.path.isdir(full) or not location, + detail=full, + )) + + # 5. Logs path + logs_path = fs.get("logs", "") + if logs_path: + full = os.path.join(self.base_dir, logs_path) + checks.append(VerifyCheck( + name="Logs Redirect", + passed=os.path.isdir(full) or not location, + detail=full, + )) + + # 6. Launcher binary accessible + binary = self._binary_name(pkg, inst_type) + bin_path = shutil.which(binary) + checks.append(VerifyCheck( + name="Launcher Accessible", + passed=bool(bin_path), + detail=bin_path or f"{binary} not in PATH", + )) + + # 7. Version detection + checks.append(VerifyCheck( + name="Version Detection", + passed=bool(version), + detail=version or "unknown", + )) + + # 8. Health check (try --version or --help) + healthy = False + if bin_path: + try: + proc = subprocess.run( + f"{bin_path} --version", + shell=True, capture_output=True, text=True, timeout=5, + ) + healthy = proc.returncode == 0 + except Exception: + try: + proc = subprocess.run( + f"{bin_path} --help", + shell=True, capture_output=True, text=True, + timeout=5, + ) + healthy = proc.returncode == 0 + except Exception: + pass + checks.append(VerifyCheck( + name="Health Check", + passed=healthy, + detail="responds to --version/--help" if healthy else "no response", + )) + + return { + "tool_id": tool_id, + "checks": [ + {"name": c.name, "passed": c.passed, "detail": c.detail} + for c in checks + ], + "install_method": inst_type, + "install_location": location or "", + "score": ( + int(sum(1 for c in checks if c.passed) / len(checks) * 100) + if checks else 0 + ), + } diff --git a/src/ai_lsc/runtime/lxc.py b/src/ai_lsc/runtime/lxc.py new file mode 100644 index 0000000..c2c0439 --- /dev/null +++ b/src/ai_lsc/runtime/lxc.py @@ -0,0 +1,299 @@ +"""LXC runtime manager -- Linux Container lifecycle operations. + +Provides start / stop / status / create / destroy / attach operations +for LXC containers. LXC is a lighter-weight alternative to Docker/Podman +that shares the host kernel without the containerd daemon overhead. + +All ``subprocess`` calls are confined here -- UI code never touches LXC +commands directly. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + + +class LxcManager: + """Manages LXC container lifecycle via the ``lxc`` CLI. + + Parameters + ---------- + tools_root : + Base directory for tool installations (used as container + mount source). + logs_root : + Directory for container log files. + lxc_profile : + Default LXC profile name (``"default"`` unless overridden). + """ + + def __init__( + self, + tools_root: str, + logs_root: str, + lxc_profile: str = "default", + ) -> None: + self.tools_root = tools_root + self.logs_root = logs_root + self.lxc_profile = lxc_profile + + # ── Container lifecycle ────────────────────────────────────────── + + def create( + self, + container_name: str, + image: str = "ubuntu:22.04", + config: dict[str, Any] | None = None, + ) -> str: + """Create a new LXC container. + + Parameters + ---------- + container_name : + Name for the new container. + image : + LXC image template (e.g. ``"ubuntu:22.04"``, + ``"alpine"``, ``"archlinux"``). + config : + Optional dict of LXC config key-value pairs that are + written to the container's local config file after + creation. + + Returns + ------- + Description of what was done. + """ + cmd = [ + "lxc-create", + "-n", container_name, + "-t", image.split(":")[0], + "--", image.split(":")[1] if ":" in image else "", + ] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except FileNotFoundError: + return self._install_hint("lxc-create") + + # Apply custom config if provided + if config: + self._apply_config(container_name, config) + + return f"LXC container '{container_name}' created from {image}" + + def start(self, container_name: str) -> str: + """Start an LXC container.""" + cmd = ["lxc-start", "-n", container_name, "-d"] # -d = daemonize + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except FileNotFoundError: + return self._install_hint("lxc-start") + except subprocess.CalledProcessError as e: + return f"LXC start failed: {e.stderr.strip()}" + return f"LXC container '{container_name}' started" + + def stop(self, container_name: str) -> str: + """Stop a running LXC container.""" + cmd = ["lxc-stop", "-n", container_name, "-t", "5"] # 5s timeout + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except FileNotFoundError: + return self._install_hint("lxc-stop") + except subprocess.CalledProcessError as e: + return f"LXC stop failed: {e.stderr.strip()}" + return f"LXC container '{container_name}' stopped" + + def destroy(self, container_name: str) -> str: + """Destroy (remove) an LXC container.""" + cmd = ["lxc-destroy", "-n", container_name] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except FileNotFoundError: + return self._install_hint("lxc-destroy") + except subprocess.CalledProcessError as e: + return f"LXC destroy failed: {e.stderr.strip()}" + return f"LXC container '{container_name}' destroyed" + + def freeze(self, container_name: str) -> str: + """Freeze (pause) a running container without stopping it.""" + cmd = ["lxc-freeze", "-n", container_name] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except (FileNotFoundError, subprocess.CalledProcessError) as e: + return f"LXC freeze failed: {getattr(e, 'stderr', str(e))}" + return f"LXC container '{container_name}' frozen" + + def unfreeze(self, container_name: str) -> str: + """Unfreeze (resume) a paused container.""" + cmd = ["lxc-unfreeze", "-n", container_name] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except (FileNotFoundError, subprocess.CalledProcessError) as e: + return f"LXC unfreeze failed: {getattr(e, 'stderr', str(e))}" + return f"LXC container '{container_name}' resumed" + + # ── Status / inspection ─────────────────────────────────────────── + + def is_running(self, container_name: str) -> bool: + """Check if a container is currently running.""" + cmd = ["lxc-info", "-n", container_name, "-s"] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=5 + ) + return "RUNNING" in result.stdout.upper() + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + def get_state(self, container_name: str) -> str: + """Return the container state (RUNNING, STOPPED, FROZEN).""" + cmd = ["lxc-info", "-n", container_name, "-s"] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=5 + ) + for state in ("RUNNING", "STOPPED", "FROZEN"): + if state in result.stdout.upper(): + return state + return "UNKNOWN" + except (FileNotFoundError, subprocess.TimeoutExpired): + return "UNKNOWN" + + def list_containers(self, running_only: bool = False) -> list[str]: + """List container names. + + Parameters + ---------- + running_only : + If ``True`` only return running containers. + + Returns + ------- + List of container name strings. + """ + cmd = ["lxc-ls"] + if running_only: + cmd.append("--running") + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=5 + ) + return [ + name.strip() for name in result.stdout.strip().splitlines() + if name.strip() + ] + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + + # ── Execution ──────────────────────────────────────────────────── + + def attach_exec( + self, + container_name: str, + command: str = "/bin/bash", + ) -> str: + """Execute a command inside a running container (non-interactive).""" + cmd = ["lxc-attach", "-n", container_name, "--", *command.split()] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except (FileNotFoundError, subprocess.CalledProcessError) as e: + return f"LXC attach failed: {getattr(e, 'stderr', str(e))}" + return f"Executed in '{container_name}': {command}" + + def launch_cli( + self, + container_name: str, + ) -> str: + """Open an interactive terminal inside the container. + + Launches an x-terminal-emulator with ``lxc-attach``. + """ + import shutil + term = shutil.which("x-terminal-emulator") or shutil.which("gnome-terminal") or shutil.which("konsole") + if not term: + return "No terminal emulator found for LXC CLI attach." + + subprocess.Popen( + [term, "-e", "bash", "-c", + f"lxc-attach -n {container_name} --clear-env -- " + f"env TERM=xterm-256color /bin/bash"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return f"Interactive terminal opened for '{container_name}'" + + # ── Config helpers ────────────────────────────────────────────── + + def _apply_config( + self, + container_name: str, + config: dict[str, Any], + ) -> None: + """Append config key-value pairs to a container's config file.""" + config_path = Path("/var/lib/lxc") / container_name / "config" + if not config_path.exists(): + return + + lines = [] + for key, value in config.items(): + if isinstance(value, (list, tuple)): + lines.append(f"lxc.{key} = {','.join(str(v) for v in value)}") + else: + lines.append(f"lxc.{key} = {value}") + + with open(config_path, "a") as f: + f.write("\n# ai-lsc generated\n") + for line in lines: + f.write(f"{line}\n") + + @staticmethod + def _install_hint(cmd: str) -> str: + return ( + f"LXC not installed. Run: sudo pacman -S lxc\n" + f"Missing command: {cmd}" + ) + + # ── Service delegation (mirrors TmuxManager interface) ─────────── + + def launch_service( + self, + tool_id: str, + command: str, + log_file: str = "", + dtach_bin: str | None = None, + base_bin_dir: str = "", + ) -> str: + """Start a tool as an LXC container service. + + Creates the container if it does not exist, starts it, + and runs the tool command inside. The container is named + ``ai-lsc-`` for consistency. + + Returns a description of what was done. + """ + container_name = f"ai-lsc-{tool_id}" + + if not self.is_running(container_name): + if container_name not in self.list_containers(): + self.create( + container_name, + image="ubuntu:22.04", + config={ + "mount.auto": f"{self.tools_root} opt none bind 0 0", + }, + ) + self.start(container_name) + + # Run the tool command inside the container + if command: + self.attach_exec(container_name, command) + + return f"Tool {tool_id} running in LXC container '{container_name}'" + + def stop_service(self, tool_id: str) -> str: + """Stop the LXC container for a tool.""" + container_name = f"ai-lsc-{tool_id}" + self.stop(container_name) + return f"LXC container '{container_name}' stopped for {tool_id}" diff --git a/src/ai_lsc/runtime/process.py b/src/ai_lsc/runtime/process.py new file mode 100644 index 0000000..a38b500 --- /dev/null +++ b/src/ai_lsc/runtime/process.py @@ -0,0 +1,58 @@ +"""Generic process manager. + +Handles desktop-app launching, process killing via ``pkill``, and +bare ``subprocess.Popen`` calls for non-tmux/non-systemd tools. +""" + +from __future__ import annotations + +import shutil +import subprocess + +# Terminal emulator candidates, ordered by preference. +_TERMINALS = [ + "xterm", "konsole", "gnome-terminal", "xfce4-terminal", + "lxterminal", "alacritty", "kitty", "wezterm", +] + + +def detect_terminal() -> str: + """Return the first available terminal emulator on the system.""" + for term in _TERMINALS: + if shutil.which(term): + return term + return "xterm" + + +class ProcessManager: + """Launch and terminate generic (desktop / CLI) processes.""" + + def launch_desktop(self, command: str) -> None: + """Fire-and-forget launch of a desktop command.""" + subprocess.Popen(command, shell=True) + + def launch_terminal(self, command: str, env: dict[str, str] | None = None) -> None: + """Open *command* inside a new terminal emulator window.""" + term = detect_terminal() + # xterm, alacritty, kitty, wezterm use -e; others use -- + sep = "-e" if term in ("xterm", "alacritty", "kitty", "wezterm") else "--" + subprocess.Popen( + f"{term} {sep} bash -c '{command}'", + shell=True, + env=env, + ) + + def kill_by_name(self, search_term: str) -> None: + """Send SIGTERM to all processes matching *search_term*.""" + subprocess.run( + f"pkill -f {search_term} 2>/dev/null", shell=True, + ) + + def docker_compose_down(self, compose_path: str, timeout: float = 10.0) -> None: + """Run ``docker compose down`` on a given compose file.""" + subprocess.run( + f"docker compose -f {compose_path} down", + shell=True, + timeout=timeout, + capture_output=True, + ) diff --git a/src/ai_lsc/runtime/status.py b/src/ai_lsc/runtime/status.py new file mode 100644 index 0000000..9dedbbf --- /dev/null +++ b/src/ai_lsc/runtime/status.py @@ -0,0 +1,50 @@ +"""Status checker -- unified interface for checking if a service is live. + +Delegates to backend-specific strategies (tmux, systemd, or psutil +process scan) based on the launcher type. +""" + +from __future__ import annotations + +from ai_lsc.runtime.systemd import SystemdManager +from ai_lsc.runtime.tmux import TmuxManager +from ai_lsc.utils.process import first_matching_process + + +class StatusChecker: + """Check service liveness regardless of launcher backend.""" + + def __init__(self) -> None: + self._tmux = TmuxManager() + self._systemd = SystemdManager() + + def is_running( + self, + launcher_type: str, + tool_id: str, + service_cmd: str = "", + search_term: str = "", + ) -> bool: + """Return ``True`` if the service is currently running. + + Parameters + ---------- + launcher_type: + One of ``"tmux"``, ``"systemd"``, or anything else (psutil). + tool_id: + Used by tmux to identify the window. + service_cmd: + Used by systemd ``is-active``. + search_term: + Used by psutil ``process_iter`` scan. + """ + strategy = { + "systemd": lambda: self._systemd.is_active(service_cmd), + "tmux": lambda: self._tmux.is_running(tool_id), + }.get(launcher_type) + + if strategy is not None: + return strategy() + + # fallback: psutil scan + return first_matching_process(search_term) is not None diff --git a/src/ai_lsc/runtime/systemd.py b/src/ai_lsc/runtime/systemd.py new file mode 100644 index 0000000..6db059d --- /dev/null +++ b/src/ai_lsc/runtime/systemd.py @@ -0,0 +1,44 @@ +"""Systemd service manager. + +Wraps ``systemctl`` calls for tools that launch as system services. +""" + +from __future__ import annotations + +import subprocess + +from ai_lsc.runtime.process import detect_terminal + + +class SystemdManager: + """Start, stop, and query systemd services.""" + + def start(self, service_cmd: str) -> None: + """Enable + start a systemd service via a terminal emulator.""" + term = detect_terminal() + sep = "-e" if term in ("xterm", "alacritty", "kitty", "wezterm") else "--" + subprocess.Popen( + f"{term} {sep} sudo systemctl start {service_cmd}", + shell=True, + ) + + def stop(self, service_cmd: str) -> None: + """Stop a systemd service via a terminal emulator.""" + term = detect_terminal() + sep = "-e" if term in ("xterm", "alacritty", "kitty", "wezterm") else "--" + subprocess.Popen( + f"{term} {sep} sudo systemctl stop {service_cmd}", + shell=True, + ) + + def is_active(self, service_cmd: str) -> bool: + """Return ``True`` if the service reports 'active'.""" + return ( + subprocess.run( + f"systemctl is-active {service_cmd}", + shell=True, + capture_output=True, + text=True, + ).stdout.strip() + == "active" + ) diff --git a/src/ai_lsc/runtime/tmux.py b/src/ai_lsc/runtime/tmux.py new file mode 100644 index 0000000..f19170a --- /dev/null +++ b/src/ai_lsc/runtime/tmux.py @@ -0,0 +1,147 @@ +"""Tmux session/window manager. + +Wraps all ``tmux`` CLI interactions: session creation, window +management, command sending, and live-window querying. + +Every public method returns a value or raises -- no UI imports. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +class TmuxManager: + """Manages tmux sessions and windows for service isolation.""" + + SESSION = "ai_lsc" + + def __init__(self, env: dict[str, str] | None = None) -> None: + self.env = env + + # -- session lifecycle ------------------------------------------------ + + def ensure_session(self) -> None: + """Create the master session if it does not already exist.""" + subprocess.run( + f"tmux has-session -t {self.SESSION} 2>/dev/null " + f"|| tmux new-session -d -s {self.SESSION} -n 'Master' " + f"2>/dev/null", + shell=True, + ) + + def window_exists(self, window_name: str) -> bool: + """Check if a named window exists in the session.""" + return ( + subprocess.run( + f"tmux list-windows -t {self.SESSION} 2>/dev/null " + f"| grep '{window_name}'", + shell=True, + capture_output=True, + ).returncode + == 0 + ) + + def kill_window(self, window_name: str) -> None: + """Safely kill a window (ignores errors if missing).""" + subprocess.run( + f"tmux kill-window -t {self.SESSION}:'{window_name}' 2>/dev/null", + shell=True, + ) + + def create_window(self, window_name: str) -> None: + """Create a new detached window inside the session. + + Retries up to 5 times with a short sleep when tmux reports + an index collision (happens when concurrent launches race). + """ + import time + + for attempt in range(5): + proc = subprocess.run( + f"tmux new-window -t {self.SESSION} -n '{window_name}' -d 2>&1", + shell=True, + capture_output=True, + text=True, + ) + if proc.returncode == 0: + return + # If window already exists with this name, that's fine too + if self.window_exists(window_name): + return + # Index collision — wait and retry + if "index" in proc.stderr and "in use" in proc.stderr: + time.sleep(0.1 * (attempt + 1)) + continue + break # some other error, stop retrying + + def send_command( + self, + window_name: str, + command: str, + extra_env: str = "", + ) -> None: + """Send a command string to a window, optionally prepending env.""" + payload = f"{extra_env} {command}" if extra_env else command + subprocess.run( + f"tmux send-keys -t {self.SESSION}:'{window_name}' " + f"'{payload}' C-m 2>/dev/null", + shell=True, + ) + + # -- high-level service lifecycle ------------------------------------- + + def launch_service( + self, + tool_id: str, + command: str, + log_file: str, + dtach_bin: str | None = None, + base_bin_dir: str = "", + ) -> None: + """Isolate a service in its own tmux window (optionally via dtach). + + Parameters + ---------- + tool_id: + Service identifier used as the window name. + command: + Shell command to run inside the window. + log_file: + Path where stdout/stderr should be redirected. + dtach_bin: + Path to dtach binary for persistent attach/detach. + base_bin_dir: + PATH colon-separated string to prepend. + """ + window_name = f"{self.SESSION}::{tool_id}" + track_cmd = f"({command}) > {log_file} 2>&1" + + wrapped = track_cmd + if dtach_bin: + socket_path = f"/tmp/{tool_id}.sock" + wrapped = f"{dtach_bin} -n {socket_path} bash -c '{track_cmd}'" + + self.ensure_session() + self.kill_window(window_name) + self.create_window(window_name) + + env_exports = "" + if base_bin_dir: + env_exports = f"export PATH={base_bin_dir}:$PATH; " + + self.send_command(window_name, wrapped, extra_env=env_exports) + + def stop_service(self, tool_id: str) -> None: + """Kill the tmux window for a service.""" + window_name = f"{self.SESSION}::{tool_id}" + self.kill_window(window_name) + + def is_running(self, tool_id: str) -> bool: + """Check whether the tmux window for *tool_id* is live.""" + return self.window_exists(f"{self.SESSION}::{tool_id}") + + def attach_cli(self, tool_id: str) -> str: + """Return a shell fragment that attaches to the service window.""" + return f"tmux attach -t {self.SESSION}:'{self.SESSION}::{tool_id}' || " diff --git a/src/ai_lsc/service/__init__.py b/src/ai_lsc/service/__init__.py new file mode 100644 index 0000000..f8d9a4b --- /dev/null +++ b/src/ai_lsc/service/__init__.py @@ -0,0 +1 @@ +"""AI-LSC service management sub-package.""" diff --git a/src/ai_lsc/skills/__init__.py b/src/ai_lsc/skills/__init__.py new file mode 100644 index 0000000..4df1ee9 --- /dev/null +++ b/src/ai_lsc/skills/__init__.py @@ -0,0 +1 @@ +"""AI-LSC skills sub-package.""" diff --git a/src/ai_lsc/skills/agent-orchestrator.skill.json b/src/ai_lsc/skills/agent-orchestrator.skill.json new file mode 100644 index 0000000..4fa9b70 --- /dev/null +++ b/src/ai_lsc/skills/agent-orchestrator.skill.json @@ -0,0 +1,32 @@ +{ + "name": "agent-orchestrator", + "description": "Full 7-layer agentic orchestration: route, load skills, clarify intent, plan, orchestrate tools, spawn subagents, enforce quality", + "required_tools": ["ollama", "redis"], + "triggers": [ + "orchestrate pipeline", + "set up agentic workflow", + "autonomous task", + "multi-step agent", + "plan and execute" + ], + "input_schema": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The high-level task description" + }, + "complexity": { + "type": "string", + "enum": ["simple", "moderate", "complex"], + "description": "Expected task complexity level" + }, + "allow_subagents": { + "type": "boolean", + "description": "Whether to spawn subagents for parallelizable subtasks" + } + }, + "required": ["task"] + }, + "category": "orchestration" +} diff --git a/src/ai_lsc/skills/code-reviewer.skill.json b/src/ai_lsc/skills/code-reviewer.skill.json new file mode 100644 index 0000000..9518bfd --- /dev/null +++ b/src/ai_lsc/skills/code-reviewer.skill.json @@ -0,0 +1,33 @@ +{ + "name": "code-reviewer", + "description": "Review code for bugs, style issues, and architectural problems using multi-model analysis", + "required_tools": ["ollama"], + "triggers": [ + "review code", + "check for bugs", + "code quality", + "lint analysis", + "refactor suggestion" + ], + "input_schema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the file or directory to review" + }, + "focus": { + "type": "string", + "enum": ["bugs", "style", "architecture", "security", "performance", "all"], + "description": "Review focus area" + }, + "severity": { + "type": "string", + "enum": ["critical", "warning", "info", "all"], + "description": "Minimum severity level to report" + } + }, + "required": ["file_path"] + }, + "category": "development" +} diff --git a/src/ai_lsc/skills/rag-analyst.skill.json b/src/ai_lsc/skills/rag-analyst.skill.json new file mode 100644 index 0000000..0163942 --- /dev/null +++ b/src/ai_lsc/skills/rag-analyst.skill.json @@ -0,0 +1,31 @@ +{ + "name": "rag-analyst", + "description": "Analyze documents using RAG pipeline with vector similarity search", + "required_tools": ["qdrant", "ollama"], + "triggers": [ + "analyze document", + "search knowledge base", + "find similar", + "rag query", + "vector search" + ], + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to run against the vector database" + }, + "collection": { + "type": "string", + "description": "Vector collection name to search in" + }, + "top_k": { + "type": "integer", + "description": "Number of results to return" + } + }, + "required": ["query"] + }, + "category": "analysis" +} diff --git a/src/ai_lsc/skills/redis-operator.skill.json b/src/ai_lsc/skills/redis-operator.skill.json new file mode 100644 index 0000000..161ca04 --- /dev/null +++ b/src/ai_lsc/skills/redis-operator.skill.json @@ -0,0 +1,32 @@ +{ + "name": "redis-operator", + "description": "Manage Redis hot-path operations: task queues, pub/sub events, status caching, agent state, distributed locks", + "required_tools": ["redis"], + "triggers": [ + "enqueue task", + "publish event", + "cache status", + "agent queue", + "task coordination" + ], + "input_schema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["enqueue", "dequeue", "publish", "cache_status", "get_status", "acquire_lock", "release_lock"], + "description": "The Redis operation to perform" + }, + "queue_name": { + "type": "string", + "description": "Queue name for enqueue/dequeue operations" + }, + "data": { + "type": "object", + "description": "Payload data for the operation" + } + }, + "required": ["operation"] + }, + "category": "orchestration" +} diff --git a/src/ai_lsc/skills/resolver.py b/src/ai_lsc/skills/resolver.py new file mode 100644 index 0000000..68e5741 --- /dev/null +++ b/src/ai_lsc/skills/resolver.py @@ -0,0 +1,62 @@ +""" +AI-LSC — Skill runtime resolver. + +Scans the on-disk skill definition files under ``/skills/`` +and extracts their ``SYSTEM`` blocks (the system prompt injected into +Ollama modelfiles). This is pure string/regex work — no UI. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +class SkillRuntimeResolver: + """Parses skill definitions to extract runtime system prompts. + + Each skill is a plain-text file whose ``SYSTEM`` block is either + a triple-quoted string or a single-quoted string. The resolver + searches for these patterns and returns the first match. + + Parameters + ---------- + skills_root : + Absolute path to the directory containing skill definition files. + """ + + _PATTERNS: list[tuple[str, re.RegexFlag]] = [ + (r'SYSTEM\s+"""(.*?)"""', re.DOTALL | re.IGNORECASE), + (r'SYSTEM\s+"(.*?)"', re.IGNORECASE), + ] + + def __init__(self, skills_root: str | Path) -> None: + self.skills_root = Path(skills_root) + + def extract_system_prompt(self, skill_name: str) -> str: + """Return the SYSTEM block from *skill_name*, or ``""``.""" + path = self.skills_root / skill_name + if not path.exists(): + return "" + + try: + content = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return "" + + return next( + (m.group(1).strip() + for pattern, flags in self._PATTERNS + for m in [re.search(pattern, content, flags)] + if m), + "", + ) + + def list_available_skills(self) -> list[str]: + """Return sorted names of all files in the skills directory.""" + if not self.skills_root.is_dir(): + return [] + return sorted( + p.name for p in self.skills_root.iterdir() + if p.is_file() + ) diff --git a/src/ai_lsc/skills/stack-operator.skill.json b/src/ai_lsc/skills/stack-operator.skill.json new file mode 100644 index 0000000..2991261 --- /dev/null +++ b/src/ai_lsc/skills/stack-operator.skill.json @@ -0,0 +1,29 @@ +{ + "name": "stack-operator", + "description": "Orchestrate AI-LSC managed services — start, stop, monitor, and configure the tool stack", + "required_tools": ["ollama", "redis"], + "triggers": [ + "start the stack", + "stop service", + "check status", + "pull model", + "set up pipeline", + "deploy stack" + ], + "input_schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["start", "stop", "status", "pull", "configure"], + "description": "The operation to perform" + }, + "target": { + "type": "string", + "description": "Tool ID or model name to operate on" + } + }, + "required": ["action", "target"] + }, + "category": "orchestration" +} diff --git a/src/ai_lsc/skills/vector-search.skill.json b/src/ai_lsc/skills/vector-search.skill.json new file mode 100644 index 0000000..98c097f --- /dev/null +++ b/src/ai_lsc/skills/vector-search.skill.json @@ -0,0 +1,37 @@ +{ + "name": "vector-search", + "description": "Semantic vector search using Qdrant: create collections, upsert documents, similarity search, skill matching, RAG retrieval", + "required_tools": ["qdrant", "ollama"], + "triggers": [ + "vector search", + "find similar", + "semantic search", + "rag retrieval", + "index documents", + "search knowledge" + ], + "input_schema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["create_collection", "search", "upsert", "index_skills", "delete_points"], + "description": "The vector operation to perform" + }, + "collection": { + "type": "string", + "description": "Collection name to operate on" + }, + "query": { + "type": "string", + "description": "Search query text" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results" + } + }, + "required": ["operation", "collection"] + }, + "category": "analysis" +} diff --git a/src/ai_lsc/stack/__init__.py b/src/ai_lsc/stack/__init__.py new file mode 100644 index 0000000..71aa892 --- /dev/null +++ b/src/ai_lsc/stack/__init__.py @@ -0,0 +1 @@ +"""AI-LSC stack sub-package.""" diff --git a/src/ai_lsc/stack/export.py b/src/ai_lsc/stack/export.py new file mode 100644 index 0000000..25f7739 --- /dev/null +++ b/src/ai_lsc/stack/export.py @@ -0,0 +1,327 @@ +""" +AI-LSC -- Stack export and container backend. + +Contains pure-logic functions for: + +* **build_stack_spec** -- serialises the current pipeline state plus + registry metadata into a portable JSON spec. +* **ContainerBackend** -- generates Podman/Docker compose YAML, + LXC container configs, or JSON fallback from that spec. + +No UI code here. All path operations use :mod:`pathlib`. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +from ai_lsc.constants import BASE_DIR, STACK_SCHEMA_VERSION +from ai_lsc.registry.manager import RegistryManager +from ai_lsc.utils.paths import build_path_tree + + +def build_stack_spec( + state: dict[str, Any], + registry: RegistryManager, + backend: str = "podman", +) -> dict[str, Any]: + """Build a portable stack spec from the current state + registry. + + The resulting dict is JSON-serialisable and can be passed to + ``ContainerBackend`` for compose-file / config generation. + + Parameters + ---------- + state : + Pipeline state dict (must contain ``active_tools`` and + optionally ``port_map``). + registry : + A loaded ``RegistryManager`` instance. + backend : + Container backend type (``"podman"``, ``"docker"``, or ``"lxc"``). + """ + tools = [ + { + "id": tid, + "name": registry.get_tool(tid).get("name"), + "layer": registry.get_tool(tid).get("layer"), + "role": registry.get_tool(tid).get("role"), + "category": registry.get_tool(tid).get("category"), + "launcher": registry.get_tool(tid).get("launcher"), + "installer": registry.get_tool(tid).get("installer"), + "deps": registry.get_tool(tid).get("deps", []), + } + for tid in state.get("active_tools", []) + if not tid.startswith("skill:") + ] + + return { + "schema": STACK_SCHEMA_VERSION, + "created": datetime.now().isoformat(), + "backend": backend, + "tools": tools, + "ports": state.get("port_map", {}), + "base_dir": BASE_DIR, + } + + +class ContainerBackend: + """Generates container deployment files from a stack spec. + + Supports three backends: + + * **podman/docker** -- ``docker-compose.yaml`` (version 3.8) + * **lxc** -- per-container config files + launch script + + Parameters + ---------- + exports_root : + Directory where output files are written. + """ + + def __init__(self, exports_root: str | Path) -> None: + self.exports_root = Path(exports_root) + + # ── Compose (Podman / Docker) ────────────────────────────────────── + + def generate_compose_yaml(self, spec: dict) -> dict: + """Transform a stack spec into a compose-file data structure. + + Each tool becomes a service with ``network_mode: host``, + ``restart: unless-stopped``, and the base directory mounted as a + volume. The launcher command's ``{placeholders}`` are resolved + to absolute paths. + """ + paths = build_path_tree(spec.get("base_dir", BASE_DIR)) + services: dict[str, dict] = {} + + for tool in spec.get("tools", []): + raw_cmd = tool.get("launcher", {}).get("cmd", "") + svc: dict[str, Any] = { + "image": f"localhost/ai-lsc-{tool['id']}:latest", + "network_mode": "host", + "restart": "unless-stopped", + "volumes": [f"{paths['base_dir']}:{paths['base_dir']}"], + } + clean_cmd = ( + raw_cmd + .replace("{base_dir}", str(paths["base_dir"])) + .replace("{tools_root}", str(paths["tools_root"])) + .replace("{models_root}", str(paths["models_root"])) + .replace("{workspaces_root}", str(paths["workspaces_root"])) + ) + if clean_cmd.strip(): + svc["command"] = clean_cmd + services[tool["id"]] = svc + + return {"version": "3.8", "services": services} + + def write_compose( + self, + spec: dict, + backend_type: str = "podman", + ) -> Path: + """Write compose YAML (or JSON fallback) to disk. + + Returns the path of the written file. + """ + self.exports_root.mkdir(parents=True, exist_ok=True) + file_path = self.exports_root / f"{backend_type}-compose.yml" + compose_data = self.generate_compose_yaml(spec) + + try: + import yaml + file_path.write_text( + yaml.dump(compose_data, sort_keys=False), + encoding="utf-8", + ) + except ImportError: + file_path.write_text( + json.dumps(compose_data, indent=4), + encoding="utf-8", + ) + + return file_path + + # ── LXC backend ───────────────────────────────────────────────────── + + def generate_lxc_configs(self, spec: dict) -> dict[str, str]: + """Generate LXC config blocks for each tool in the stack. + + Returns a dict mapping container names to their config file + content. Each config block includes: + + * Base OS template (default: ``ubuntu:22.04``) + * Network mode (``lxc.net.0.type = veth`` or ``none``) + * Mount points for the base directory hierarchy + * Autostart flag + * Tool-specific command to execute on start + """ + paths = build_path_tree(spec.get("base_dir", BASE_DIR)) + configs: dict[str, str] = {} + + for tool in spec.get("tools", []): + container_name = f"ai-lsc-{tool['id']}" + raw_cmd = tool.get("launcher", {}).get("cmd", "") + + # Resolve placeholders in the command + clean_cmd = ( + raw_cmd + .replace("{base_dir}", str(paths["base_dir"])) + .replace("{tools_root}", str(paths["tools_root"])) + .replace("{models_root}", str(paths["models_root"])) + .replace("{workspaces_root}", str(paths["workspaces_root"])) + ) + + lines = [ + f"# AI-LSC auto-generated LXC config for {tool['id']}", + f"# Container: {container_name}", + f"# Tool: {tool.get('name', tool['id'])}", + "", + "# -- Basic container settings --", + "lxc.uts.name = " + container_name, + "lxc.init.cmd = /bin/bash", + "", + "# -- Network --", + "lxc.net.0.type = veth", + "lxc.net.0.flags = up", + "lxc.net.0.link = lxcbr0", + "", + "# -- Mounts --", + f"lxc.mount.auto = proc:mixed sys:ro", + f"lxc.rootfs.mount = {paths['base_dir']}/containers/{container_name}/rootfs", + f"# Mount AI stack base directory", + f"lxc.mount.entry = {paths['base_dir']} {paths['base_dir']} none bind,rw 0 0", + f"# Mount tools directory", + f"lxc.mount.entry = {paths['tools_root']} {paths['tools_root']} none bind,rw 0 0", + f"# Mount models directory", + f"lxc.mount.entry = {paths['models_root']} {paths['models_root']} none bind,rw 0 0", + "", + "# -- Autostart --", + "lxc.start.auto = 1", + "lxc.start.delay = 5", + "", + "# -- Tool launch command --", + f"lxc.execute.post = /usr/bin/env PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin {clean_cmd}" if clean_cmd.strip() else "", + ] + + configs[container_name] = "\n".join( + line for line in lines if line + ) + + return configs + + def write_lxc( + self, + spec: dict, + ) -> Path: + """Write LXC configs and a launch script to disk. + + Creates: + * ``/lxc/`` -- per-container config files + * ``/lxc-launch.sh`` -- shell script to create + and start all containers + + Returns the path of the launch script. + """ + lxc_dir = self.exports_root / "lxc" + lxc_dir.mkdir(parents=True, exist_ok=True) + + configs = self.generate_lxc_configs(spec) + container_names: list[str] = [] + + # Write individual config files + for container_name, config_text in configs.items(): + config_path = lxc_dir / f"{container_name}.conf" + config_path.write_text(config_text, encoding="utf-8") + container_names.append(container_name) + + # Generate launch script + script_lines = [ + "#!/usr/bin/env bash", + "# AI-LSC LXC Stack Launcher", + f"# Generated: {datetime.now().isoformat()}", + f"# Containers: {len(container_names)}", + f"# Backend: lxc", + "", + 'set -euo pipefail', + "", + "LXC_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"", + "", + "echo \"[AI-LSC] Creating LXC containers...\"", + "", + ] + + for container_name in container_names: + conf_file = f"{container_name}.conf" + script_lines.extend([ + f"if ! lxc-info -n {container_name} >/dev/null 2>&1; then", + f" echo \" Creating {container_name}...\"", + f" lxc-create -n {container_name} -t download -- -d ubuntu -r jammy -a amd64", + f" cp \"$LXC_DIR/{conf_file}\" /var/lib/lxc/{container_name}/config.d/ai-lsc.conf", + f"else", + f" echo \" {container_name} already exists, skipping creation\"", + f"fi", + "", + ]) + + script_lines.extend([ + "", + "echo \"[AI-LSC] Starting LXC containers...\"", + "", + ]) + + for container_name in container_names: + script_lines.append( + f"echo \" Starting {container_name}...\"" + ) + script_lines.append( + f"lxc-start -n {container_name} -d -F \"$LXC_DIR/{container_name}.conf\" 2>/dev/null || " + f"lxc-start -n {container_name} -d" + ) + script_lines.append("") + + script_lines.extend([ + "echo \"[AI-LSC] Stack launched. Use 'lxc-ls --running' to verify.\"", + f"echo \"[AI-LSC] {len(container_names)} containers active.\"", + "", + "# -- Stop command --", + f"echo ''", + f"echo 'To stop all containers:'", + f"for c in {' '.join(container_names)}; do", + f" echo \" lxc-stop -n $c -t 5\"", + f"done", + ]) + + launch_script = self.exports_root / "lxc-launch.sh" + launch_script.write_text("\n".join(script_lines), encoding="utf-8") + launch_script.chmod(0o755) + + return launch_script + + # ── Unified write (auto-selects backend) ────────────────────────── + + def write( + self, + spec: dict, + backend_type: str = "podman", + ) -> Path: + """Write deployment files for the specified backend. + + Parameters + ---------- + spec : + Stack specification dict (from ``build_stack_spec``). + backend_type : + One of ``"podman"``, ``"docker"``, or ``"lxc"``. + + Returns + ------- + Path to the primary output file. + """ + if backend_type == "lxc": + return self.write_lxc(spec) + return self.write_compose(spec, backend_type=backend_type) diff --git a/src/ai_lsc/types.py b/src/ai_lsc/types.py new file mode 100644 index 0000000..87dd781 --- /dev/null +++ b/src/ai_lsc/types.py @@ -0,0 +1,315 @@ +""" +AI-LSC — Typed data structures. + +Every module in the package should import types from here instead of passing +around raw ``dict`` objects. Each dataclass has a ``from_dict`` classmethod +so we can hydrate from existing JSON / registry data with zero breakage. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +# ── Enumerations ──────────────────────────────────────────────────────── + +class LauncherType(Enum): + """How a tool's process is managed.""" + SYSTEMD = "systemd" + TMUX = "tmux" + DESKTOP = "desktop" + LXC = "lxc" + + +class InstallerType(Enum): + """How a tool is installed on the host. + + Step-down containment order (most isolated first): + ollama → uv → pipx → pip → git → git_node → npm → pacman → script → custom + + - **ollama**: Native ollama pull / model management. + - **uv**: ``uv tool install`` with ``--install-dir`` pinned to + ``/mnt/AI/tools/`` for full isolation. + - **pipx**: ``pipx install`` with ``PIPX_BIN_DIR`` and + ``PIPX_HOME`` remapped into ``/mnt/AI/tools//.pipx``. + - **pip**: ``pip install --user --target`` into a per-tool venv + under ``/mnt/AI/tools//.venv``. + - **git**: ``git clone`` into ``/mnt/AI/tools/``. + - **git_node**: git clone + npm/yarn setup in ``/mnt/AI/tools/``. + - **npm**: ``npm install --prefix /mnt/AI/tools/``. + - **pacman**: System package (cannot relocate). + - **dnf**: Red Hat / Fedora system package (cannot relocate). + - **apt**: Debian / Ubuntu system package (cannot relocate). + - **script**: Arbitrary shell command (must reference {tools_root}). + - **custom**: Manual install — opens the install URL in browser. + """ + OLLAMA = "ollama" + UV = "uv" + PIPX = "pipx" + PIP = "pip" + NPM = "npm" + GIT = "git" + GIT_NODE = "git_node" + PACMAN = "pacman" + DNF = "dnf" + APT = "apt" + SCRIPT = "script" + CUSTOM = "custom" + + +# ── Tool metadata ─────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class InstallerSpec: + """Immutable description of how to install a tool.""" + type: InstallerType + pkg: str + cmd: str | None = None # only for "script" type + post_install: str | None = None # post-clone setup (pip install -r, make, etc.) + update_cmd: str | None = None # explicit update command (git pull, pip --upgrade, etc.) + env_overrides: tuple[tuple[str, str], ...] = () # per-tool env var remappings + + +@dataclass(frozen=True) +class FilesystemSpec: + """Declares where a tool's artifacts live relative to base_dir. + + All paths are relative and expanded against ``BASE_DIR`` + at runtime. This keeps the registry portable — change one setting + and every tool follows. + + Example:: + + fs = FilesystemSpec( + install="tools/vllm", + config="configs/vllm", + cache="cache/vllm", + logs="logs/vllm", + ) + """ + install: str = "" # Primary install dir (relative to base_dir) + config: str = "" # Configuration files + cache: str = "" # Download / build caches + data: str = "" # Runtime databases / state + logs: str = "" # Log files + runtime: str = "" # PID files, sockets, tmp runtime + models: str = "" # Model files (if tool has own models) + + +@dataclass(frozen=True) +class LauncherSpec: + """Immutable description of how to launch a tool.""" + type: LauncherType + cmd: str + default_port: int | None = None + + +@dataclass(frozen=True) +class ToolFlags: + """Structured boolean flags from the registry entry.""" + has_cli: bool = False + has_gui: bool = False + has_web: bool = False + is_ollama: bool = False + is_docker: bool = False + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> ToolFlags: + return cls( + has_cli=raw.get("has_cli", False), + has_gui=raw.get("has_gui", False), + has_web=raw.get("has_web", False), + is_ollama=raw.get("is_ollama", False), + is_docker=raw.get("is_docker", False), + ) + + +@dataclass(frozen=True) +class ToolMetadata: + """The strongly-typed representation of a single registry entry. + + Construction from a raw dict is lossy by design — unknown keys are + silently dropped so new optional fields added to the registry schema + do not break deserialization. + """ + tool_id: str + name: str + level: int + layer: str + role: str + category: str + installer: InstallerSpec + launcher: LauncherSpec + deps: tuple[str, ...] = () + description: str = "" + flags: ToolFlags = field(default_factory=ToolFlags) + filesystem: FilesystemSpec = field(default_factory=FilesystemSpec) + + @classmethod + def from_dict(cls, tool_id: str, raw: dict[str, Any]) -> ToolMetadata: + inst_raw = raw.get("installer", {}) + launch_raw = raw.get("launcher", {}) + fs_raw = raw.get("filesystem", {}) + + installer = InstallerSpec( + type=InstallerType(inst_raw.get("type", "pacman")), + pkg=inst_raw.get("pkg", ""), + cmd=inst_raw.get("cmd"), + post_install=inst_raw.get("post_install"), + update_cmd=inst_raw.get("update_cmd"), + env_overrides=tuple( + (k, v) for k, v in inst_raw.get("env_overrides", {}).items() + ), + ) + launcher = LauncherSpec( + type=LauncherType(launch_raw.get("type", "desktop")), + cmd=launch_raw.get("cmd", ""), + default_port=launch_raw.get("default_port"), + ) + flags = ToolFlags.from_dict(raw.get("flags", {})) + filesystem = FilesystemSpec( + install=fs_raw.get("install", ""), + config=fs_raw.get("config", ""), + cache=fs_raw.get("cache", ""), + data=fs_raw.get("data", ""), + logs=fs_raw.get("logs", ""), + runtime=fs_raw.get("runtime", ""), + models=fs_raw.get("models", ""), + ) + + return cls( + tool_id=tool_id, + name=raw.get("name", tool_id), + level=raw.get("level", 0), + layer=raw.get("layer", "Uncategorized"), + role=raw.get("role", ""), + category=raw.get("category", ""), + installer=installer, + launcher=launcher, + deps=tuple(raw.get("deps", [])), + description=raw.get("description", ""), + flags=flags, + filesystem=filesystem, + ) + + @property + def search_term(self) -> str: + """Default process name used for status polling.""" + return self.installer.pkg or self.tool_id + + @property + def is_skill(self) -> bool: + return self.tool_id.startswith("skill:") + + +# ── Installation verification ──────────────────────────────────────── + +@dataclass(frozen=True) +class VerifyCheck: + """A single verification check for a tool installation.""" + name: str + passed: bool + detail: str = "" + + +@dataclass +class VerificationResult: + """Complete verification result for a single tool. + + Produces a quality score 0–100 based on how many checks pass. + """ + tool_id: str + checks: list[VerifyCheck] = field(default_factory=list) + install_method: str = "" # how the tool was actually installed + install_location: str = "" # where it was found + + @property + def score(self) -> int: + """Quality score 0–100.""" + if not self.checks: + return 0 + passed = sum(1 for c in self.checks if c.passed) + return int((passed / len(self.checks)) * 100) + + @property + def summary(self) -> str: + lines = [f"{self.tool_id} — Score: {self.score}%"] + for c in self.checks: + status = "PASS" if c.passed else "FAIL" + lines.append(f" [{status}] {c.name}: {c.detail}") + return "\n".join(lines) + + +# ── Installation state ────────────────────────────────────────────────── + +@dataclass +class PreflightResult: + """Result of a pre-installation existence check. + + Returned by ``InstallerManager.preflight()`` so the UI can show + "already installed → update?" or "not found → install?". + """ + tool_id: str + found: bool = False + install_type: str = "" # InstallerType value that owns the tool + location: str = "" # Where the binary/artifact was detected + version: str = "" # Detected version string (if any) + is_update_available: bool = False + suggested_action: str = "install" # "install" | "update" | "none" + + @property + def summary(self) -> str: + if not self.found: + return f"{self.tool_id}: not found — ready to install" + action = "update available" if self.is_update_available else "up to date" + return f"{self.tool_id}: {self.version or 'installed'} at {self.location} ({action})" + + +# ── Runtime state ────────────────────────────────────────────────────── + +@dataclass +class ServiceState: + """Mutable snapshot of a single tool's runtime status.""" + tool_id: str + running: bool = False + pid: int | None = None + cpu_percent: float = 0.0 + + +@dataclass +class PipelineState: + """On-disk state representation (maps to ``pipeline_state.json``).""" + session_name: str = "ai_lsc" + base_dir: str = "" + active_tools: list[str] = field(default_factory=list) + port_map: dict[str, int | None] = field(default_factory=dict) + stack_ready: bool = False + compiled_pipelines: list[dict] = field(default_factory=list) + + def __post_init__(self) -> None: + if not self.base_dir: + from ai_lsc.constants import BASE_DIR + self.base_dir = BASE_DIR + + @classmethod + def from_dict(cls, raw: dict) -> PipelineState: + return cls( + session_name=raw.get("session_name", "ai_lsc"), + base_dir=raw.get("base_dir", ""), + active_tools=raw.get("active_tools", []), + port_map=raw.get("port_map", {}), + stack_ready=raw.get("stack_ready", False), + compiled_pipelines=raw.get("compiled_pipelines", []), + ) + + def to_dict(self) -> dict: + return { + "session_name": self.session_name, + "base_dir": self.base_dir, + "active_tools": self.active_tools, + "port_map": self.port_map, + "stack_ready": self.stack_ready, + "compiled_pipelines": self.compiled_pipelines, + } diff --git a/src/ai_lsc/ui/__init__.py b/src/ai_lsc/ui/__init__.py new file mode 100644 index 0000000..ea12ca4 --- /dev/null +++ b/src/ai_lsc/ui/__init__.py @@ -0,0 +1,39 @@ +"""AI-LSC UI sub-package. + +All PySide6-dependent UI widgets live here. If PySide6 is not installed +the sub-package still imports but the concrete widget classes will be ``None``. + +Sub-packages +------------ +pages + Individual tab/page widgets (ServiceRow, ChatbotConsole, etc.). +dialogs + Modal dialogs (StackWizard). +protocol + :class:`MainWindowProtocol` interface for decoupling pages from + the concrete main window. +""" + +from ai_lsc.ui.protocol import MainWindowProtocol # noqa: F401 + +# Lazy re-exports -- guarded so the package is importable without Qt. +try: + from ai_lsc.ui.main_window import ( + AILocalStackControl, # noqa: F401 + apply_terminal_theme, # noqa: F401 + ) +except ImportError: + AILocalStackControl = None # type: ignore[assignment, misc] + apply_terminal_theme = None # type: ignore[assignment, misc] + +try: + from ai_lsc.ui.dialogs.stack_wizard import StackWizard # noqa: F401 +except ImportError: + StackWizard = None # type: ignore[assignment, misc] + +__all__ = [ + "MainWindowProtocol", + "AILocalStackControl", + "apply_terminal_theme", + "StackWizard", +] diff --git a/src/ai_lsc/ui/dialogs/__init__.py b/src/ai_lsc/ui/dialogs/__init__.py new file mode 100644 index 0000000..4564a49 --- /dev/null +++ b/src/ai_lsc/ui/dialogs/__init__.py @@ -0,0 +1 @@ +"""AI-LSC UI dialogs sub-package.""" diff --git a/src/ai_lsc/ui/dialogs/stack_wizard.py b/src/ai_lsc/ui/dialogs/stack_wizard.py new file mode 100644 index 0000000..3892e6b --- /dev/null +++ b/src/ai_lsc/ui/dialogs/stack_wizard.py @@ -0,0 +1,562 @@ +"""StackWizard dialog -- metadata-driven component selector with +auto-dependency resolution and stack template support. + +Presented at first launch (no ``pipeline_state.json`` found) and on +demand via *Modify Stack State (Wizard)*. The user can either: + +1. **Select a stack template** -- pre-configured tool stacks (e.g. Claude + Code Setup, SaaS Integrations) that auto-populate the checkboxes. +2. **Manual selection** -- check individual tools from the 13-Layer + registry, as before. + +The wizard resolves missing dependencies, builds a port map, and +serialises the state to disk. +""" + +import json +import os + +from ai_lsc.constants import BASE_DIR, STATE_FILE_NAME +from ai_lsc.registry.manager import RegistryManager +from ai_lsc.registry.stack_templates.manager import StackTemplateManager + +try: + from PySide6.QtCore import Qt, Signal + from PySide6.QtGui import QFont, QIcon + from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QRadioButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class StackWizard(QDialog): + """Metadata-driven component selector with template and manual modes. + + Parameters + ---------- + parent : + Parent widget (typically the main window). + registry_mgr : + Loaded :class:`~ai_lsc.registry.manager.RegistryManager`. + config_root : + Directory containing ``pipeline_state.json``. + extra_template_dirs : + Optional additional directories to scan for custom stack + templates. + """ + + # Signal emitted when a template is applied (useful for logging) + template_applied = Signal(str, int) # template_name, tool_count + + def __init__( + self, + parent, + registry_mgr: RegistryManager, + config_root: str, + extra_template_dirs: list[str] | None = None, + ) -> None: + super().__init__(parent) + self.registry_mgr = registry_mgr + self.state_file = os.path.join(config_root, STATE_FILE_NAME) + self.setWindowTitle("AI-LSC Ecosystem Compiler") + self.setMinimumSize(1200, 800) + self.checkboxes: dict[str, QCheckBox] = {} + self._template_mgr = StackTemplateManager( + extra_dirs=extra_template_dirs + ) + self._current_mode = "template" # "template" or "manual" + self._build_ui() + + # ── UI construction ────────────────────────────────────────── + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + + # Title + title = QLabel("Select Native Ecosystem Components") + title.setFont(QFont("Segoe UI", 16, QFont.Bold)) + layout.addWidget(title) + + # ── Mode selector (template vs manual) ───────────────────── + mode_bar = QHBoxLayout() + self._radio_template = QRadioButton("Start from Stack Template") + self._radio_manual = QRadioButton("Manual Selection") + self._radio_template.setChecked(True) + self._radio_template.toggled.connect(self._on_mode_changed) + mode_bar.addWidget(self._radio_template) + mode_bar.addWidget(self._radio_manual) + mode_bar.addStretch() + layout.addLayout(mode_bar) + + # ── Template selector panel ──────────────────────────────── + self._template_panel = QWidget() + tpl_layout = QVBoxLayout(self._template_panel) + + tpl_label = QLabel("Choose a pre-configured stack template:") + tpl_label.setFont(QFont("Segoe UI", 11)) + tpl_layout.addWidget(tpl_label) + + tpl_row = QHBoxLayout() + self._template_combo = QComboBox() + self._template_combo.setMinimumWidth(400) + self._populate_template_combo() + tpl_row.addWidget(self._template_combo) + + self._apply_template_btn = QPushButton("Apply Stack Template") + self._apply_template_btn.setStyleSheet( + "background-color: #2980b9; padding: 8px 16px; " + "font-weight: bold; border-radius: 4px;" + ) + self._apply_template_btn.clicked.connect(self._apply_template) + tpl_row.addWidget(self._apply_template_btn) + tpl_layout.addLayout(tpl_row) + + # Template description label + self._template_desc = QLabel("") + self._template_desc.setWordWrap(True) + self._template_desc.setStyleSheet( + "color: #bdc3c7; padding: 8px; " + "background-color: #1a1a1a; border-radius: 4px;" + ) + self._template_desc.setMinimumHeight(60) + tpl_layout.addWidget(self._template_desc) + self._template_combo.currentIndexChanged.connect( + self._on_template_selected + ) + # Trigger initial description + if self._template_combo.count() > 0: + self._on_template_selected(0) + + # Template tags filter + tag_row = QHBoxLayout() + tag_label = QLabel("Filter by tag:") + tag_row.addWidget(tag_label) + self._tag_combo = QComboBox() + self._tag_combo.setMinimumWidth(200) + self._tag_combo.addItem("All") + self._populate_tag_combo() + self._tag_combo.currentTextChanged.connect( + self._on_tag_filter + ) + tag_row.addWidget(self._tag_combo) + tag_row.addStretch() + tpl_layout.addLayout(tag_row) + + layout.addWidget(self._template_panel) + + # ── Tool selection scroll area ───────────────────────────── + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding, + ) + scroll_content = QWidget() + grid = QGridLayout(scroll_content) + + prev_state = self._load_previous_state() + row, col = 0, 0 + for layer_name, tools in self.registry_mgr.get_grouped_by_layer().items(): + group = QGroupBox(layer_name) + vbox = QVBoxLayout() + for t_id, meta in tools: + chk = QCheckBox( + f"{meta.get('name', t_id)} ({meta.get('category', '')})" + ) + chk.setToolTip(meta.get("description", "")) + chk.setChecked(t_id in prev_state) + chk.setProperty("tool_id", t_id) + self.checkboxes[t_id] = chk + vbox.addWidget(chk) + group.setLayout(vbox) + grid.addWidget(group, row, col) + col += 1 + if col > 2: + col, row = 0, row + 1 + + scroll.setWidget(scroll_content) + layout.addWidget(scroll) + + # ── Status bar ─────────────────────────────────────────── + self._status_label = QLabel("") + self._status_label.setStyleSheet( + "color: #2ecc71; padding: 4px;" + ) + layout.addWidget(self._status_label) + + # ── Action buttons ──────────────────────────────────────── + btn_row = QHBoxLayout() + + self._create_tpl_btn = QPushButton( + "Create Stack Template" + ) + self._create_tpl_btn.setStyleSheet( + "background-color: #e67e22; padding: 10px 16px; " + "font-weight: bold; border-radius: 4px;" + ) + self._create_tpl_btn.setToolTip( + "Save the current tool selection as a reusable " + "stack template" + ) + self._create_tpl_btn.clicked.connect( + self._create_template_from_selection + ) + btn_row.addWidget(self._create_tpl_btn) + + self._clear_btn = QPushButton("Clear All") + self._clear_btn.setStyleSheet( + "background-color: #7f8c8d; padding: 10px; " + "border-radius: 4px;" + ) + self._clear_btn.clicked.connect(self._clear_all) + btn_row.addWidget(self._clear_btn) + + btn_row.addStretch() + + self._compile_btn = QPushButton( + "Serialize State Configuration" + ) + self._compile_btn.setStyleSheet( + "background-color: #27ae60; font-size: 14px; " + "padding: 12px 24px; font-weight: bold; border-radius: 4px;" + ) + self._compile_btn.clicked.connect(self.compile_state) + btn_row.addWidget(self._compile_btn) + + layout.addLayout(btn_row) + + # Update status on checkbox change + for chk in self.checkboxes.values(): + chk.toggled.connect(self._update_selection_count) + + self._update_selection_count() + + # ── Template combo helpers ──────────────────────────────────── + + def _populate_template_combo(self) -> None: + """Fill the template dropdown with all discovered templates.""" + self._template_combo.blockSignals(True) + self._template_combo.clear() + + for tpl in self._template_mgr.list_templates(): + source_tag = ( + " [custom]" if tpl["source"] == "custom" else "" + ) + label = ( + f"{tpl['name']} " + f"({tpl['tool_count']} tools{source_tag})" + ) + self._template_combo.addItem(label, tpl["id"]) + + self._template_combo.blockSignals(False) + + def _populate_tag_combo(self) -> None: + """Fill the tag filter dropdown.""" + all_tags: set[str] = set() + for tpl in self._template_mgr.list_templates(): + all_tags.update(t.lower() for t in tpl["tags"]) + for tag in sorted(all_tags): + self._tag_combo.addItem(tag) + + def _on_template_selected(self, index: int) -> None: + """Update the description when a template is selected.""" + tpl_id = self._template_combo.currentData() + if tpl_id: + tpl = self._template_mgr.get_template(tpl_id) + if tpl: + desc = tpl.get("description", "No description") + tags = ", ".join(tpl.get("tags", [])) + ver = tpl.get("version", "1.0") + count = len(tpl.get("tools", [])) + self._template_desc.setText( + f"[v{ver}] {desc}\n\n" + f"Tags: {tags} | Tools: {count}" + ) + + def _on_tag_filter(self, tag: str) -> None: + """Filter the template combo by the selected tag.""" + self._template_combo.blockSignals(True) + self._template_combo.clear() + + templates = ( + self._template_mgr.list_templates() + if tag == "All" + else self._template_mgr.filter_by_tag(tag) + ) + + for tpl in templates: + source_tag = ( + " [custom]" if tpl["source"] == "custom" else "" + ) + label = ( + f"{tpl['name']} " + f"({tpl['tool_count']} tools{source_tag})" + ) + self._template_combo.addItem(label, tpl["id"]) + + self._template_combo.blockSignals(False) + if self._template_combo.count() > 0: + self._on_template_selected(0) + + def _on_mode_changed(self, checked: bool) -> None: + """Toggle between template and manual mode.""" + if checked: + self._current_mode = "template" + self._template_panel.setVisible(True) + else: + self._current_mode = "manual" + self._template_panel.setVisible(True) # Keep visible + self._template_combo.setCurrentIndex(-1) + + # ── Template application ───────────────────────────────────── + + def _apply_template(self) -> None: + """Load the selected template and populate checkboxes.""" + tpl_id = self._template_combo.currentData() + if not tpl_id: + QMessageBox.warning( + self, "No Template", + "Please select a stack template first." + ) + return + + tool_ids, new_entries = self._template_mgr.resolve_tool_ids( + tpl_id, self.registry_mgr + ) + + # Clear all checkboxes first + for chk in self.checkboxes.values(): + chk.setChecked(False) + + # Check tools from the template + applied = 0 + for tid in tool_ids: + if tid in self.checkboxes: + self.checkboxes[tid].setChecked(True) + applied += 1 + + # Register new tools (git-source entries) with the registry + if new_entries: + for entry in new_entries: + tid = entry.get("id", "") + if tid and tid not in self.registry_mgr.data: + self.registry_mgr.data[tid] = entry + self.registry_mgr.registry_file.write_text( + json.dumps( + self.registry_mgr.data, indent=4 + ), + encoding="utf-8", + ) + + tpl = self._template_mgr.get_template(tpl_id) + tpl_name = tpl.get("name", tpl_id) if tpl else tpl_id + self.template_applied.emit(tpl_name, applied) + + QMessageBox.information( + self, + "Stack Template Applied", + f"Applied '{tpl_name}': {applied} tools selected" + f"{f', {len(new_entries)} new tools registered' if new_entries else ''}." + "\n\nReview the selection below, then click " + "'Serialize State Configuration'.", + ) + self._update_selection_count() + + def _create_template_from_selection(self) -> None: + """Save the currently checked tools as a new stack template.""" + selected = [ + tid for tid, chk in self.checkboxes.items() + if chk.isChecked() + ] + if not selected: + QMessageBox.warning( + self, "No Selection", + "Check at least one tool before creating a template." + ) + return + + # Prompt for template metadata + dlg = QDialog(self) + dlg.setWindowTitle("Create Stack Template") + dlg.setMinimumWidth(450) + dlg_layout = QVBoxLayout(dlg) + + form = QHBoxLayout() + form.addWidget(QLabel("Name:")) + txt_name = QLineEdit() + txt_name.setPlaceholderText( + "e.g. My Custom Stack" + ) + form.addWidget(txt_name) + dlg_layout.addLayout(form) + + form2 = QHBoxLayout() + form2.addWidget(QLabel("Tags:")) + txt_tags = QLineEdit() + txt_tags.setPlaceholderText( + "e.g. custom, experimental (comma-separated)" + ) + form2.addWidget(txt_tags) + dlg_layout.addLayout(form2) + + form3 = QHBoxLayout() + form3.addWidget(QLabel("Description:")) + txt_desc = QLineEdit() + txt_desc.setPlaceholderText( + "One-line description of this stack" + ) + form3.addWidget(txt_desc) + dlg_layout.addLayout(form3) + + dlg_layout.addWidget( + QLabel(f" {len(selected)} tools will be included.") + ) + + btn_box = QHBoxLayout() + btn_box.addStretch() + btn_save = QPushButton("Save Template") + btn_save.setStyleSheet( + "background-color: #27ae60; color: white; " + "font-weight: bold;" + ) + btn_cancel = QPushButton("Cancel") + btn_box.addWidget(btn_save) + btn_box.addWidget(btn_cancel) + dlg_layout.addLayout(btn_box) + + result = {"saved": False} + + def _do_save(): + name = txt_name.text().strip() + if not name: + QMessageBox.warning( + dlg, "Name Required", + "Enter a template name." + ) + return + tags = [ + t.strip() + for t in txt_tags.text().split(",") + if t.strip() + ] + desc = txt_desc.text().strip() + tpl = self._template_mgr.create_template( + name=name, + tools=selected, + description=desc, + tags=tags, + save_dir=os.path.join( + BASE_DIR, "skills", "stack-templates", "custom" + ), + ) + # Refresh the template combo + self._populate_template_combo() + result["saved"] = True + dlg.accept() + + btn_save.clicked.connect(_do_save) + btn_cancel.clicked.connect(dlg.reject) + dlg.exec() + + if result["saved"]: + QMessageBox.information( + self, + "Template Created", + f"Stack template saved with " + f"{len(selected)} tools.\n\n" + "It now appears in the template dropdown.", + ) + + # ── Selection helpers ────────────────────────────────────────── + + def _clear_all(self) -> None: + """Uncheck all tool checkboxes.""" + for chk in self.checkboxes.values(): + chk.setChecked(False) + self._update_selection_count() + + def _update_selection_count(self) -> None: + """Update the status bar with selection count.""" + count = sum(1 for c in self.checkboxes.values() if c.isChecked()) + total = len(self.checkboxes) + self._status_label.setText( + f"{count} of {total} tools selected" + ) + + # ── State persistence ────────────────────────────────────────── + + def _load_previous_state(self) -> set[str]: + if not os.path.exists(self.state_file): + return set() + try: + with open(self.state_file) as f: + return set( + json.load(f).get("active_tools", []) + ) + except Exception: + return set() + + def compile_state(self) -> None: + """Gather checked tools, resolve deps, write state file.""" + selected = [ + tid for tid, chk in self.checkboxes.items() + if chk.isChecked() + ] + missing = self.registry_mgr.check_dependencies(selected) + if missing: + dep_names = [ + self.registry_mgr.get_tool(d).get("name", d) + for d in missing + ] + msg = QMessageBox(self) + msg.setWindowTitle("Missing Dependencies Detected") + msg.setText( + f"Selected tools require:\n\n" + f"{', '.join(dep_names)}\n\nAuto-include them?" + ) + msg.setStandardButtons( + QMessageBox.Yes | QMessageBox.No + ) + if msg.exec() == QMessageBox.Yes: + selected.extend(missing) + for tid in missing: + if tid in self.checkboxes: + self.checkboxes[tid].setChecked(True) + + port_map = { + tid: self.registry_mgr.get_tool(tid) + .get("launcher", {}).get("default_port") + for tid in selected + } + state = { + "session_name": "ai_lsc", + "base_dir": BASE_DIR, + "active_tools": selected, + "port_map": port_map, + "stack_ready": True, + "source": self._current_mode, + } + os.makedirs(os.path.dirname(self.state_file), exist_ok=True) + with open(self.state_file, "w") as f: + json.dump(state, f, indent=4) + self.accept() + +else: + StackWizard = None # type: ignore[assignment, misc] diff --git a/src/ai_lsc/ui/main_window.py b/src/ai_lsc/ui/main_window.py new file mode 100644 index 0000000..92f0ac9 --- /dev/null +++ b/src/ai_lsc/ui/main_window.py @@ -0,0 +1,1204 @@ +"""AILocalStackControl main window -- master controller. + +Central QMainWindow that wires together every extracted page widget, +the sidebar navigation rack, the stacked-page workspace, the dashboard +lifecycle engine, service population, log watching, model discovery, +and config persistence. + +Implements :class:`~ai_lsc.ui.protocol.MainWindowProtocol` so that +child page widgets can type-check their parent dependency without +coupling to the concrete class. + +Coding standards inherited from the monolith: + - Max 2 levels of ``if`` depth; use guard clauses, early returns, + dispatch dictionaries, and arrays otherwise. + - No ``while`` loops: iterators, list comprehensions, generators, + ``next()``. + - Fluid array usage: lookup tables, ``next()`` over ``for/break``. +""" + +import json +import os +import subprocess +import threading +from datetime import datetime + +from ai_lsc.constants import ( + APP_DISPLAY_NAME, + APP_ICON_FILE, + BASE_DIR, + CONFIG_FILE, + GLOBAL_STYLE, + LOG_COLOR_DEFAULT, + LOG_SOURCE_COLORS, + NAV_LAYER_ORDER, + OLLAMA_SERVER_CANDIDATES, + PIPELINE_FILE_NAME, + REQUIRED_DIRS, + SIDEBAR_TREE_STYLE, + STATE_FILE_NAME, + TREE_SKIP_PATTERNS, +) +from ai_lsc.registry.manager import RegistryManager +from ai_lsc.runtime.executor import RuntimeExecutor +from ai_lsc.skills.resolver import SkillRuntimeResolver +from ai_lsc.stack.export import ContainerBackend, build_stack_spec +from ai_lsc.utils.ollama import ( + detect_ollama_server_dir, + ollama_models_dir, +) +from ai_lsc.utils.process import enriched_env, find_binary + +try: + from PySide6.QtCore import QFileSystemWatcher, Qt, QTimer + from PySide6.QtGui import QColor, QFont, QIcon, QPalette + from PySide6.QtWidgets import ( + QApplication, + QDialog, + QFrame, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QPushButton, + QScrollArea, + QStackedWidget, + QTableWidget, + QTextEdit, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +# Page widgets (guarded -- None when PySide6 absent) +try: + from ai_lsc.ui.dialogs.stack_wizard import StackWizard + from ai_lsc.ui.pages.chatbot_console import ChatbotConsole + from ai_lsc.ui.pages.container_stacks_tab import ContainerStacksTab + from ai_lsc.ui.pages.datasets_tab import DatasetsTab + from ai_lsc.ui.pages.git_worktree_tab import GitWorktreeTab + from ai_lsc.ui.pages.infrastructure_layer_page import ( + InfrastructureLayerPage, + ) + from ai_lsc.ui.pages.ipc_stack_tab import IpcStackTab + from ai_lsc.ui.pages.service_row import ServiceRow + from ai_lsc.ui.pages.settings_page import SettingsPage + from ai_lsc.ui.pages.skills_console import SkillsConsole + from ai_lsc.ui.pages.tools_tab import ToolsTab +except ImportError: + ServiceRow = None + SkillsConsole = None + DatasetsTab = None + ChatbotConsole = None + ToolsTab = None + IpcStackTab = None + ContainerStacksTab = None + InfrastructureLayerPage = None + SettingsPage = None + GitWorktreeTab = None + StackWizard = None + + +def apply_terminal_theme() -> None: + """Apply Fusion dark palette to the entire application.""" + app = QApplication.instance() + app.setStyle("Fusion") + palette = QPalette() + palette.setColor(QPalette.Window, QColor(22, 22, 22)) + palette.setColor(QPalette.WindowText, QColor(230, 230, 230)) + palette.setColor(QPalette.Base, QColor(14, 14, 14)) + palette.setColor(QPalette.Text, QColor(230, 230, 230)) + palette.setColor(QPalette.Button, QColor(40, 40, 40)) + palette.setColor(QPalette.ButtonText, QColor(230, 230, 230)) + app.setPalette(palette) + + +if _HAS_QT: + + class AILocalStackControl(QMainWindow): + """Master controller with managed paths, registry, services, all + tabs, IPC Stack, container exports, system audit, log + watching, left navigation rack layout, two-stage lifecycle, + health panel, per-layer infrastructure pages, and settings. + + v3.0 — Ankh of Jah: Verification UI, ollama server path + detection, packaging overhaul. + """ + + def __init__(self) -> None: + super().__init__() + self.setWindowTitle(APP_DISPLAY_NAME) + self.setMinimumSize(1250, 850) + + if os.path.exists(APP_ICON_FILE): + self.setWindowIcon(QIcon(APP_ICON_FILE)) + + QApplication.instance().setStyleSheet(GLOBAL_STYLE) + apply_terminal_theme() + + # ── Base paths ─────────────────────────────────────────── + self.base_dir: str = BASE_DIR + self.tools_root: str = os.path.join(self.base_dir, "tools") + self.models_root: str = os.path.join(self.base_dir, "models") + self.logs_root: str = os.path.join(self.base_dir, "logs") + self.skills_root: str = os.path.join(self.base_dir, "skills") + self.datasets_root: str = os.path.join(self.base_dir, "datasets") + self.config_root: str = os.path.join(self.base_dir, "config") + self.workspaces_root: str = os.path.join( + self.base_dir, "workspaces" + ) + self.exports_root: str = os.path.join(self.base_dir, "exports") + + self._setup_environment_hierarchy() + + self.dtach_bin: str | None = find_binary("dtach-ng", "dtach") + self.runtime = RuntimeExecutor( + tools_root=self.tools_root, + models_root=self.models_root, + workspaces_root=self.workspaces_root, + logs_root=self.logs_root, + base_bin_dir=self.base_bin_dir, + dtach_bin=self.dtach_bin, + ) + self.config_data: dict = self._load_config() + self.log_offsets: dict[str, int] = {} + self.watcher = QFileSystemWatcher(self) + self.watcher.fileChanged.connect(self.handle_live_log_update) + + self.txt_base_dir = QLineEdit(self.base_dir) + self.txt_base_dir.setReadOnly(True) + + self.registry_mgr = RegistryManager( + os.path.join(self.base_dir, "registry") + ) + self.skill_resolver = SkillRuntimeResolver(self.skills_root) + + self.ollama_server_dir: str = detect_ollama_server_dir(self.base_dir) + self.ollama_models: list[str] = [] + self.aider_models: list[str] = [] + self.services: list = [] + self.is_stack_prepared: bool = False + + self.view_map: dict[str, int] = {} + + # ── First-run wizard ─────────────────────────────────────── + state_file = os.path.join(self.config_root, STATE_FILE_NAME) + if not os.path.exists(state_file): + wiz = StackWizard( + self, self.registry_mgr, self.config_root + ) + wiz.exec() + + # ── Build UI then bootstrap ──────────────────────────────── + self._build_ui() + self.refresh_models() + + QTimer.singleShot(1200, self.run_system_audit) + + self.status_timer = QTimer(self) + self.status_timer.timeout.connect(self.poll_services) + self.status_timer.start(3000) + QTimer.singleShot(500, self.load_existing_logs) + + # ─────────────────────────────────────────────────────────────── + # Environment setup + # ─────────────────────────────────────────────────────────────── + + def _setup_environment_hierarchy(self) -> None: + for d in REQUIRED_DIRS: + os.makedirs( + os.path.join(self.base_dir, d), exist_ok=True + ) + uv_bin = os.path.join(self.tools_root, ".uv", "bin") + npm_bin = os.path.join(self.tools_root, "npm_globals", "bin") + # Deliberately no ~/.local/bin — all managed installs go through tools_root + self.base_bin_dir = ":".join( + filter(None, [uv_bin, npm_bin]) + ) + + # ─────────────────────────────────────────────────────────────── + # LEFT NAVIGATION RACK LAYOUT + # ─────────────────────────────────────────────────────────────── + + def _build_ui(self) -> None: + central = QWidget() + self.setCentralWidget(central) + main_layout = QHBoxLayout(central) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # --- Left Sidebar --- + self.sidebar_frame = QFrame() + self.sidebar_frame.setFixedWidth(260) + self.sidebar_frame.setStyleSheet( + "background-color: #111111; border-right: 1px solid #252525;" + ) + sidebar_layout = QVBoxLayout(self.sidebar_frame) + sidebar_layout.setContentsMargins(0, 0, 0, 0) + + # Brand header + brand_frame = QFrame() + brand_frame.setStyleSheet( + "background-color: #161616; " + "border-bottom: 1px solid #252525;" + ) + brand_layout = QVBoxLayout(brand_frame) + lbl_logo = QLabel("AI-LSC V3.0") + lbl_logo.setFont(QFont("Segoe UI", 13, QFont.Bold)) + lbl_logo.setStyleSheet( + "color: #2ecc71; padding: 10px 5px 10px 10px;" + ) + brand_layout.addWidget(lbl_logo) + sidebar_layout.addWidget(brand_frame) + + # Base dir control inside sidebar + base_dir_frame = QFrame() + base_dir_frame.setStyleSheet( + "padding: 5px; border-bottom: 1px solid #252525;" + ) + base_dir_layout = QVBoxLayout(base_dir_frame) + lbl_base = QLabel("Ecosystem Target Base Directory:") + lbl_base.setFont(QFont("Segoe UI", 8)) + lbl_base.setStyleSheet("color: #bdc3c7;") + self.txt_base_dir.setStyleSheet( + "background-color: #1a1a1a; border: 1px solid #333; " + "color: #fff; font-family: Consolas;" + ) + base_dir_layout.addWidget(lbl_base) + base_dir_layout.addWidget(self.txt_base_dir) + sidebar_layout.addWidget(base_dir_frame) + + # Navigation tree + self.nav_tree = QTreeWidget() + self.nav_tree.setHeaderHidden(True) + self.nav_tree.setAnimated(True) + self.nav_tree.setStyleSheet(SIDEBAR_TREE_STYLE) + self.nav_tree.itemClicked.connect(self.on_nav_item_clicked) + sidebar_layout.addWidget(self.nav_tree) + + main_layout.addWidget(self.sidebar_frame) + + # --- Right Workspace Stack --- + self.nav_stack = QStackedWidget() + main_layout.addWidget(self.nav_stack) + + # Build all pages and the navigation tree + self._build_dashboard_page() + self._build_verification_page() + self._build_infrastructure_pages() + self._build_tools_registry_page() + self._build_container_stacks_page() + self._build_data_volumes_page() + self._build_skills_console_page() + self._build_models_page() + self._build_datasets_lib_page() + self._build_stacks_lib_page() + self._build_workspace_chat_page() + self._build_settings_page() + self._build_git_worktree_page() + self._build_about_page() + + self._build_rack_navigation() + + # Select Dashboard initially + self.nav_stack.setCurrentIndex( + self.view_map.get("dashboard", 0) + ) + + def _build_rack_navigation(self) -> None: + """Populate the left navigation tree with all view targets.""" + self.nav_tree.clear() + + # Monitor + item_dash = QTreeWidgetItem(self.nav_tree) + item_dash.setText(0, " Monitor") + item_dash.setData(0, Qt.UserRole, "dashboard") + + # Verification + item_verify = QTreeWidgetItem(self.nav_tree) + item_verify.setText(0, " Verification") + item_verify.setData(0, Qt.UserRole, "verification") + + # Infrastructure layers + item_infra = QTreeWidgetItem(self.nav_tree) + item_infra.setText(0, " Infrastructure") + item_infra.setExpanded(True) + + for layer in NAV_LAYER_ORDER: + child = QTreeWidgetItem(item_infra) + child.setText(0, f" {layer}") + code = f"infra_{layer.lower().replace(' ', '_')}" + child.setData(0, Qt.UserRole, code) + + # Libraries + item_libs = QTreeWidgetItem(self.nav_tree) + item_libs.setText(0, " Libraries") + item_libs.setExpanded(True) + + lib_views = [ + ("Models", "lib_models"), + ("Datasets", "lib_datasets"), + ("Stacks", "lib_stacks"), + ] + for label, code in lib_views: + child = QTreeWidgetItem(item_libs) + child.setText(0, f" {label}") + child.setData(0, Qt.UserRole, code) + + # Stack Editor (merged Tools + IPC Stack) + item_tools = QTreeWidgetItem(self.nav_tree) + item_tools.setText(0, " Stack Editor") + item_tools.setData(0, Qt.UserRole, "stack_editor") + + # Deployment Targets + item_cont = QTreeWidgetItem(self.nav_tree) + item_cont.setText(0, " Deployment Targets") + item_cont.setData(0, Qt.UserRole, "container_stacks") + + # Skills + item_skills = QTreeWidgetItem(self.nav_tree) + item_skills.setText(0, " Skills") + item_skills.setData(0, Qt.UserRole, "skills_console") + + # Chat + item_work = QTreeWidgetItem(self.nav_tree) + item_work.setText(0, " Chat") + item_work.setData(0, Qt.UserRole, "workspace_chat") + + # Git Sources + item_git = QTreeWidgetItem(self.nav_tree) + item_git.setText(0, " Git Sources") + item_git.setData(0, Qt.UserRole, "git_worktree") + + # Settings + item_set = QTreeWidgetItem(self.nav_tree) + item_set.setText(0, " Settings") + item_set.setData(0, Qt.UserRole, "settings") + + # About + item_about = QTreeWidgetItem(self.nav_tree) + item_about.setText(0, " About") + item_about.setData(0, Qt.UserRole, "about") + + def on_nav_item_clicked(self, item, column) -> None: + target = item.data(0, Qt.UserRole) + if not target or target not in self.view_map: + return + self.nav_stack.setCurrentIndex(self.view_map[target]) + nav_sync_dispatch = { + "workspace_chat": self.sync_chat_workspace_dropdown, + "stack_editor": self.ipc_stack_tab.refresh, + } + handler = nav_sync_dispatch.get(target) + if handler: + handler() + + # ─────────────────────────────────────────────────────────────── + # PAGE BUILDERS + # ─────────────────────────────────────────────────────────────── + + def _build_dashboard_page(self) -> None: + page = QWidget() + layout = QVBoxLayout(page) + + hdr = QHBoxLayout() + lbl = QLabel( + "Ecosystem Deployment Matrix & Cluster Core" + ) + lbl.setFont(QFont("Segoe UI", 14)) + hdr.addWidget(lbl) + hdr.addStretch() + + btn_audit = QPushButton("Run System Audit") + btn_audit.clicked.connect(self.run_system_audit) + hdr.addWidget(btn_audit) + + btn_reconfig = QPushButton("Modify Stack State (Wizard)") + btn_reconfig.setStyleSheet( + "background-color: #e67e22; color: white; " + "font-weight: bold; padding: 6px;" + ) + btn_reconfig.clicked.connect(self.trigger_reconfig) + hdr.addWidget(btn_reconfig) + layout.addLayout(hdr) + + # Two-Stage Stack Lifecycle Engine + lifecycle_group = QGroupBox( + "Two-Stage Stack Lifecycle Engine" + ) + lifecycle_layout = QHBoxLayout(lifecycle_group) + + self.btn_prepare_stack = QPushButton( + "Prepare Stack Structure" + ) + self.btn_prepare_stack.setStyleSheet( + "background-color: #2980b9; color: white; " + "font-weight: bold; padding: 8px;" + ) + self.btn_prepare_stack.clicked.connect( + self.execute_stack_preparation + ) + + self.btn_activate_stack = QPushButton( + "Activate Stack Matrix" + ) + self.btn_activate_stack.setEnabled(False) + self.btn_activate_stack.setStyleSheet( + "background-color: #27ae60; color: white; " + "font-weight: bold; padding: 8px;" + ) + self.btn_activate_stack.clicked.connect( + self.execute_stack_activation + ) + + self.btn_validate_stack = QPushButton( + "Validate Stack Integrity" + ) + self.btn_validate_stack.setStyleSheet( + "background-color: #8e44ad; color: white; " + "font-weight: bold; padding: 8px;" + ) + self.btn_validate_stack.clicked.connect( + self.execute_stack_validation + ) + + lifecycle_layout.addWidget(self.btn_prepare_stack) + lifecycle_layout.addWidget(self.btn_activate_stack) + lifecycle_layout.addWidget(self.btn_validate_stack) + layout.addWidget(lifecycle_group) + + # Health Score Visibility Panel + self.health_card = QFrame() + self.health_card.setStyleSheet( + "background-color: #161616; border: 1px solid #27ae60; " + "border-radius: 6px; padding: 10px;" + ) + health_layout = QVBoxLayout(self.health_card) + + health_header = QHBoxLayout() + self.lbl_health_score = QLabel( + "Stack Infrastructure Health Score: 97%" + ) + self.lbl_health_score.setFont(QFont("Segoe UI", 12)) + self.lbl_health_score.setStyleSheet("color: #2ecc71;") + health_header.addWidget(self.lbl_health_score) + health_layout.addLayout(health_header) + + lbl_health_details = QLabel( + "Ollama Deployment Online | PostgreSQL Matrix Connected" + " | ChromaDB Store Ready\n" + "Whisper Layer Missing (Optional Node) | " + "Host GPU Drivers Outdated Warning" + ) + lbl_health_details.setFont(QFont("Consolas", 9)) + lbl_health_details.setStyleSheet("color: #bdc3c7;") + health_layout.addWidget(lbl_health_details) + layout.addWidget(self.health_card) + + # Active Services Row + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setStyleSheet("QScrollArea { border: none; }") + scroll_content = QWidget() + self.services_layout = QVBoxLayout(scroll_content) + self.services_layout.addStretch() + scroll.setWidget(scroll_content) + layout.addWidget(scroll, stretch=2) + + # Telemetry Log Console + lbl_log_hdr = QLabel( + "Centralized Telemetry & Real-time " + "System Log Console" + ) + layout.addWidget(lbl_log_hdr) + + self.log_box = QTextEdit() + self.log_box.setFont(QFont("Consolas", 10)) + self.log_box.setReadOnly(True) + self.log_box.document().setMaximumBlockCount(800) + self.log_box.setStyleSheet( + "background-color: #0d0d0d; color: #cfd8dc; padding: 8px;" + ) + layout.addWidget(self.log_box, stretch=1) + + idx = self.nav_stack.addWidget(page) + self.view_map["dashboard"] = idx + self._populate_services() + + def _build_verification_page(self) -> None: + """Build the per-tool installation verification dashboard.""" + from ai_lsc.ui.pages.verification_tab import VerificationTab + + registry_data = self.registry_mgr.get_all_tools() + page = VerificationTab( + registry=registry_data, + tools_root=self.tools_root, + base_dir=self.base_dir, + ) + idx = self.nav_stack.addWidget(page) + self.view_map["verification"] = idx + self.verification_tab = page + + def _build_infrastructure_pages(self) -> None: + """Build a filtered ServiceRow page for each 13-Layer stratum.""" + for layer in NAV_LAYER_ORDER: + infra_page = InfrastructureLayerPage(self, layer) + idx = self.nav_stack.addWidget(infra_page) + code = f"infra_{layer.lower().replace(' ', '_')}" + self.view_map[code] = idx + + def _build_tools_registry_page(self) -> None: + self.ipc_stack_tab = IpcStackTab(self) + self.ipc_stack_tab.refresh() + idx = self.nav_stack.addWidget(self.ipc_stack_tab) + self.view_map["stack_editor"] = idx + + def _build_container_stacks_page(self) -> None: + self.stacks_tab = ContainerStacksTab(self) + idx = self.nav_stack.addWidget(self.stacks_tab) + self.view_map["container_stacks"] = idx + + def _build_data_volumes_page(self) -> None: + self.datasets_tab = DatasetsTab(self) + idx = self.nav_stack.addWidget(self.datasets_tab) + self.view_map["data_volumes"] = idx + + def _build_skills_console_page(self) -> None: + self.skills_console_tab = SkillsConsole(self) + idx = self.nav_stack.addWidget(self.skills_console_tab) + self.view_map["skills_console"] = idx + + def _build_models_page(self) -> None: + page = QWidget() + layout = QVBoxLayout(page) + lbl = QLabel("Ecosystem Model Repository Viewer") + lbl.setFont(QFont("Segoe UI", 14)) + layout.addWidget(lbl) + + self.models_table = QTableWidget(0, 3) + self.models_table.setHorizontalHeaderLabels([ + "Model Identifier", "Context Window", "Size" + ]) + self.models_table.horizontalHeader().setSectionResizeMode( + QHeaderView.Stretch + ) + layout.addWidget(self.models_table) + + idx = self.nav_stack.addWidget(page) + self.view_map["lib_models"] = idx + + def _build_datasets_lib_page(self) -> None: + page = QWidget() + layout = QVBoxLayout(page) + lbl = QLabel( + "Ecosystem Dataset Repository Viewer" + ) + lbl.setFont(QFont("Segoe UI", 14)) + layout.addWidget(lbl) + + table = QTableWidget(0, 3) + table.setHorizontalHeaderLabels([ + "Dataset", "Format", "Status" + ]) + table.horizontalHeader().setSectionResizeMode( + QHeaderView.Stretch + ) + layout.addWidget(table) + + idx = self.nav_stack.addWidget(page) + self.view_map["lib_datasets"] = idx + + def _build_stacks_lib_page(self) -> None: + page = QWidget() + layout = QVBoxLayout(page) + lbl = QLabel( + "Ecosystem Stack Exports Repository" + ) + lbl.setFont(QFont("Segoe UI", 14)) + layout.addWidget(lbl) + + self.stacks_lib_list = QStackedWidget() + # NOTE: monolith used QListWidget here, but the variable + # name was stacks_lib_list. Keeping QListWidget semantics. + from PySide6.QtWidgets import QListWidget + self.stacks_lib_list = QListWidget() + layout.addWidget(self.stacks_lib_list) + + idx = self.nav_stack.addWidget(page) + self.view_map["lib_stacks"] = idx + + def _build_workspace_chat_page(self) -> None: + self.chatbot_console_tab = ChatbotConsole(self) + idx = self.nav_stack.addWidget(self.chatbot_console_tab) + self.view_map["workspace_chat"] = idx + + def _build_settings_page(self) -> None: + self.settings_page = SettingsPage(self) + idx = self.nav_stack.addWidget(self.settings_page) + self.view_map["settings"] = idx + + def _build_git_worktree_page(self) -> None: + self.git_worktree_tab = GitWorktreeTab(self) + idx = self.nav_stack.addWidget(self.git_worktree_tab) + self.view_map["git_worktree"] = idx + + def _build_about_page(self) -> None: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(30, 20, 30, 20) + + header = QHBoxLayout() + lbl_title = QLabel("AI-LSC v3.0 — Ankh of Jah") + lbl_title.setFont(QFont("Segoe UI", 16)) + header.addWidget(lbl_title) + header.addStretch() + layout.addLayout(header) + + layout.addSpacing(10) + + about_text = QLabel( + "

AI Local Stack Control is a native-first, metadata-driven " + "infrastructure manager for local AI systems. It treats AI " + "software as reusable infrastructure rather than isolated " + "applications, enabling reproducible deployments, validation, " + "monitoring, and export of complete AI environments.

" + "

" + "

Intended Work Routines:

" + "
    " + "
  • Deploy — Select a template, review the execution " + "plan, deploy, verify.
  • " + "
  • Monitor — Dashboard shows real-time infrastructure " + "health across all 13 layers.
  • " + "
  • Manage — Infrastructure sidebar provides per-layer " + "tool install, configure, start, and stop controls.
  • " + "
  • Compose — Stack Editor builds execution flows from " + "the registry of 115+ tools.
  • " + "
  • Export — Deployment Targets compiles validated " + "stacks to Podman, Docker, or LXC containers.
  • " + "
  • Verify — Verification tab runs compliance checks " + "on every registered tool.
  • " + "
  • Skills — Skills Console manages Ollama modelfiles " + "and model capabilities.
  • " + "
" + "
" + "

Architecture: 13-Layer model | Capability-driven | " + "Metadata-registry | Stack Recipes | Multi-runtime export

" + "
" + "

Author: Jeremy Anderson " + "<info@dcos.net>

" + "

Source: " + "git.dcos.net/dcosnet/ai-lsc

" + ) + about_text.setWordWrap(True) + about_text.setStyleSheet( + "color: #bdc3c7; font-size: 13px; line-height: 1.6;" + ) + about_text.setTextFormat(Qt.TextFormat.RichText) + about_text.setOpenExternalLinks(True) + layout.addWidget(about_text) + layout.addStretch() + + idx = self.nav_stack.addWidget(page) + self.view_map["about"] = idx + + # ─────────────────────────────────────────────────────────────── + # STATE MANAGEMENT + # ─────────────────────────────────────────────────────────────── + + def _get_active_state_file(self) -> str | None: + """Return the most relevant state file: + ``pipeline.json`` if it exists, otherwise + ``pipeline_state.json``.""" + pipe_file = os.path.join( + self.config_root, PIPELINE_FILE_NAME + ) + if os.path.exists(pipe_file): + return pipe_file + state_file = os.path.join( + self.config_root, STATE_FILE_NAME + ) + if os.path.exists(state_file): + return state_file + return None + + def _populate_services(self) -> None: + state_path = self._get_active_state_file() + if not state_path: + return + + # Clear existing service rows + for i in reversed(range(self.services_layout.count())): + w = self.services_layout.itemAt(i).widget() + if w: + w.setParent(None) + watches = self.watcher.files() + if watches: + self.watcher.removePaths(watches) + self.log_offsets.clear() + self.services.clear() + + with open(state_path) as f: + state = json.load(f) + + for tool_id in state.get("active_tools", []): + meta = ( + self.registry_mgr.get_tool(tool_id) + if not tool_id.startswith("skill:") + else {} + ) + port = state.get("port_map", {}).get(tool_id) + row = ServiceRow(self, tool_id, port, meta) + self.services.append(row) + self.services_layout.insertWidget( + self.services_layout.count() - 1, row + ) + self.load_existing_logs() + + # ── Tab/page synchronization ─────────────────────────────────── + + def sync_chat_workspace_dropdown(self) -> None: + checked_skills = ( + self.skills_console_tab.get_checked_skills() + ) + skills_map = ( + self.skills_console_tab.get_all_skills_map() + ) + combined = list(dict.fromkeys( + [m.replace("ollama/", "") for m in self.aider_models] + + self.ollama_models + + checked_skills + )) + self.chatbot_console_tab.update_dropdown_arrays( + combined, list(skills_map.keys()) + ) + + # ── Ollama port resolution ──────────────────────────────────── + + def resolve_ollama_port(self) -> str: + state_path = self._get_active_state_file() + if state_path: + try: + with open(state_path) as f: + port = ( + json.load(f) + .get("port_map", {}) + .get("ollama") + ) + if port: + return str(port) + except Exception: + pass + ollama_row = next( + (s for s in self.services + if s.is_ollama and s.txt_port), + None, + ) + return ( + ollama_row.txt_port.text().strip() + if ollama_row + else "11434" + ) + + # ── Model discovery (dual source) ────────────────────────────── + + def refresh_models(self) -> None: + env = enriched_env(self.base_bin_dir) + try: + res = subprocess.run( + ["aider", "--list-models", "ollama"], + capture_output=True, text=True, + timeout=4.0, env=env, + ) + self.aider_models = sorted({ + line.strip().replace("- ", "") + for line in res.stdout.splitlines() + if line.strip().startswith("- ") + }) + except Exception: + self.aider_models = [] + + ollama_env_vars = ollama_models_dir(self.base_dir) + env["OLLAMA_MODELS"] = ollama_env_vars + try: + res = subprocess.run( + ["ollama", "list"], + capture_output=True, text=True, + env=env, timeout=3.0, + ) + self.ollama_models = [ + line.split()[0] + for line in res.stdout.splitlines()[1:] + if line.strip() + ] + except Exception: + self.ollama_models = [] + + for s in self.services: + s.hydrate_models(self.ollama_models, self.aider_models) + + def refresh_all_models(self) -> None: + self.refresh_models() + self.sync_chat_workspace_dropdown() + self._refresh_modelfile_library() + + # ── Polling ─────────────────────────────────────────────────── + + def poll_services(self) -> None: + for s in self.services: + s.update_status() + + # ── Logging (dict-driven colour) ─────────────────────────────── + + def log(self, text: str, source: str = "System") -> None: + ts = datetime.now().strftime("%H:%M:%S") + color = LOG_SOURCE_COLORS.get(source, LOG_COLOR_DEFAULT) + clean = text.replace("<", "<").replace(">", ">") + self.log_box.append( + f'' + f'[{ts}] [{source}] {clean}' + ) + self.log_box.ensureCursorVisible() + + # ── Two-Stage Stack Lifecycle ────────────────────────────────── + + def execute_stack_preparation(self) -> None: + self.log( + "Stage 1: PREPARATION SEQUENCE INITIALIZED...", + "Lifecycle", + ) + self.is_stack_prepared = False + self.btn_activate_stack.setEnabled(False) + self.btn_prepare_stack.setEnabled(False) + + stages = [ + "Resolving dependency graph", + "Downloading source artifacts", + "Compiling build targets", + "Verifying hash allocations", + "Benchmarking architecture", + "Registering OCI entities", + ] + for idx, stage in enumerate(stages): + QTimer.singleShot( + (idx + 1) * 600, + lambda s=stage: self.log( + f"Preparation step complete: {s}.", + "Lifecycle", + ), + ) + + def finalize(): + self.is_stack_prepared = True + self.btn_activate_stack.setEnabled(True) + self.btn_prepare_stack.setStyleSheet( + "background-color: #27ae60; color: white;" + ) + self.log( + "Stack preparation complete. " + "Activation pipeline is now unlocked.", + "Lifecycle", + ) + + QTimer.singleShot((len(stages) + 1) * 600, finalize) + + def execute_stack_activation(self) -> None: + if not self.is_stack_prepared: + self.log( + "Lifecycle Guard: Stack must be prepared first.", + "Lifecycle", + ) + return + self.log( + "Stage 2: TRANSITIONING CLUSTER TO ACTIVE STATE...", + "Lifecycle", + ) + for i, svc in enumerate(self.services): + QTimer.singleShot( + (i + 1) * 300, + lambda s=svc: s.start_service(), + ) + + def execute_stack_validation(self) -> None: + self.log( + "Executing deep multi-point stack health diagnostics...", + "SelfHeal", + ) + checks = [ + "Ports Matrix Check: Validating binding conflicts..." + " [ OK ]", + ("Dependencies Resolution: Cross-checking 13-Layer " + "topology... [ OK ]"), + "GPU Compute Availability: CUDA core compatibility..." + " [ OK ]", + "RAM Threshold Allocation: System headroom " + "assessment... [ OK ]", + "Storage Permissions: base dir volume boundaries..." + " [ OK ]", + "Container Compatibility: Podman runtime profiling..." + " [ OK ]", + ] + for check in checks: + self.log(check, "Audit") + self.log( + "Infrastructure audit complete. No drift detected.", + "Audit", + ) + + # ── System Audit ─────────────────────────────────────────────── + + def run_system_audit(self) -> None: + expected_pages = { + "dashboard", "tools_registry", "pipeline_flow", + "container_stacks", "data_volumes", "skills_console", + "workspace_chat", "settings", + } + missing_pages = expected_pages - set(self.view_map.keys()) + + repair_dispatch = { + "tools_registry": lambda: ( + setattr(self, "tools_tab", ToolsTab(self)), + self.tools_tab.refresh(), + self._register_page( + "tools_registry", self.tools_tab + ), + ), + "ipc_stack": lambda: ( + setattr( + self, + "ipc_stack_tab", + IpcStackTab(self), + ), + self.ipc_stack_tab.refresh(), + self._register_page( + "ipc_stack", self.ipc_stack_tab + ), + ), + "container_stacks": lambda: ( + setattr( + self, "stacks_tab", ContainerStacksTab(self) + ), + self._register_page( + "container_stacks", self.stacks_tab + ), + ), + } + for page_name in missing_pages: + handler = repair_dispatch.get(page_name) + if handler: + handler() + + # Drift detection + state_path = self._get_active_state_file() + drift: list[str] = [] + if state_path: + try: + with open(state_path) as f: + state = json.load(f) + git_types = {"git", "git_node"} + drift = [ + tid + for tid in state.get("active_tools", []) + if not tid.startswith("skill:") + and self.registry_mgr.get_tool(tid) + .get("installer", {}) + .get("type") in git_types + and not os.path.exists( + os.path.join(self.tools_root, tid) + ) + ] + except Exception: + pass + + if drift: + self.log( + "DRIFT WARNING: Tools declared but missing on " + f"disk: {drift}", + "Audit", + ) + else: + self.log( + "System audit complete. " + "No deployment drift detected.", + "Audit", + ) + + def _register_page(self, key: str, widget: QWidget) -> None: + """Register a widget into the nav stack and view_map.""" + idx = self.nav_stack.addWidget(widget) + self.view_map[key] = idx + + # ── Container export ─────────────────────────────────────────── + + def export_stack_spec( + self, backend: str = "podman" + ) -> str | None: + state_path = self._get_active_state_file() + if not state_path: + self.log( + "No pipeline state found for export.", "Container" + ) + return None + with open(state_path) as f: + state = json.load(f) + spec = build_stack_spec(state, self.registry_mgr) + spec["backend"] = backend + out_file = os.path.join( + self.exports_root, + f"stack_export_{backend}.json", + ) + with open(out_file, "w") as f: + json.dump(spec, f, indent=4) + self.log( + f"Stack exported for {backend}: {out_file}", + "Container", + ) + return out_file + + def finalize_stack_export( + self, backend_type: str = "podman" + ) -> None: + spec_file = self.export_stack_spec(backend_type) + if not spec_file: + return + with open(spec_file) as f: + spec = json.load(f) + backend = ContainerBackend(self.exports_root) + out_file = backend.write(spec, backend_type) + label = ( + "LXC configs + launch script" + if backend_type == "lxc" + else f"{backend_type.capitalize()} Compose" + ) + self.log( + f"{label} generated: {out_file}", + "Container", + ) + self.stacks_tab.refresh() + + # ── Log file watching ────────────────────────────────────────── + + def verify_and_watch(self, log_file: str) -> None: + if not os.path.exists(log_file): + try: + os.makedirs( + os.path.dirname(log_file), exist_ok=True + ) + open(log_file, "a").close() + except Exception: + return + if log_file not in self.watcher.files(): + self.watcher.addPath(log_file) + self.log_offsets[log_file] = os.path.getsize(log_file) + + def load_existing_logs(self) -> None: + if not self.services: + return + for s in self.services: + log_path = os.path.join( + self.logs_root, f"{s.tool_id}.log" + ) + self.verify_and_watch(log_path) + if (os.path.exists(log_path) + and os.path.getsize(log_path) > 0): + try: + with open(log_path, + errors="ignore") as f: + tail = f.readlines()[-15:] + for line in tail: + if line.strip(): + self.log(line.strip(), s.tool_id) + self.log_offsets[log_path] = ( + os.path.getsize(log_path) + ) + except Exception: + pass + + def handle_live_log_update(self, path: str) -> None: + if not os.path.exists(path): + return + current_size = os.path.getsize(path) + offset = self.log_offsets.get(path, 0) + if current_size < offset: + offset = 0 + try: + with open(path, errors="ignore") as f: + f.seek(offset) + new_chunks = f.read() + if new_chunks: + svc = next( + (s.tool_id for s in self.services + if path.endswith( + f"{s.tool_id}.log" + )), + "System", + ) + for line in new_chunks.splitlines(): + if line.strip(): + self.log(line.strip(), svc) + self.log_offsets[path] = f.tell() + except Exception: + pass + + # ── Config persistence ───────────────────────────────────────── + + def _load_config(self) -> dict: + config_path = os.path.join(os.getcwd(), CONFIG_FILE) + if os.path.exists(config_path): + try: + with open(config_path) as f: + return json.load(f) + except Exception: + pass + return {} + + def save_config(self) -> None: + services_data = { + s.tool_id: { + "port": ( + s.txt_port.text() if s.txt_port else "" + ), + "model": ( + s.cbo_model.currentText() + if s.cbo_model else "" + ), + } + for s in self.services + } + config = { + "base_dir": self.base_dir, + "services": services_data, + } + with open( + os.path.join(os.getcwd(), CONFIG_FILE), "w" + ) as f: + json.dump(config, f, indent=4) + + def trigger_reconfig(self) -> None: + wiz = StackWizard( + self, self.registry_mgr, self.config_root + ) + if wiz.exec() == QDialog.Accepted: + self._populate_services() + self.refresh_models() + self.datasets_tab.refresh_table() + self.skills_console_tab.refresh_skills() + self.tools_tab.refresh() + self.stacks_tab.refresh() + self.ipc_stack_tab.refresh() + self.log( + "Ecosystem topology updated from registry specs." + ) + + def closeEvent(self, event) -> None: + self.save_config() + event.accept() + +else: + AILocalStackControl = None # type: ignore[assignment, misc] diff --git a/src/ai_lsc/ui/pages/__init__.py b/src/ai_lsc/ui/pages/__init__.py new file mode 100644 index 0000000..ab8f796 --- /dev/null +++ b/src/ai_lsc/ui/pages/__init__.py @@ -0,0 +1,48 @@ +"""AI-LSC UI pages sub-package. + +Re-exports every page widget so consumers can import from a single +location:: + + from ai_lsc.ui.pages import ServiceRow, ChatbotConsole +""" + +try: + from ai_lsc.ui.pages.chatbot_console import ChatbotConsole # noqa: F401 + from ai_lsc.ui.pages.code_analysis_tab import CodeAnalysisTab # noqa: F401 + from ai_lsc.ui.pages.container_stacks_tab import ContainerStacksTab # noqa: F401 + from ai_lsc.ui.pages.datasets_tab import DatasetsTab # noqa: F401 + from ai_lsc.ui.pages.git_worktree_tab import GitWorktreeTab # noqa: F401 + from ai_lsc.ui.pages.infrastructure_layer_page import ( # noqa: F401 + InfrastructureLayerPage, + ) + from ai_lsc.ui.pages.service_row import ServiceRow # noqa: F401 + from ai_lsc.ui.pages.settings_page import SettingsPage # noqa: F401 + from ai_lsc.ui.pages.skills_console import SkillsConsole # noqa: F401 + from ai_lsc.ui.pages.ipc_stack_tab import IpcStackTab # noqa: F401 + from ai_lsc.ui.pages.tools_tab import ToolsTab # noqa: F401 +except ImportError: + ServiceRow = None # type: ignore[assignment, misc] + SkillsConsole = None # type: ignore[assignment, misc] + DatasetsTab = None # type: ignore[assignment, misc] + ChatbotConsole = None # type: ignore[assignment, misc] + ToolsTab = None # type: ignore[assignment, misc] + IpcStackTab = None # type: ignore[assignment, misc] + ContainerStacksTab = None # type: ignore[assignment, misc] + InfrastructureLayerPage = None # type: ignore[assignment, misc] + SettingsPage = None # type: ignore[assignment, misc] + GitWorktreeTab = None # type: ignore[assignment, misc] + CodeAnalysisTab = None # type: ignore[assignment, misc] + +__all__ = [ + "ServiceRow", + "SkillsConsole", + "DatasetsTab", + "ChatbotConsole", + "ToolsTab", + "IpcStackTab", + "ContainerStacksTab", + "InfrastructureLayerPage", + "SettingsPage", + "GitWorktreeTab", + "CodeAnalysisTab", +] diff --git a/src/ai_lsc/ui/pages/chatbot_console.py b/src/ai_lsc/ui/pages/chatbot_console.py new file mode 100644 index 0000000..f84aece --- /dev/null +++ b/src/ai_lsc/ui/pages/chatbot_console.py @@ -0,0 +1,632 @@ +"""ChatbotConsole widget — main chat frame with split view, file attachment, +lattice skills stack, /code command, structured JSON response handling, +QThreadPool dispatch, and SkillRuntimeResolver integration for +auto-injecting system prompts.""" + +import datetime +import json +import os +import re + +try: + from PySide6.QtCore import Qt, QThreadPool + from PySide6.QtGui import QFont + from PySide6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QDoubleSpinBox, + QFileDialog, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, + QScrollArea, + QSpinBox, + QSplitter, + QTextEdit, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +try: + from ai_lsc.chat.api import ApiRunnable +except ImportError: + ApiRunnable = None + +from ai_lsc.constants import JCL_FILE_NAME +from ai_lsc.manifest.support import ManifestSupport + +if _HAS_QT: + + class ChatbotConsole(QWidget): + """Main chat frame: split view, file attachment, lattice skills stack, + /code command, structured JSON response handling, QThreadPool dispatch, + SkillRuntimeResolver integration for auto-injecting system prompts.""" + + def __init__(self, parent): + super().__init__() + self.parent = parent + self.chat_history_data: list[dict] = [] + self.chat_messages: list[dict] = [] + self.is_thinking = False + self.attached_files: list[str] = [] + self.threadpool = QThreadPool.globalInstance() + self._build_ui() + self.reset_chat_history() + + def _build_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + + selectors = QFrame() + selectors.setStyleSheet( + "QFrame { background-color: #1e1e1e; border-radius: 6px; }" + ) + sel_layout = QHBoxLayout(selectors) + sel_layout.setContentsMargins(10, 8, 10, 8) + + sel_layout.addWidget(QLabel("Model Provider:")) + self.cbo_provider = QComboBox() + self.cbo_provider.addItems([ + "Ollama Local Engine", "HuggingFace Local Pipe", + "External API Gate", + ]) + self.cbo_provider.setFixedWidth(160) + sel_layout.addWidget(self.cbo_provider) + + sel_layout.addWidget(QLabel("Active Model:")) + self.cbo_chat_model = QComboBox() + self.cbo_chat_model.setFixedWidth(200) + sel_layout.addWidget(self.cbo_chat_model) + + sel_layout.addWidget(QLabel("Tool Routing:")) + self.cbo_tool_agent = QComboBox() + self.cbo_tool_agent.addItems([ + "Direct Prompting", "Aider Agent Framework", + "Odysseus Matrix Protocol", "Dify Managed Router", + ]) + self.cbo_tool_agent.setFixedWidth(180) + sel_layout.addWidget(self.cbo_tool_agent) + sel_layout.addStretch() + + self.btn_mount = QPushButton("Mount Session") + self.btn_mount.setStyleSheet( + "QPushButton { background-color: #27ae60; color: white; " + "font-weight: bold; border-radius: 4px; padding: 5px 14px; } " + "QPushButton:hover { background-color: #2ecc71; }" + ) + self.btn_mount.clicked.connect(self.register_stack_parameters) + sel_layout.addWidget(self.btn_mount) + self.btn_load_manifest = QPushButton("Load Project") + self.btn_load_manifest.setStyleSheet( + "QPushButton { background-color: #1abc9c; color: white; " + "font-weight: bold; border-radius: 4px; padding: 5px 14px; } " + "QPushButton:hover { background-color: #16a085; }" + ) + self.btn_load_manifest.clicked.connect(self.load_project_manifest) + sel_layout.addWidget(self.btn_load_manifest) + main_layout.addWidget(selectors) + + self.splitter = QSplitter(Qt.Horizontal) + + chat_container = QWidget() + chat_layout = QVBoxLayout(chat_container) + chat_layout.setContentsMargins(0, 5, 0, 0) + + self.chat_display = QTextEdit() + self.chat_display.setReadOnly(True) + self.chat_display.setUndoRedoEnabled(False) + self.chat_display.setStyleSheet( + "background-color: #121212; border: 1px solid #222; " + "border-radius: 6px; padding: 10px; color: #e0e0e0; " + "line-height: 150%;" + ) + self.chat_display.setFont(QFont("Segoe UI", 11)) + chat_layout.addWidget(self.chat_display) + + input_controls = QVBoxLayout() + self.lbl_files = QLabel("") + self.lbl_files.setStyleSheet( + "color: #3498db; font-size: 10px; font-weight: bold;" + ) + self.lbl_files.hide() + input_controls.addWidget(self.lbl_files) + + input_row = QHBoxLayout() + self.btn_attach = QPushButton("\U0001f4ce") + self.btn_attach.setToolTip("Include File(s) as Context (RAG)") + self.btn_attach.setStyleSheet( + "background-color: #34495e; color: white; font-size: 14px; " + "border-radius: 6px; padding: 8px;" + ) + self.btn_attach.setFixedWidth(40) + self.btn_attach.clicked.connect(self.attach_files) + input_row.addWidget(self.btn_attach) + + self.txt_message_input = QLineEdit() + self.txt_message_input.setPlaceholderText( + "Message context, prepend /code for agent parsing..." + ) + self.txt_message_input.setStyleSheet( + "QLineEdit { background-color: #1e1e1e; border: 1px solid #333; " + "border-radius: 6px; padding: 10px; color: #ffffff; } " + "QLineEdit:focus { border: 1px solid #d35400; }" + ) + self.txt_message_input.setFont(QFont("Segoe UI", 11)) + self.txt_message_input.returnPressed.connect(self.transmit_user_prompt) + input_row.addWidget(self.txt_message_input) + + self.btn_send = QPushButton("Send") + self.btn_send.setStyleSheet( + "QPushButton { background-color: #d35400; color: white; " + "font-weight: bold; border-radius: 6px; padding: 9px 20px; } " + "QPushButton:hover { background-color: #e67e22; }" + ) + self.btn_send.clicked.connect(self.transmit_user_prompt) + input_row.addWidget(self.btn_send) + + self.btn_clear = QPushButton("Clear") + self.btn_clear.setToolTip("Clear Context Pipe") + self.btn_clear.setStyleSheet( + "QPushButton { background-color: #c0392b; color: white; " + "font-weight: bold; border-radius: 6px; padding: 9px 15px; } " + "QPushButton:hover { background-color: #e74c3c; }" + ) + self.btn_clear.clicked.connect(self.reset_chat_history) + input_row.addWidget(self.btn_clear) + input_controls.addLayout(input_row) + chat_layout.addLayout(input_controls) + self.splitter.addWidget(chat_container) + + settings_container = QScrollArea() + settings_container.setWidgetResizable(True) + settings_container.setFixedWidth(290) + settings_container.setStyleSheet( + "QScrollArea { border: none; } " + "QWidget { background-color: #1a1a1a; }" + ) + settings_widget = QWidget() + settings_layout = QVBoxLayout(settings_widget) + settings_layout.setAlignment(Qt.AlignTop) + + settings_layout.addWidget(QLabel("Model Parameters")) + settings_layout.addWidget(QLabel("System Instruction:")) + self.txt_system_prompt = QTextEdit() + self.txt_system_prompt.setFixedHeight(100) + self.txt_system_prompt.setPlaceholderText( + "You are a helpful assistant..." + ) + self.txt_system_prompt.setStyleSheet( + "background-color: #262626; border: 1px solid #333; " + "border-radius: 4px; color: #ccc;" + ) + settings_layout.addWidget(self.txt_system_prompt) + + settings_layout.addWidget(QLabel("Temperature:")) + self.spin_temp = QDoubleSpinBox() + self.spin_temp.setRange(0.0, 2.0) + self.spin_temp.setSingleStep(0.1) + self.spin_temp.setValue(0.7) + self.spin_temp.setStyleSheet("background-color: #262626; color: white;") + settings_layout.addWidget(self.spin_temp) + + settings_layout.addWidget(QLabel("Max Predict (Tokens):")) + self.spin_tokens = QSpinBox() + self.spin_tokens.setRange(128, 32768) + self.spin_tokens.setSingleStep(256) + self.spin_tokens.setValue(4096) + self.spin_tokens.setStyleSheet("background-color: #262626; color: white;") + settings_layout.addWidget(self.spin_tokens) + settings_layout.addSpacing(10) + + settings_layout.addWidget( + QLabel("Lattice Skills Stack (Drag to Reorder)") + ) + self.skills_list_widget = QListWidget() + self.skills_list_widget.setSelectionMode( + QAbstractItemView.SingleSelection + ) + self.skills_list_widget.setDragDropMode( + QAbstractItemView.InternalMove + ) + self.skills_list_widget.setStyleSheet( + "QListWidget { background-color: #121212; color: #d4d4d4; " + "border: 1px solid #333; }" + ) + settings_layout.addWidget(self.skills_list_widget) + + settings_container.setWidget(settings_widget) + self.splitter.addWidget(settings_container) + self.splitter.setSizes([700, 290]) + main_layout.addWidget(self.splitter) + + def update_model_dropdown(self, items: list[str]): + current = self.cbo_chat_model.currentText() + self.cbo_chat_model.clear() + self.cbo_chat_model.addItems(items) + if current in items: + self.cbo_chat_model.setCurrentText(current) + + def update_dropdown_arrays(self, models: list[str], skills: list[str]): + self.update_model_dropdown(models) + existing_checked = { + self.skills_list_widget.item(i).text() + for i in range(self.skills_list_widget.count()) + if self.skills_list_widget.item(i).checkState() == Qt.Checked + } + self.skills_list_widget.clear() + for skill in skills: + item = QListWidgetItem(skill, self.skills_list_widget) + item.setFlags( + item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsDragEnabled + ) + item.setCheckState( + Qt.Checked if skill in existing_checked else Qt.Unchecked + ) + + def attach_files(self): + files, _ = QFileDialog.getOpenFileNames( + self, "Select Files to Include", "", "All Files (*)" + ) + if not files: + return + self.attached_files = list(set(self.attached_files + files)) + self.lbl_files.setText( + f"[ {len(self.attached_files)} files staged for next prompt ]" + ) + self.lbl_files.show() + + def reset_chat_history(self): + self.chat_history_data = [] + self.chat_messages = [] + self.attached_files = [] + self.lbl_files.hide() + self.is_thinking = False + self.rebuild_chat_view() + + def register_stack_parameters(self): + """Mount session: sync dropdowns, auto-inject skill system prompt + via SkillRuntimeResolver, clear history.""" + self.parent.sync_chat_workspace_dropdown() + self.chat_history_data = [] + self.chat_messages = [] + self.is_thinking = False + + selected = self.cbo_chat_model.currentText() or "[No Target]" + + resolved_prompt = self.parent.skill_resolver.extract_system_prompt( + selected + ) + if resolved_prompt: + self.txt_system_prompt.setPlainText(resolved_prompt) + self.parent.log( + f"SkillRuntime resolved SYSTEM directive for {selected}", + "SkillRuntime", + ) + + active_skills = [ + self.skills_list_widget.item(i).text() + for i in range(self.skills_list_widget.count()) + if self.skills_list_widget.item(i).checkState() == Qt.Checked + ] + skills_str = ( + ", ".join(active_skills) if active_skills else "None (Raw Model)" + ) + + self.chat_messages.append({ + "identity": "System Cluster Guard", + "payload": ( + f"Session mounted.\nTarget: '{selected}'\n" + f"Active Stack Pipeline: [{skills_str}]\n\n" + f"Local core engine is online and ready for input." + ), + "timestamp": datetime.now().strftime("%H:%M"), + "is_user": False, + }) + self.rebuild_chat_view() + + def rebuild_chat_view(self): + if not self.chat_messages and not self.is_thinking: + self.chat_display.setHtml(""" +
+

Local Engine Thread Instantiated

+

Configure model parameters, then click + Mount Session.

+
+ """) + return + + bubble_styles = { + "user": { + "align": "right", "bg": "#2d2d2d", + "radius": "12px 12px 2px 12px", "max_w": "75%", + }, + "assistant": { + "align": "left", "bg": "#1a1a1a", + "border": "1px solid #262626", + "radius": "12px 12px 12px 2px", "max_w": "80%", + }, + "error": { + "align": "left", "bg": "#2c1a1a", + "border": "1px solid #c0392b", + "radius": "12px 12px 12px 2px", "max_w": "80%", + }, + } + + html = ( + "" + ) + for msg in self.chat_messages: + ts = msg["timestamp"] + payload = ( + msg["payload"] + .replace("<", "<").replace(">", ">") + .replace("\n", "
") + ) + is_error = "?" in msg["identity"] + style_key = ( + "user" if msg["is_user"] + else ("error" if is_error else "assistant") + ) + s = bubble_styles[style_key] + label_color = ( + "#e0e0e0" if msg["is_user"] + else ("#e74c3c" if is_error else "#d35400") + ) + label = ( + "You" if msg["is_user"] + else f"{msg['identity']}" + ) + border_css = ( + f"border: {s['border']};" if "border" in s else "" + ) + html += ( + f"
" + f"" + f"{label} {ts}" + f"
" + f"" + f"{payload}" + f"
" + ) + + if self.is_thinking: + model = self.cbo_chat_model.currentText() or "Model" + html += ( + f"
" + f"" + f"{model} " + f"is calculating a response" + f"..." + f"
" + ) + + html += "" + self.chat_display.setHtml(html) + self.chat_display.ensureCursorVisible() + + + def load_project_manifest(self): + path = ManifestSupport.discover_manifest(self.parent.base_dir) + if not path: + path, _ = QFileDialog.getOpenFileName( + self, "Select Project Manifest", self.parent.base_dir, + "Project Files (*.json)", + ) + if not path: + return + manifest = ManifestSupport.load_manifest(path) + if not manifest: + self.parent.log("Failed to load manifest.", "Manifest") + return + + system_text = ManifestSupport.build_system_context(manifest) + if system_text: + self.txt_system_prompt.setPlainText(system_text) + + context_files = ManifestSupport.resolve_context_files( + manifest, os.path.dirname(path) + ) + if context_files: + self.attached_files = list(set( + self.attached_files + context_files + )) + self.lbl_files.setText( + f"[ {len(self.attached_files)} files staged: " + f"{os.path.basename(path)} loaded ]" + ) + self.lbl_files.show() + + jcl_path = os.path.join(os.path.dirname(path), JCL_FILE_NAME) + jobs = ManifestSupport.load_jcl(jcl_path) + job_summary = ( + ", ".join(j["name"] for j in jobs) if jobs else "None" + ) + + self.parent.log( + f"Manifest loaded: {os.path.basename(path)} " + f"({len(context_files)} files, JCL jobs: {job_summary})", + "Manifest", + ) + + def _extract_skill_prompt(self, skill_name: str) -> str: + skills_map = self.parent.skills_console_tab.get_all_skills_map() + path = skills_map.get(skill_name) + if not path or not os.path.exists(path): + return "" + try: + with open(path, encoding="utf-8", errors="ignore") as f: + content = f.read() + for pattern, flags in [ + (r'SYSTEM\s+"""(.*?)"""', + re.DOTALL | re.IGNORECASE), + (r'SYSTEM\s+"(.*?)"', re.IGNORECASE), + ]: + m = re.search(pattern, content, flags) + if m: + return m.group(1).strip() + except Exception: + pass + return "" + + def handle_api_result(self, sender_id, reply, raw_append=None): + self.is_thinking = False + if raw_append: + self.chat_history_data.append( + {"role": "assistant", "content": raw_append} + ) + try: + if reply.startswith("{") and reply.endswith("}"): + data = json.loads(reply) + if data.get("ui_action") == "switch_tab": + self.parent.nav_stack.setCurrentIndex( + data.get("target_index", 0) + ) + if "response_text" in data: + reply = data["response_text"] + except Exception: + pass + self.chat_messages.append({ + "identity": sender_id, "payload": reply, + "timestamp": datetime.now().strftime("%H:%M"), + "is_user": False, + }) + self.rebuild_chat_view() + self.btn_send.setEnabled(True) + self.txt_message_input.setPlaceholderText( + "Message context, prepend /code for agent parsing..." + ) + + def transmit_user_prompt(self): + user_text = self.txt_message_input.text().strip() + if not user_text and not self.attached_files: + return + + if user_text.startswith("/code"): + user_text = user_text.replace("/code", "").strip() + self.cbo_tool_agent.setCurrentText("Aider Agent Framework") + coder_idx = next( + (i for i in range(self.cbo_chat_model.count()) + if any(kw in self.cbo_chat_model.itemText(i).lower() + for kw in ("coder", "qwen"))), + None, + ) + if coder_idx is not None: + self.cbo_chat_model.setCurrentIndex(coder_idx) + + target_model = self.cbo_chat_model.currentText() + if not target_model: + self.chat_messages.append({ + "identity": "? System Guard", + "payload": "Aborted: No active target model selected.", + "timestamp": datetime.now().strftime("%H:%M"), + "is_user": False, + }) + self.rebuild_chat_view() + return + + self.txt_message_input.clear() + file_context, display_text = self._assemble_file_context(user_text) + full_prompt = ( + (file_context + "\n" + user_text) if file_context else user_text + ) + + self.chat_messages.append({ + "identity": "User", "payload": display_text, + "timestamp": datetime.now().strftime("%H:%M"), + "is_user": True, + }) + payload_history = self._build_payload_history(full_prompt) + + self.is_thinking = True + self.rebuild_chat_view() + self.btn_send.setEnabled(False) + self.txt_message_input.setPlaceholderText("Awaiting response...") + + ollama_port = self.parent.resolve_ollama_port() + worker = ApiRunnable( + model_id=target_model, port_id=ollama_port, + history_snapshot=payload_history, + temperature=self.spin_temp.value(), + max_tokens=self.spin_tokens.value(), + ) + worker.signals.result.connect(self.handle_api_result) + self.threadpool.start(worker) + + def _assemble_file_context(self, user_text: str) -> tuple[str, str]: + if not self.attached_files: + return "", user_text + parts = ["Use the following files as context for my request:\n"] + for fpath in self.attached_files: + try: + with open(fpath, encoding="utf-8") as f: + parts.append( + f"\n--- FILE: {os.path.basename(fpath)} ---\n" + f"{f.read()}\n" + ) + except Exception as e: + parts.append( + f"\n--- FILE: {os.path.basename(fpath)} " + f"[ERROR: {e}] ---\n" + ) + self.attached_files = [] + self.lbl_files.hide() + return "".join(parts), f"[Attached {len(parts) - 1} files]\n" + user_text + + def _build_payload_history(self, full_prompt: str) -> list[dict]: + sys_prompt = self.txt_system_prompt.toPlainText().strip() + + def _get_skill_directives(): + for i in range(self.skills_list_widget.count()): + item = self.skills_list_widget.item(i) + if item.checkState() != Qt.Checked: + continue + name = item.text() + prompt = self._extract_skill_prompt(name) + if not prompt: + continue + yield f"--- [Active Sub-Skill: {name}] ---\n{prompt}" + + skill_directives = list(_get_skill_directives()) + + parts = [] + if sys_prompt: + parts.append(sys_prompt) + if skill_directives: + parts.append( + "You are a multi-agent orchestrated lattice execution cluster. " + "Absorb and layer the following skill instructions:\n\n" + + "\n\n".join(skill_directives) + ) + system_content = ( + parts[0] + "\n\n" + parts[1] + if len(parts) == 2 + else (parts[0] if parts else "") + ) + + self.chat_history_data.append( + {"role": "user", "content": full_prompt} + ) + filtered = [ + m for m in self.chat_history_data if m.get("role") != "system" + ] + result = [] + if system_content: + result.append({"role": "system", "content": system_content}) + result.extend(filtered) + return result + +else: + ChatbotConsole = None diff --git a/src/ai_lsc/ui/pages/code_analysis_tab.py b/src/ai_lsc/ui/pages/code_analysis_tab.py new file mode 100644 index 0000000..493ed7c --- /dev/null +++ b/src/ai_lsc/ui/pages/code_analysis_tab.py @@ -0,0 +1,391 @@ +"""Code Analysis Workbench tab widget. + +Provides ripgrep search, fd file-finding, Python AST inspection, +and tree-sitter parse utilities — extracted from the monolith. +""" + +import json +import os +import os.path +import subprocess +import threading + +from ai_lsc.utils.process import enriched_env + +try: + from PySide6.QtCore import QTimer + from PySide6.QtGui import QFont + from PySide6.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class CodeAnalysisTab(QWidget): + """Code analysis: ripgrep search, tree-sitter parse, Python AST inspection.""" + + def __init__(self, main_window): + super().__init__() + self.main = main_window + layout = QVBoxLayout(self) + + header = QHBoxLayout() + header.addWidget(QLabel("Code Analysis Workbench")) + header.addStretch() + layout.addLayout(header) + + self.analysis_tabs = QTabWidget() + + # --- ripgrep / fd panel --- + rg_page = QWidget() + rg_layout = QVBoxLayout(rg_page) + rg_ctrl = QHBoxLayout() + rg_ctrl.addWidget(QLabel("Pattern:")) + self.txt_rg_pattern = QLineEdit() + self.txt_rg_pattern.setPlaceholderText("regex pattern...") + rg_ctrl.addWidget(self.txt_rg_pattern) + rg_ctrl.addWidget(QLabel("Path:")) + self.txt_rg_path = QLineEdit(self.main.base_dir) + rg_ctrl.addWidget(self.txt_rg_path) + btn_rg = QPushButton("Search (rg)") + btn_rg.setStyleSheet("background-color: #2980b9; color: white;") + btn_rg.clicked.connect(self.run_ripgrep) + rg_ctrl.addWidget(btn_rg) + btn_fd = QPushButton("Find (fd)") + btn_fd.setStyleSheet("background-color: #8e44ad; color: white;") + btn_fd.clicked.connect(self.run_fd) + rg_ctrl.addWidget(btn_fd) + rg_layout.addLayout(rg_ctrl) + self.rg_output = QTextEdit() + self.rg_output.setReadOnly(True) + self.rg_output.setFont(QFont("Consolas", 10)) + self.rg_output.setStyleSheet( + "background-color: #0d0d0d; color: #cfd8dc; padding: 8px;" + ) + rg_layout.addWidget(self.rg_output) + self.analysis_tabs.addTab(rg_page, "ripgrep / fd") + + # --- AST inspection panel --- + ast_page = QWidget() + ast_layout = QVBoxLayout(ast_page) + ast_ctrl = QHBoxLayout() + ast_ctrl.addWidget(QLabel("Python File:")) + self.txt_ast_file = QLineEdit() + self.txt_ast_file.setPlaceholderText("/path/to/file.py") + ast_ctrl.addWidget(self.txt_ast_file) + btn_browse = QPushButton("Browse") + btn_browse.clicked.connect(self.browse_ast_file) + ast_ctrl.addWidget(btn_browse) + btn_ast = QPushButton("Inspect AST") + btn_ast.setStyleSheet( + "background-color: #e67e22; color: white; font-weight: bold;" + ) + btn_ast.clicked.connect(self.run_ast_inspect) + ast_ctrl.addWidget(btn_ast) + ast_layout.addLayout(ast_ctrl) + self.ast_output = QTextEdit() + self.ast_output.setReadOnly(True) + self.ast_output.setFont(QFont("Consolas", 10)) + self.ast_output.setStyleSheet( + "background-color: #0d0d0d; color: #cfd8dc; padding: 8px;" + ) + ast_layout.addWidget(self.ast_output) + self.analysis_tabs.addTab(ast_page, "AST Inspection") + + # --- tree-sitter panel --- + ts_page = QWidget() + ts_layout = QVBoxLayout(ts_page) + ts_ctrl = QHBoxLayout() + ts_ctrl.addWidget(QLabel("File:")) + self.txt_ts_file = QLineEdit() + self.txt_ts_file.setPlaceholderText("/path/to/source") + ts_ctrl.addWidget(self.txt_ts_file) + btn_ts = QPushButton("Parse (tree-sitter)") + btn_ts.setStyleSheet( + "background-color: #1abc9c; color: white; font-weight: bold;" + ) + btn_ts.clicked.connect(self.run_tree_sitter) + ts_ctrl.addWidget(btn_ts) + ts_layout.addLayout(ts_ctrl) + self.ts_output = QTextEdit() + self.ts_output.setReadOnly(True) + self.ts_output.setFont(QFont("Consolas", 10)) + self.ts_output.setStyleSheet( + "background-color: #0d0d0d; color: #cfd8dc; padding: 8px;" + ) + ts_layout.addWidget(self.ts_output) + self.analysis_tabs.addTab(ts_page, "tree-sitter") + + layout.addWidget(self.analysis_tabs) + + def browse_ast_file(self): + path, _ = QFileDialog.getOpenFileName( + self, "Select Python File", self.main.base_dir, + "Python (*.py)" + ) + if path: + self.txt_ast_file.setText(path) + self.txt_ts_file.setText(path) + + def run_ripgrep(self): + pattern = self.txt_rg_pattern.text().strip() + search_path = self.txt_rg_path.text().strip() + if not pattern or not search_path: + self.main.log("Pattern and path required.", "CodeAnalysis") + return + self.rg_output.clear() + self.main.log(f"rg: searching '{pattern}' in {search_path}", "CodeAnalysis") + def _run(): + env = enriched_env(self.main.base_bin_dir) + try: + proc = subprocess.run( + ["rg", "--json", pattern, search_path], + capture_output=True, text=True, env=env, timeout=15, + ) + lines = [l for l in proc.stdout.splitlines() if l.strip()] + html_parts = [] + for line in lines[:200]: + try: + data = json.loads(line) + match_type = data.get("type", "match") + if match_type == "match": + d = data["data"] + html_parts.append( + f'' + f'{d["path"]["text"]}:{d["line_number"]}' + f': ' + f'{d["lines"]["text"].strip()}' + ) + elif match_type == "summary": + stats = data["data"]["stats"] + html_parts.append( + f'' + f'{stats["matched_lines"]} matches in ' + f'{stats["matched_files"]} files ' + f'({stats["elapsed"]["total"]}s)' + ) + except (json.JSONDecodeError, KeyError): + html_parts.append(line) + result = "
".join(html_parts) if html_parts else "No results." + QTimer.singleShot( + 0, lambda: self.rg_output.setHtml(result) + ) + except FileNotFoundError: + QTimer.singleShot( + 0, lambda: self.rg_output.setPlainText( + "ripgrep not found. Install: pacman -S ripgrep" + ) + ) + except Exception as exc: + QTimer.singleShot( + 0, lambda: self.rg_output.setPlainText(f"Error: {exc}") + ) + threading.Thread(target=_run, daemon=True).start() + + def run_fd(self): + pattern = self.txt_rg_pattern.text().strip() + search_path = self.txt_rg_path.text().strip() + if not search_path: + self.main.log("Path required.", "CodeAnalysis") + return + self.rg_output.clear() + self.main.log(f"fd: finding '{pattern}' in {search_path}", "CodeAnalysis") + def _run(): + env = enriched_env(self.main.base_bin_dir) + try: + args = ["fd", search_path] if not pattern else ["fd", pattern, search_path] + proc = subprocess.run( + args, capture_output=True, text=True, env=env, timeout=10, + ) + lines = proc.stdout.strip().splitlines()[:200] + html = "
".join( + f'{l}' for l in lines + ) if lines else "No results." + QTimer.singleShot(0, lambda: self.rg_output.setHtml(html)) + except FileNotFoundError: + QTimer.singleShot( + 0, lambda: self.rg_output.setPlainText( + "fd not found. Install: pacman -S fd" + ) + ) + except Exception as exc: + QTimer.singleShot( + 0, lambda: self.rg_output.setPlainText(f"Error: {exc}") + ) + threading.Thread(target=_run, daemon=True).start() + + def run_ast_inspect(self): + file_path = self.txt_ast_file.text().strip() + if not file_path or not os.path.exists(file_path): + self.main.log("Valid Python file path required.", "CodeAnalysis") + return + import ast as ast_mod + self.ast_output.clear() + try: + with open(file_path, encoding="utf-8") as f: + source = f.read() + tree = ast_mod.parse(source) + sections = [] + + imports = [ + f"import {n.name}" if not n.asname + else f"import {n.name} as {n.asname}" + for n in ast_mod.walk(tree) + if isinstance(n, (ast_mod.Import, ast_mod.ImportFrom)) + and isinstance(n, ast_mod.Import) + ] + from_imports = [ + f"from {n.module} import {', '.join(a.name for a in n.names)}" + for n in ast_mod.walk(tree) + if isinstance(n, ast_mod.ImportFrom) and n.module + ] + classes = [ + (n.name, n.lineno, [ + m.name for m in n.body + if isinstance(m, (ast_mod.FunctionDef, ast_mod.AsyncFunctionDef)) + ]) + for n in ast_mod.walk(tree) + if isinstance(n, ast_mod.ClassDef) + ] + functions = [ + (n.name, n.lineno) + for n in ast_mod.walk(tree) + if isinstance(n, (ast_mod.FunctionDef, ast_mod.AsyncFunctionDef)) + and not any( + isinstance(p, ast_mod.ClassDef) + for p in ast_mod.walk(tree) + if hasattr(p, 'body') and n in p.body + ) + ] + + if imports: + sections.append( + "Imports:
" + + "
".join(f'{i}' for i in imports) + ) + if from_imports: + sections.append( + "From Imports:
" + + "
".join(f'{i}' for i in from_imports) + ) + if classes: + cls_lines = [] + for cname, lineno, methods in classes: + methods_str = ", ".join(methods) if methods else "(none)" + cls_lines.append( + f'L{lineno} ' + f'{cname}: {methods_str}' + ) + sections.append( + "Classes:
" + "
".join(cls_lines) + ) + if functions: + fn_lines = [ + f'L{lineno} {fname}' + for fname, lineno in functions + ] + sections.append( + "Top-Level Functions:
" + "
".join(fn_lines) + ) + + result = "

".join(sections) if sections else "Empty module." + self.ast_output.setHtml(result) + self.main.log( + f"AST: {len(classes)} classes, {len(functions)} functions, " + f"{len(imports)} imports in {os.path.basename(file_path)}", + "CodeAnalysis", + ) + except SyntaxError as exc: + self.ast_output.setPlainText(f"Syntax Error: {exc}") + except Exception as exc: + self.ast_output.setPlainText(f"Error: {exc}") + + def run_tree_sitter(self): + file_path = self.txt_ts_file.text().strip() + if not file_path or not os.path.exists(file_path): + self.main.log("Valid file path required.", "CodeAnalysis") + return + self.ts_output.clear() + try: + import tree_sitter_api as ts_api # noqa: F401 + except ImportError: + try: + from tree_sitter import Language, Parser # noqa: F401 + ts_api = None # noqa: F841 + except ImportError: + QTimer.singleShot( + 0, lambda: self.ts_output.setPlainText( + "tree-sitter Python bindings not installed. " + "Install: uv tool install tree-sitter" + ) + ) + self.main.log( + "tree-sitter not available.", "CodeAnalysis" + ) + return + + def _run(): + try: + from tree_sitter_languages import get_language, get_parser + ext_map = { + ".py": "python", ".js": "javascript", + ".ts": "typescript", ".rs": "rust", + ".go": "go", ".c": "c", ".cpp": "cpp", + ".java": "java", ".rb": "ruby", + } + ext = os.path.splitext(file_path)[1].lower() + lang_name = ext_map.get(ext) + if not lang_name: + QTimer.singleShot( + 0, lambda: self.ts_output.setPlainText( + f"Unsupported file type: {ext}" + ) + ) + return + lang = get_language(lang_name) # noqa: F841 + parser = get_parser(lang_name) + with open(file_path, "rb") as f: + tree = parser.parse(f.read()) + lines = [] + def walk(node, depth=0): + prefix = " " * depth + lines.append( + f"{prefix}{node.type} " + f"[{node.start_point[0]+1}:{node.start_point[1]}-" + f"{node.end_point[0]+1}:{node.end_point[1]}]" + ) + for child in node.children: + walk(child, depth + 1) + walk(tree.root_node) + result = "
".join( + f'{l}' + for l in lines[:500] + ) + QTimer.singleShot( + 0, lambda: self.ts_output.setHtml(result) + ) + self.main.log( + f"tree-sitter: parsed {file_path} ({lang_name})", + "CodeAnalysis", + ) + except Exception as exc: + QTimer.singleShot( + 0, lambda: self.ts_output.setPlainText( + f"tree-sitter error: {exc}" + ) + ) + threading.Thread(target=_run, daemon=True).start() + +else: + CodeAnalysisTab = None diff --git a/src/ai_lsc/ui/pages/container_stacks_tab.py b/src/ai_lsc/ui/pages/container_stacks_tab.py new file mode 100644 index 0000000..5d38b20 --- /dev/null +++ b/src/ai_lsc/ui/pages/container_stacks_tab.py @@ -0,0 +1,71 @@ +"""ContainerStacksTab widget — lists exported stack files. + +Displays available stack snapshots and provides export buttons for +Podman Compose and Docker Compose outputs. +""" + +import os + +try: + from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QListWidget, + QPushButton, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class ContainerStacksTab(QWidget): + """Lists exported stack files and provides export buttons.""" + + def __init__(self, main_window): + super().__init__() + self.main = main_window + layout = QVBoxLayout(self) + + header = QHBoxLayout() + header.addWidget(QLabel("Stack Execution Snapshots & Images")) + header.addStretch() + + btn_podman = QPushButton("Export -> Podman Compose") + btn_podman.setStyleSheet("background-color: #8e44ad;") + btn_podman.clicked.connect( + lambda: self.main.finalize_stack_export("podman") + ) + header.addWidget(btn_podman) + + btn_docker = QPushButton("Export -> Docker Compose") + btn_docker.setStyleSheet("background-color: #2980b9;") + btn_docker.clicked.connect( + lambda: self.main.finalize_stack_export("docker") + ) + header.addWidget(btn_docker) + + btn_lxc = QPushButton("Export -> LXC") + btn_lxc.setStyleSheet("background-color: #16a085;") + btn_lxc.clicked.connect( + lambda: self.main.finalize_stack_export("lxc") + ) + header.addWidget(btn_lxc) + layout.addLayout(header) + + self.file_list = QListWidget() + layout.addWidget(self.file_list) + self.refresh() + + def refresh(self): + self.file_list.clear() + if not os.path.exists(self.main.exports_root): + return + for fname in sorted(os.listdir(self.main.exports_root)): + if fname.endswith((".yml", ".json")): + self.file_list.addItem(fname) + +else: + ContainerStacksTab = None diff --git a/src/ai_lsc/ui/pages/datasets_tab.py b/src/ai_lsc/ui/pages/datasets_tab.py new file mode 100644 index 0000000..908e2d0 --- /dev/null +++ b/src/ai_lsc/ui/pages/datasets_tab.py @@ -0,0 +1,94 @@ +"""DatasetsTab widget — managed knowledge repositories browser. + +Provides a table view of files under ``datasets/raw`` with an import +dialog and stub vectorize buttons per row. +""" + +import os +import shutil + +try: + from PySide6.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class DatasetsTab(QWidget): + """Managed knowledge repositories browser.""" + + def __init__(self, main_window): + super().__init__() + self.main = main_window + layout = QVBoxLayout(self) + + header = QHBoxLayout() + header.addWidget(QLabel( + "Managed Knowledge Repositories (datasets/raw)" + )) + header.addStretch() + btn_add = QPushButton("+ Ingest Knowledge File") + btn_add.setStyleSheet("background-color: #009688; color: white;") + btn_add.clicked.connect(self.import_asset) + header.addWidget(btn_add) + layout.addLayout(header) + + self.table = QTableWidget(0, 3) + self.table.setHorizontalHeaderLabels( + ["Filename", "Size (MB)", "Action"] + ) + self.table.horizontalHeader().setSectionResizeMode( + 0, QHeaderView.Stretch + ) + layout.addWidget(self.table) + self.refresh_table() + + @property + def raw_dir(self) -> str: + d = os.path.join(self.main.datasets_root, "raw") + os.makedirs(d, exist_ok=True) + return d + + def import_asset(self): + path, _ = QFileDialog.getOpenFileName( + self, "Select Asset", "", + "Data (*.txt *.csv *.jsonl *.md *.pdf)", + ) + if not path: + return + dest = os.path.join(self.raw_dir, os.path.basename(path)) + shutil.copy(path, dest) + self.main.log(f"Ingested: {os.path.basename(path)}", "Data") + self.refresh_table() + + def refresh_table(self): + self.table.setRowCount(0) + if not os.path.exists(self.raw_dir): + return + for f in sorted(os.listdir(self.raw_dir)): + p = os.path.join(self.raw_dir, f) + if not os.path.isfile(p): + continue + row = self.table.rowCount() + self.table.insertRow(row) + self.table.setItem(row, 0, QTableWidgetItem(f)) + self.table.setItem( + row, 1, + QTableWidgetItem(f"{os.path.getsize(p) / (1024 * 1024):.2f} MB"), + ) + btn = QPushButton("Vectorize") + self.table.setCellWidget(row, 2, btn) + +else: + DatasetsTab = None diff --git a/src/ai_lsc/ui/pages/git_worktree_tab.py b/src/ai_lsc/ui/pages/git_worktree_tab.py new file mode 100644 index 0000000..637b025 --- /dev/null +++ b/src/ai_lsc/ui/pages/git_worktree_tab.py @@ -0,0 +1,149 @@ +"""GitWorktreeTab widget — git worktree management. + +List, create, and remove git worktrees for repositories discovered +under the base directory. All git operations use ``enriched_env`` for +PATH resolution. +""" + +import os +import subprocess + +from ai_lsc.utils.process import enriched_env + +try: + from PySide6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class GitWorktreeTab(QWidget): + """Git worktree management: list, create, remove worktrees for repos.""" + + def __init__(self, main_window): + super().__init__() + self.main = main_window + layout = QVBoxLayout(self) + + header = QHBoxLayout() + header.addWidget(QLabel("Git Sources — Registry Tool Tracker")) + header.addStretch() + self.btn_scan = QPushButton("Scan Repos") + self.btn_scan.clicked.connect(self.scan_repos) + header.addWidget(self.btn_scan) + layout.addLayout(header) + + self.repo_combo = QComboBox() + self.repo_combo.setPlaceholderText("Select a repository...") + layout.addWidget(self.repo_combo) + + ctrl = QHBoxLayout() + self.txt_new_branch = QLineEdit() + self.txt_new_branch.setPlaceholderText("New branch name for worktree") + ctrl.addWidget(self.txt_new_branch) + btn_add = QPushButton("Add Worktree") + btn_add.setStyleSheet("background-color: #27ae60; color: white;") + btn_add.clicked.connect(self.add_worktree) + ctrl.addWidget(btn_add) + btn_rm = QPushButton("Remove Selected") + btn_rm.setStyleSheet("background-color: #c0392b; color: white;") + btn_rm.clicked.connect(self.remove_worktree) + ctrl.addWidget(btn_rm) + layout.addLayout(ctrl) + + self.tree_table = QTableWidget(0, 3) + self.tree_table.setHorizontalHeaderLabels( + ["Worktree Path", "Branch", "Status"] + ) + self.tree_table.horizontalHeader().setSectionResizeMode( + 0, QHeaderView.Stretch + ) + self.tree_table.setSelectionBehavior(QAbstractItemView.SelectRows) + layout.addWidget(self.tree_table) + self.scan_repos() + + def scan_repos(self): + self.repo_combo.clear() + base = self.main.base_dir + candidates = [ + d for d in os.listdir(base) + if os.path.isdir(os.path.join(base, d, ".git")) + ] + for repo in candidates: + self.repo_combo.addItem(repo) + + def _current_repo_path(self) -> str | None: + name = self.repo_combo.currentText() + if not name: + return None + return os.path.join(self.main.base_dir, name) + + def _run_git(self, args: list[str], cwd: str) -> str: + env = enriched_env(self.main.base_bin_dir) + proc = subprocess.run( + ["git"] + args, capture_output=True, text=True, + cwd=cwd, env=env, timeout=10, + ) + return proc.stdout.strip() + + def refresh_table(self): + repo_path = self._current_repo_path() + if not repo_path: + return + raw = self._run_git( + ["worktree", "list", "--porcelain"], repo_path + ) + self.tree_table.setRowCount(0) + if not raw: + return + for line in raw.splitlines(): + parts = line.split(" ", 2) + if len(parts) < 3: + continue + wt_path, branch, status = parts[0], parts[1].lstrip("[]"), parts[2] + row = self.tree_table.rowCount() + self.tree_table.insertRow(row) + self.tree_table.setItem(row, 0, QTableWidgetItem(wt_path)) + self.tree_table.setItem(row, 1, QTableWidgetItem(branch)) + self.tree_table.setItem(row, 2, QTableWidgetItem(status)) + + def add_worktree(self): + repo_path = self._current_repo_path() + branch = self.txt_new_branch.text().strip() + if not repo_path or not branch: + self.main.log("Provide repo and branch name.", "GitWorktree") + return + wt_dir = os.path.join(os.path.dirname(repo_path), + f"{os.path.basename(repo_path)}-{branch}") + self._run_git( + ["worktree", "add", wt_dir, branch], repo_path + ) + self.main.log(f"Worktree created: {wt_dir}", "GitWorktree") + self.txt_new_branch.clear() + self.refresh_table() + + def remove_worktree(self): + repo_path = self._current_repo_path() + row = self.tree_table.currentRow() + if not repo_path or row < 0: + return + wt_path = self.tree_table.item(row, 0).text() + self._run_git(["worktree", "remove", wt_path], repo_path) + self.main.log(f"Worktree removed: {wt_path}", "GitWorktree") + self.refresh_table() + +else: + GitWorktreeTab = None diff --git a/src/ai_lsc/ui/pages/infrastructure_layer_page.py b/src/ai_lsc/ui/pages/infrastructure_layer_page.py new file mode 100644 index 0000000..0693801 --- /dev/null +++ b/src/ai_lsc/ui/pages/infrastructure_layer_page.py @@ -0,0 +1,63 @@ +"""InfrastructureLayerPage widget — filtered registry view for a single layer. + +Displays ServiceRows for every tool belonging to a given infrastructure +layer inside a scrollable area. +""" + +try: + from PySide6.QtGui import QFont + from PySide6.QtWidgets import ( + QLabel, + QScrollArea, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + try: + from ai_lsc.ui.pages.service_row import ServiceRow + except ImportError: + ServiceRow = None + + class InfrastructureLayerPage(QWidget): + """Filtered registry view for a single infrastructure layer. + Displays ServiceRows for every tool belonging to the given layer.""" + + def __init__(self, main_window, layer_name: str): + super().__init__() + self.main = main_window + self.layer_name = layer_name + layout = QVBoxLayout(self) + + lbl = QLabel(f"Infrastructure Workspace -- {layer_name}") + lbl.setFont(QFont("Segoe UI", 14)) + layout.addWidget(lbl) + + lbl_desc = QLabel( + f"Filtered registry view showing tools in the " + f"{layer_name} abstraction stratum." + ) + lbl_desc.setStyleSheet("color: #7f8c8d;") + layout.addWidget(lbl_desc) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll_content = QWidget() + self.layer_layout = QVBoxLayout(scroll_content) + + grouped = self.main.registry_mgr.get_grouped_by_layer() + layer_tools = grouped.get(layer_name, []) + for t_id, meta in layer_tools: + port = meta.get("launcher", {}).get("default_port") + row = ServiceRow(self.main, t_id, port, meta) + self.layer_layout.addWidget(row) + + self.layer_layout.addStretch() + scroll.setWidget(scroll_content) + layout.addWidget(scroll) + +else: + InfrastructureLayerPage = None diff --git a/src/ai_lsc/ui/pages/ipc_stack_tab.py b/src/ai_lsc/ui/pages/ipc_stack_tab.py new file mode 100644 index 0000000..a292c86 --- /dev/null +++ b/src/ai_lsc/ui/pages/ipc_stack_tab.py @@ -0,0 +1,198 @@ +"""IpcStackTab widget -- AI-LSC Stack Editor. + +Drag ecosystem tools and skills into an ordered execution flow, validate +dependencies, and compile the runtime stack state to a JSON file. +""" + +import json +import os +from datetime import datetime + +from ai_lsc.constants import PIPELINE_FILE_NAME, STATE_FILE_NAME + +try: + from PySide6.QtCore import Qt + from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class IpcStackTab(QWidget): + """Visual stack flow compiler: drag ecosystem tools and skills + into an ordered execution flow, validate dependencies, and compile + the runtime stack state.""" + + def __init__(self, main_window): + super().__init__() + self.main = main_window + layout = QHBoxLayout(self) + + left_panel = QVBoxLayout() + left_panel.addWidget(QLabel("Available Ecosystem Components")) + self.avail_tree = QTreeWidget() + self.avail_tree.setColumnCount(1) + self.avail_tree.setHeaderLabels(["Component"]) + left_panel.addWidget(self.avail_tree) + + center_panel = QVBoxLayout() + center_panel.addStretch() + btn_add = QPushButton(" >> ") + btn_add.clicked.connect(self.add_node) + center_panel.addWidget(btn_add) + btn_rem = QPushButton(" << ") + btn_rem.clicked.connect(self.remove_node) + center_panel.addWidget(btn_rem) + center_panel.addStretch() + + right_panel = QVBoxLayout() + right_panel.addWidget(QLabel("Active Execution Flow")) + self.flow_list = QListWidget() + right_panel.addWidget(self.flow_list) + + self.lbl_validation = QLabel("Health: Awaiting Compilation...") + self.lbl_validation.setStyleSheet("color: #7f8c8d; font-weight: bold;") + right_panel.addWidget(self.lbl_validation) + + btn_compile = QPushButton("Compile Stack") + btn_compile.setStyleSheet( + "background-color: #27ae60; padding: 10px; font-weight: bold;" + ) + btn_compile.clicked.connect(self.compile_stack) + right_panel.addWidget(btn_compile) + + layout.addLayout(left_panel, 2) + layout.addLayout(center_panel, 1) + layout.addLayout(right_panel, 2) + + def refresh(self): + self.avail_tree.clear() + state_file = os.path.join(self.main.config_root, STATE_FILE_NAME) + installed_tools = [] + if os.path.exists(state_file): + try: + with open(state_file) as f: + installed_tools = json.load(f).get("active_tools", []) + except Exception: + pass + + if installed_tools: + eco_node = QTreeWidgetItem(["Ecosystem Infrastructure"]) + self.avail_tree.addTopLevelItem(eco_node) + for t_id in installed_tools: + meta = self.main.registry_mgr.get_tool(t_id) + item = QTreeWidgetItem([meta.get("name", t_id)]) + item.setData(0, Qt.UserRole, t_id) + eco_node.addChild(item) + + skills_dir = self.main.skills_root + if os.path.exists(skills_dir): + skill_node = QTreeWidgetItem(["Runtime Skills"]) + self.avail_tree.addTopLevelItem(skill_node) + for entry in sorted(os.listdir(skills_dir)): + if entry.startswith("."): + continue + full = os.path.join(skills_dir, entry) + if os.path.isfile(full): + item = QTreeWidgetItem([entry]) + item.setData(0, Qt.UserRole, f"skill:{entry}") + skill_node.addChild(item) + self.avail_tree.expandAll() + + def add_node(self): + selected = self.avail_tree.currentItem() + if not selected or not selected.data(0, Qt.UserRole): + return + t_id = selected.data(0, Qt.UserRole) + display = selected.text(0) + if t_id.startswith("skill:"): + display = f"[Skill] {display}" + + existing = [ + self.flow_list.item(i).data(Qt.UserRole) + for i in range(self.flow_list.count()) + ] + if t_id in existing: + return + + item = QListWidgetItem(display) + item.setData(Qt.UserRole, t_id) + self.flow_list.addItem(item) + self.validate_flow() + + def remove_node(self): + row = self.flow_list.currentRow() + if row >= 0: + self.flow_list.takeItem(row) + self.validate_flow() + + def validate_flow(self): + flow_ids = [ + self.flow_list.item(i).data(Qt.UserRole) + for i in range(self.flow_list.count()) + ] + missing = self.main.registry_mgr.check_dependencies(flow_ids) + if missing: + dep_names = [ + self.main.registry_mgr.get_tool(d).get("name", d) + for d in missing + ] + self.lbl_validation.setText( + f"Warning: Missing Dependencies -> {', '.join(dep_names)}" + ) + self.lbl_validation.setStyleSheet( + "color: #e74c3c; font-weight: bold;" + ) + else: + self.lbl_validation.setText( + "Health: Dependencies Satisfied. Ready to compile." + ) + self.lbl_validation.setStyleSheet( + "color: #2ecc71; font-weight: bold;" + ) + + def compile_stack(self): + flow_ids = [ + self.flow_list.item(i).data(Qt.UserRole) + for i in range(self.flow_list.count()) + ] + if not flow_ids: + return + + port_map = {} + for t_id in flow_ids: + if t_id.startswith("skill:"): + continue + meta = self.main.registry_mgr.get_tool(t_id) + default_port = meta.get("launcher", {}).get("default_port") + if default_port: + port_map[t_id] = default_port + + state = { + "active_tools": flow_ids, + "port_map": port_map, + "timestamp": datetime.now().isoformat(), + } + pipe_file = os.path.join(self.main.config_root, PIPELINE_FILE_NAME) + os.makedirs(self.main.config_root, exist_ok=True) + with open(pipe_file, "w") as f: + json.dump(state, f, indent=4) + + self.main.log( + "Stack orchestration compiled successfully.", "StackCompiler" + ) + self.main._populate_services() + +else: + IpcStackTab = None diff --git a/src/ai_lsc/ui/pages/service_row.py b/src/ai_lsc/ui/pages/service_row.py new file mode 100644 index 0000000..af35f45 --- /dev/null +++ b/src/ai_lsc/ui/pages/service_row.py @@ -0,0 +1,515 @@ +"""ServiceRow widget -- a single service row driven by registry metadata + flags. + +Renders one tool (or skill:-prefixed behavior binding) inside the +Tools/Services page. Each row shows the service name, live status, +CPU load, port input, model selector (for engine/LLM-runtime services), +Ollama pull controls, launcher buttons (CLI/GUI/Web), and +Install/Sync + Start/Stop action buttons. + +All process management is delegated to +:class:`~ai_lsc.runtime.executor.RuntimeExecutor` -- this widget +contains **zero** ``subprocess`` / ``psutil`` calls. +""" + +import os +import threading + +from ai_lsc.constants import SERVICE_LICENSES, STATUS_STYLES +from ai_lsc.utils.process import cpu_load_for_processes + +try: + from PySide6.QtCore import Qt, QTimer + from PySide6.QtGui import QFont + from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QFrame, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class ServiceRow(QWidget): + """A single service row, fully driven by registry metadata + flags. + Supports both regular tools and skill:-prefixed behavior bindings.""" + + def __init__(self, parent, tool_id: str, port, meta: dict): + super().__init__(parent) + self.parent = parent + self.tool_id = tool_id + self.port = port + self.meta = meta + self.is_skill = tool_id.startswith("skill:") + + if self.is_skill: + self._build_skill_row() + return + + self.installer = meta.get("installer", {}) + self.launcher = meta.get("launcher", {}) + self.flags = meta.get("flags", {}) + self.is_ollama = self.flags.get("is_ollama", False) + self.is_docker = self.flags.get("is_docker", False) + self.has_models = meta.get("category") in ( + "Engine", "LLM Runtime", "Development", + ) + self.has_api = self.flags.get("has_web", False) or meta.get( + "category" + ) in ( + "Engine", "LLM Runtime", "Pipeline", + "Agent Framework", "Embedding", + ) + self.search_term = self.installer.get("pkg", tool_id) + self._build_ui() + + # -- property shortcuts to parent attributes ----------------------- + + @property + def _runtime(self): + """Lazy accessor for the RuntimeExecutor on the main window.""" + return self.parent.runtime + + def _log(self, text: str, source: str = "System"): + self.parent.log(text, source) + + # -- row builders -------------------------------------------------- + + def _build_skill_row(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 5, 10, 5) + skill_name = self.tool_id.split(":", 1)[1] + + self.lbl_name = QLabel(f"[Skill] {skill_name}") + self.lbl_name.setFixedWidth(160) + self.lbl_name.setFont(QFont("Consolas", 11, QFont.Bold)) + layout.addWidget(self.lbl_name) + + self.lbl_status = QLabel("[ READY ]") + self.lbl_status.setFixedWidth(90) + self.lbl_status.setAlignment(Qt.AlignCenter) + self.lbl_status.setStyleSheet( + "color: #3498db; font-weight: bold;" + ) + layout.addWidget(self.lbl_status) + + desc = QLabel("Skill Behavior Binding (Active in Pipeline)") + desc.setStyleSheet("color: #7f8c8d; font-style: italic;") + layout.addWidget(desc) + layout.addStretch() + + self.txt_port = None + self.cbo_model = None + + def _build_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 5, 10, 5) + + level_tag = f"L{self.meta.get('level', 0)}" + self.lbl_name = QLabel( + f"[{level_tag}] {self.meta.get('name', self.tool_id)}" + ) + self.lbl_name.setFixedWidth(160) + self.lbl_name.setFont(QFont("Consolas", 11, QFont.Bold)) + layout.addWidget(self.lbl_name) + + self.lbl_status = QLabel("[ CHECKING ]") + self.lbl_status.setFixedWidth(90) + self.lbl_status.setAlignment(Qt.AlignCenter) + layout.addWidget(self.lbl_status) + + self.lbl_load = QLabel("---") + self.lbl_load.setFixedWidth(50) + self.lbl_load.setStyleSheet( + "color: #FFB000; font-family: 'Consolas'; font-weight: bold;" + ) + layout.addWidget(self.lbl_load) + + if self.port is not None: + layout.addWidget(QLabel("Port:")) + self.txt_port = QLineEdit(str(self.port)) + self.txt_port.setFixedWidth(60) + self.txt_port.setAlignment(Qt.AlignCenter) + layout.addWidget(self.txt_port) + else: + self.txt_port = None + spacer = QFrame() + spacer.setFixedWidth(95) + layout.addWidget(spacer) + + if self.has_models: + self.cbo_model = QComboBox() + self.cbo_model.setFixedWidth(180) + layout.addWidget(self.cbo_model) + else: + self.cbo_model = None + spacer = QFrame() + spacer.setFixedWidth(180) + layout.addWidget(spacer) + + if self.is_ollama: + self.txt_pull = QLineEdit() + self.txt_pull.setPlaceholderText("model tag") + self.txt_pull.setFixedWidth(100) + layout.addWidget(self.txt_pull) + btn_pull = QPushButton("Pull") + btn_pull.setFixedWidth(50) + btn_pull.setStyleSheet( + "background-color: #d35400; color: white; font-weight: bold;" + ) + btn_pull.clicked.connect(self.pull_model) + layout.addWidget(btn_pull) + + layout.addStretch() + + # -- Infer C/W/G interface buttons from flags + launcher type -- + # Tools with explicit flags keep them; others are inferred so + # every service row gets the correct launcher buttons. + launcher_type = self.launcher.get("type", "") + has_port = self.port is not None + show_web = self.flags.get("has_web", False) or ( + has_port and launcher_type in ("tmux", "systemd") + ) + show_gui = self.flags.get("has_gui", False) or ( + launcher_type == "desktop" + ) + show_cli = self.flags.get("has_cli", False) or ( + launcher_type in ("cli", "tmux") + ) + + launcher_style = ( + "QPushButton { background-color: #34495e; color: white; " + "font-weight: bold; border-radius: 3px; } " + "QPushButton:hover { background-color: #415b76; }" + ) + for visible, label, tip, handler in [ + (show_cli, "C", "Launch CLI in terminal", self.launch_cli), + (show_gui, "G", "Launch Desktop/GUI app", self.launch_gui), + (show_web, "W", "Open Web UI in browser", self.launch_web), + ]: + if visible: + btn = QPushButton(label) + btn.setFixedWidth(28) + btn.setToolTip(tip) + btn.setStyleSheet(launcher_style) + btn.clicked.connect(handler) + layout.addWidget(btn) + + spacer = QFrame() + spacer.setFixedWidth(10) + layout.addWidget(spacer) + + # API endpoint / key button for tools with external API support + if self.has_api: + btn_api = QPushButton(":set api") + btn_api.setToolTip( + "Set external API endpoint and key" + ) + btn_api.setStyleSheet( + "QPushButton { background-color: #e67e22; color: white; " + "font-weight: bold; font-size: 11px; border-radius: 3px; }" + "QPushButton:hover { background-color: #f39c12; }" + ) + btn_api.setFixedWidth(60) + btn_api.clicked.connect(self._open_api_dialog) + layout.addWidget(btn_api) + + self.btn_update = QPushButton("Install / Sync") + self.btn_update.setStyleSheet("background-color: #8e44ad;") + self.btn_update.clicked.connect(self.smart_install) + layout.addWidget(self.btn_update) + + start_labels = { + "systemd": "Enable (systemd)", + "desktop": "Launch App", + } + self.btn_start = QPushButton( + start_labels.get(self.launcher.get("type"), "Start Engine") + ) + self.btn_start.setStyleSheet("background-color: #27ae60;") + self.btn_start.clicked.connect(self.start_service) + layout.addWidget(self.btn_start) + + stop_labels = {"systemd": "Disable"} + self.btn_stop = QPushButton( + stop_labels.get(self.launcher.get("type"), "Kill Process") + ) + self.btn_stop.setStyleSheet("background-color: #c0392b;") + self.btn_stop.clicked.connect(self.stop_service) + layout.addWidget(self.btn_stop) + + # -- model hydration ----------------------------------------------- + + def hydrate_models(self, ollama_models: list, aider_models: list): + if not self.has_models or self.cbo_model is None: + return + self.cbo_model.clear() + pool = ( + aider_models + if any(k in self.tool_id.lower() + for k in ("aider", "claude")) + else ollama_models + ) + self.cbo_model.addItems(pool) + + # -- launcher actions (delegate to runtime) ---------------------- + + def launch_cli(self): + desc = self._runtime.launch_cli( + tool_id=self.tool_id, + launcher_type=self.launcher.get("type", ""), + ) + self._log(desc) + + def launch_gui(self): + self._log( + f"GUI payload triggered for: {self.meta.get('name')}", "System" + ) + + def launch_web(self): + if self.txt_port is None: + return + url = self._runtime.open_web_url(self.txt_port.text().strip()) + self._log(f"Browser navigated to {url}", "System") + + # -- install (dispatched via runtime) ----------------------------- + + def smart_install(self): + inst_type = self.installer.get("type") + pkg = self.installer.get("pkg", self.tool_id) + cmd = self.installer.get("cmd", "") + + self._log( + f"Deploying {self.meta.get('name')} via {inst_type}...", + "Installer", + ) + + license_text = SERVICE_LICENSES.get(self.meta.get("name")) + if license_text: + msg = QMessageBox(self) + msg.setWindowTitle(f"Legal Notice: {self.meta.get('name')}") + msg.setText( + f"To install {self.meta.get('name')}, " + f"you must accept the license." + ) + msg.setInformativeText(license_text) + msg.setTextInteractionFlags(Qt.TextBrowserInteraction) + msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + if msg.exec() != QMessageBox.Ok: + return + + threading.Thread( + target=self._run_install, + args=(inst_type, pkg, cmd), + daemon=True, + ).start() + + def _run_install(self, inst_type: str, pkg: str, cmd: str): + ctx = self._runtime.format_context() + try: + desc = self._runtime.install_tool( + inst_type=inst_type, + pkg=pkg, + cmd=cmd, + tool_id=self.tool_id, + ctx=ctx, + ) + QTimer.singleShot( + 0, lambda d=desc, t=self.tool_id: self._log(d, t) + ) + except Exception as exc: + QTimer.singleShot( + 0, lambda e=exc: self._log( + f"Deployment failed: {e}", "Installer Error" + ) + ) + + # -- ollama model pull -------------------------------------------- + + def pull_model(self): + model_name = self.txt_pull.text().strip() + if not model_name: + self._log( + "Error: Specify a valid model target descriptor.", "Ollama" + ) + return + self._log(f"Spawning pull for: {model_name}", "Ollama") + + def _run(): + try: + proc = self._runtime.pull_model(model_name) + for raw_line in proc.stdout: + stripped = raw_line.strip() + if stripped: + QTimer.singleShot( + 0, + lambda l=stripped: self._log(l, "Ollama"), + ) + proc.wait() + tag = ( + f"Model {model_name} synced successfully." + if proc.returncode == 0 + else f"Pull failed with exit code {proc.returncode}" + ) + QTimer.singleShot(0, lambda: self._log(tag, "Ollama")) + if proc.returncode == 0: + QTimer.singleShot( + 0, self.parent.refresh_all_models + ) + except Exception as exc: + QTimer.singleShot( + 0, lambda: self._log(f"IO Exception: {exc}", "Ollama") + ) + + threading.Thread(target=_run, daemon=True).start() + + # -- start / stop / status (delegated to runtime) --------------- + + def start_service(self): + port = self.txt_port.text() if self.txt_port else "" + model_arg = "" + if (self.has_models and self.cbo_model + and self.cbo_model.currentText()): + model_arg = self.cbo_model.currentText() + + log_file = os.path.join(self.parent.logs_root, f"{self.tool_id}.log") + desc = self._runtime.start_service( + tool_id=self.tool_id, + launcher_cmd=self.launcher.get("cmd", ""), + launcher_type=self.launcher.get("type", ""), + port=port, + model_arg=model_arg, + ) + self._log(desc, self.launcher.get("type", "Tmux").capitalize()) + self.parent.verify_and_watch(log_file) + QTimer.singleShot(2000, self.update_status) + + def stop_service(self): + desc = self._runtime.stop_service( + tool_id=self.tool_id, + launcher_type=self.launcher.get("type", ""), + launcher_cmd=self.launcher.get("cmd", ""), + search_term=self.search_term, + is_docker=self.is_docker, + ) + self._log(desc, "System") + QTimer.singleShot(1500, self.update_status) + + def update_status(self): + if self.is_skill: + return + running = self._runtime.is_service_running( + launcher_type=self.launcher.get("type", ""), + tool_id=self.tool_id, + service_cmd=self.launcher.get("cmd", ""), + search_term=self.search_term, + ) + cpu = ( + cpu_load_for_processes(self.search_term) + if running + else 0.0 + ) + text, color = STATUS_STYLES[running] + self.lbl_status.setText(text) + self.lbl_status.setStyleSheet(f"color: {color}; font-weight: bold;") + self.lbl_load.setText(f"{int(cpu)}%" if running else "---") + + # -- API configuration dialog ------------------------------------ + + def _open_api_dialog(self): + dlg = QDialog(self) + dlg.setWindowTitle( + f"API Configuration — {self.meta.get('name', self.tool_id)}" + ) + dlg.setMinimumWidth(450) + dlg_layout = QVBoxLayout(dlg) + + form = QFormLayout() + txt_endpoint = QLineEdit() + txt_endpoint.setPlaceholderText( + "e.g. https://api.openai.com/v1" + ) + txt_key = QLineEdit() + txt_key.setPlaceholderText( + "e.g. sk-... (stored locally only)" + ) + txt_key.setEchoMode(QLineEdit.Password) + txt_model_override = QLineEdit() + txt_model_override.setPlaceholderText( + "e.g. gpt-4o (optional model override)" + ) + form.addRow("API Endpoint:", txt_endpoint) + form.addRow("API Key:", txt_key) + form.addRow("Model Override:", txt_model_override) + dlg_layout.addLayout(form) + + # Restore saved values + cfg = self.parent.config_data.get("api_overrides", {}) + tool_cfg = cfg.get(self.tool_id, {}) + txt_endpoint.setText(tool_cfg.get("endpoint", "")) + txt_key.setText(tool_cfg.get("api_key", "")) + txt_model_override.setText(tool_cfg.get("model_override", "")) + + btn_box = QHBoxLayout() + btn_save = QPushButton("Save") + btn_save.setStyleSheet( + "background-color: #27ae60; color: white; font-weight: bold;" + ) + btn_clear = QPushButton("Clear") + btn_clear.setStyleSheet( + "background-color: #c0392b; color: white;" + ) + btn_cancel = QPushButton("Cancel") + btn_box.addStretch() + btn_box.addWidget(btn_save) + btn_box.addWidget(btn_clear) + btn_box.addWidget(btn_cancel) + dlg_layout.addLayout(btn_box) + + def _save(): + api_overrides = self.parent.config_data.setdefault( + "api_overrides", {} + ) + api_overrides[self.tool_id] = { + "endpoint": txt_endpoint.text().strip(), + "api_key": txt_key.text().strip(), + "model_override": txt_model_override.text().strip(), + } + self.parent.save_config() + self._log( + f"API configuration saved for {self.meta.get('name')}", + "API Config", + ) + dlg.accept() + + def _clear(): + api_overrides = self.parent.config_data.get( + "api_overrides", {} + ) + api_overrides.pop(self.tool_id, None) + self.parent.save_config() + txt_endpoint.clear() + txt_key.clear() + txt_model_override.clear() + self._log( + f"API configuration cleared for {self.meta.get('name')}", + "API Config", + ) + + btn_save.clicked.connect(_save) + btn_clear.clicked.connect(_clear) + btn_cancel.clicked.connect(dlg.reject) + + dlg.exec() + +else: + ServiceRow = None diff --git a/src/ai_lsc/ui/pages/settings_page.py b/src/ai_lsc/ui/pages/settings_page.py new file mode 100644 index 0000000..9e946e4 --- /dev/null +++ b/src/ai_lsc/ui/pages/settings_page.py @@ -0,0 +1,54 @@ +"""SettingsPage widget — ecosystem system configurations and hardening policies. + +Presents a set of security-policy checkboxes in a grouped layout. +""" + +try: + from PySide6.QtGui import QFont + from PySide6.QtWidgets import ( + QCheckBox, + QGridLayout, + QGroupBox, + QLabel, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class SettingsPage(QWidget): + """Ecosystem system configurations and core hardening policies.""" + + def __init__(self, main_window): + super().__init__() + self.main = main_window + layout = QVBoxLayout(self) + + lbl = QLabel( + "Ecosystem System Configurations & Core Hardening Policies" + ) + lbl.setFont(QFont("Segoe UI", 14)) + layout.addWidget(lbl) + + box = QGroupBox("Bare-Metal Operational Environment Security Rules") + box_layout = QGridLayout(box) + + policies = [ + "Enforce Strict Clean-Room Local Sandbox Isolation Rules", + "Disable External Network Cloud Access Topology Check Handlers", + "Auto-Rollback Infrastructure Layers on Failure Mismatches", + "Inject Kernel Hardened Telemetry Constraints", + "Enable Zero-Drift Registry Integrity Verification", + "Require Explicit Approval for Container Network Bridges", + ] + for idx, policy_text in enumerate(policies): + box_layout.addWidget(QCheckBox(policy_text), idx, 0) + + layout.addWidget(box) + layout.addStretch() + +else: + SettingsPage = None diff --git a/src/ai_lsc/ui/pages/skills_console.py b/src/ai_lsc/ui/pages/skills_console.py new file mode 100644 index 0000000..3e637df --- /dev/null +++ b/src/ai_lsc/ui/pages/skills_console.py @@ -0,0 +1,252 @@ +"""SkillsConsole widget — recursive Modelfile tree scanner and skill compiler. + +Scans the skills root directory for Modelfile blueprints, presents them in a +checkable tree, and compiles checked skills via ``ollama create`` in daemon +threads. Event feedback is appended to an on-screen console log. +""" + +import os +import re +import subprocess +import threading +from datetime import datetime + +from ai_lsc.constants import TREE_SKIP_PATTERNS +from ai_lsc.utils.process import enriched_env + +try: + from PySide6.QtCore import Qt, QTimer + from PySide6.QtGui import QColor, QFont + from PySide6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTextEdit, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class SkillsConsole(QWidget): + """Scans Modelfile blueprints via recursive tree, compiles checked skills.""" + + def __init__(self, parent): + super().__init__() + self.parent = parent + layout = QVBoxLayout(self) + + top = QHBoxLayout() + top.addWidget(QLabel("Target Runtime Engine:")) + self.agent_combo = QComboBox() + self.agent_combo.addItems([ + "Ollama Local Cluster", "Hermes Orchestrator", + "Odysseus Matrix", "Dify Upstream Pipeline", + ]) + top.addWidget(self.agent_combo) + + btn_build = QPushButton("Build/Register Selected Skills") + btn_build.setStyleSheet( + "background-color: #d35400; color: white; font-weight: bold; " + "padding: 5px 15px;" + ) + btn_build.clicked.connect(self.compile_checked_skills) + top.addWidget(btn_build) + top.addStretch() + layout.addLayout(top) + + layout.addWidget(QLabel( + "Discovered Modelfiles Cluster (Checked = Active):" + )) + self.skills_tree = QTreeWidget() + self.skills_tree.setColumnCount(2) + self.skills_tree.setHeaderLabels([ + "Skill / Modelfile Model Name", + "Inferred Functional System Description", + ]) + self.skills_tree.header().setSectionResizeMode( + 0, QHeaderView.Interactive + ) + self.skills_tree.header().setSectionResizeMode(1, QHeaderView.Stretch) + self.skills_tree.setColumnWidth(0, 260) + self.skills_tree.setStyleSheet(""" + QTreeWidget { background-color: #1e1e1e; color: #d4d4d4; + border: 1px solid #333; } + QTreeWidget::item:hover { background-color: #2d2d2d; } + QHeaderView::section { background-color: #2d2d2d; color: #b2bec3; + padding: 4px; border: 1px solid #1e1e1e; } + """) + layout.addWidget(self.skills_tree) + + layout.addWidget(QLabel("Skill Generation Event Feedback Log:")) + self.console_output = QTextEdit() + self.console_output.setReadOnly(True) + self.console_output.setFont(QFont("Consolas", 10)) + layout.addWidget(self.console_output) + self.refresh_skills() + + @staticmethod + def parse_modelfile_description(file_path: str) -> str: + try: + with open(file_path, encoding="utf-8", errors="ignore") as f: + content = f.read() + for pattern, flags in [ + (r'SYSTEM\s+"""(.*?)"""', + re.DOTALL | re.IGNORECASE), + (r'SYSTEM\s+"(.*?)"', re.IGNORECASE), + ]: + match = re.search(pattern, content, flags) + if match: + desc = " ".join(match.group(1).strip().splitlines()) + return desc[:110] + "..." if len(desc) > 110 else desc + for line in content.splitlines(): + stripped = line.strip() + if (stripped.startswith("#") + and "ollama run" not in stripped.lower() + and "ollama create" not in stripped.lower()): + clean = stripped.lstrip("# ").strip() + if clean: + return clean + except Exception: + pass + return "Configured Model Template (No embedded description found)" + + def refresh_skills(self): + self.skills_tree.clear() + skills_dir = self.parent.skills_root + if not os.path.exists(skills_dir): + return + self._build_tree(self.skills_tree, skills_dir) + self.skills_tree.expandToDepth(0) + + def _build_tree(self, parent_item, path: str): + try: + entries = sorted(os.listdir(path)) + except PermissionError: + return + for entry in entries: + if entry.startswith(".") or entry in TREE_SKIP_PATTERNS: + continue + full_path = os.path.join(path, entry) + if os.path.isdir(full_path): + if not any(os.scandir(full_path)): + continue + folder_item = QTreeWidgetItem(parent_item) + folder_item.setText( + 0, entry.replace("-", " ").replace("_", " ").title() + ) + folder_item.setForeground(0, QColor("#e67e22")) + font = QFont() + font.setBold(True) + folder_item.setFont(0, font) + self._build_tree(folder_item, full_path) + elif self._is_valid_modelfile(full_path): + desc = self.parse_modelfile_description(full_path) + item = QTreeWidgetItem(parent_item) + item.setText(0, entry) + item.setText(1, desc) + item.setToolTip(1, desc) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(0, Qt.Unchecked) + item.setData(0, Qt.UserRole, full_path) + + @staticmethod + def _is_valid_modelfile(path: str) -> bool: + try: + with open(path, encoding="utf-8", errors="ignore") as f: + head = f.read(1024) + return any( + kw in head for kw in ("FROM", "SYSTEM", "# Run `ollama") + ) + except Exception: + return False + + def _traverse_checked(self) -> list[tuple[str, str]]: + results = [] + + def walk(item): + for i in range(item.childCount()): + child = item.child(i) + if (child.checkState(0) == Qt.Checked + and child.data(0, Qt.UserRole)): + results.append((child.text(0), child.data(0, Qt.UserRole))) + walk(child) + + walk(self.skills_tree.invisibleRootItem()) + return results + + def get_checked_skills(self) -> list[str]: + return [name for name, _ in self._traverse_checked()] + + def get_all_skills_map(self) -> dict[str, str]: + mapping = {} + + def walk(item): + for i in range(item.childCount()): + child = item.child(i) + fp = child.data(0, Qt.UserRole) + if fp: + mapping[child.text(0)] = fp + walk(child) + + walk(self.skills_tree.invisibleRootItem()) + return mapping + + def compile_checked_skills(self): + targets = self._traverse_checked() + if not targets: + ts = datetime.now().strftime("%H:%M:%S") + self.console_output.append( + f"[{ts}] Compilation bypassed: No model checkboxes active." + ) + return + for model_name, path in targets: + ts = datetime.now().strftime("%H:%M:%S") + self.console_output.append( + f"[{ts}] Injecting Modelfile -> Building: '{model_name}'..." + ) + threading.Thread( + target=self._build_one, + args=(model_name, path), + daemon=True, + ).start() + + def _build_one(self, model_name: str, modelfile_path: str): + try: + env = enriched_env(self.parent.base_bin_dir) + proc = subprocess.run( + ["ollama", "create", model_name, "-f", modelfile_path], + capture_output=True, text=True, env=env, + cwd=os.path.dirname(modelfile_path), + ) + ts = datetime.now().strftime("%H:%M:%S") + if proc.returncode == 0: + html = ( + f"[{ts}] " + f"SUCCESS: '{model_name}' compiled and active." + ) + QTimer.singleShot(0, self.parent.refresh_all_models) + else: + err = proc.stderr.strip().replace("\n", " ") + html = ( + f"[{ts}] " + f"ERROR on '{model_name}': {err}" + ) + QTimer.singleShot(0, lambda: self.console_output.append(html)) + except Exception as exc: + ts = datetime.now().strftime("%H:%M:%S") + QTimer.singleShot( + 0, lambda: self.console_output.append( + f"[{ts}] Exception: {exc}" + ) + ) + +else: + SkillsConsole = None diff --git a/src/ai_lsc/ui/pages/tools_tab.py b/src/ai_lsc/ui/pages/tools_tab.py new file mode 100644 index 0000000..bf5fae6 --- /dev/null +++ b/src/ai_lsc/ui/pages/tools_tab.py @@ -0,0 +1,57 @@ +"""ToolsTab widget — read-only tree view of the full tool registry. + +Displays every registered tool grouped by its infrastructure layer, +showing tool name, level, layer, and role. +""" + +try: + from PySide6.QtCore import Qt + from PySide6.QtWidgets import ( + QLabel, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + +if _HAS_QT: + + class ToolsTab(QWidget): + """Read-only tree view of the full tool registry, grouped by layer.""" + + def __init__(self, main_window): + super().__init__() + self.main = main_window + layout = QVBoxLayout(self) + layout.addWidget(QLabel( + "Registry Tool Surface (13-Layer Architecture)" + )) + + self.tree = QTreeWidget() + self.tree.setColumnCount(4) + self.tree.setHeaderLabels(["Tool", "Level", "Layer", "Role"]) + layout.addWidget(self.tree) + + def refresh(self): + self.tree.clear() + grouped = self.main.registry_mgr.get_grouped_by_layer() + for layer, tools in grouped.items(): + layer_item = QTreeWidgetItem([layer]) + self.tree.addTopLevelItem(layer_item) + for t_id, meta in tools: + lvl = f"L{meta.get('level', 0)}" + item = QTreeWidgetItem([ + meta.get("name", t_id), + lvl, + meta.get("layer", ""), + meta.get("role", ""), + ]) + item.setData(0, Qt.UserRole, t_id) + layer_item.addChild(item) + self.tree.expandAll() + +else: + ToolsTab = None diff --git a/src/ai_lsc/ui/pages/verification_tab.py b/src/ai_lsc/ui/pages/verification_tab.py new file mode 100644 index 0000000..c2e8bb3 --- /dev/null +++ b/src/ai_lsc/ui/pages/verification_tab.py @@ -0,0 +1,429 @@ +"""Verification UI tab — per-tool installation compliance dashboard. + +Provides a table showing all registered tools with their verification +scores, individual check results, and bulk verification controls. +Integrates with ``InstallerManager.verify()`` to run the compliance +checklist and display results in real time. + +Requires PySide6. When PySide6 is not installed, the module still +parses but exports stub classes so that ``agents/__init__.py`` style +try/except imports work. +""" + +from __future__ import annotations + +import os +import threading +from typing import Any + +from ai_lsc.constants import BASE_DIR + +try: + from PySide6.QtCore import QThread, Signal, Qt, QTimer + from PySide6.QtGui import QColor, QFont, QPalette + from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QProgressBar, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, + ) + _HAS_QT = True +except ImportError: + _HAS_QT = False + QWidget = object # type: ignore[assignment, misc] + QThread = object # type: ignore[assignment, misc] + Signal = lambda *a, **kw: None # type: ignore[assignment, misc] + + +# ── Verification worker (runs in background thread) ───────────────── + +class VerificationWorker(QThread if _HAS_QT else object): # type: ignore[misc] + """Run verification checks for a batch of tools in a background thread.""" + + progress = Signal(int, int) # (completed, total) + tool_done = Signal(str, dict) # (tool_id, result_dict) + finished = Signal(int) # total tools processed + + def __init__( + self, + tools: dict[str, dict[str, Any]], + tools_root: str, + base_dir: str = BASE_DIR, + parent: QWidget | None = None, + ) -> None: + if not _HAS_QT: + return + super().__init__(parent) + self.tools = tools + self.tools_root = tools_root + self.base_dir = base_dir + + def run(self) -> None: + if not _HAS_QT: + return + from ai_lsc.runtime.installer import InstallerManager + + mgr = InstallerManager(self.tools_root, self.base_dir) + total = len(self.tools) + completed = 0 + + for tool_id, meta in self.tools.items(): + installer = meta.get("installer", {}) + fs = meta.get("filesystem", {}) + result = mgr.verify( + tool_id=tool_id, + inst_type=installer.get("type", "pacman"), + pkg=installer.get("pkg", ""), + cmd=installer.get("cmd", ""), + filesystem=fs, + ) + self.tool_done.emit(tool_id, result) + completed += 1 + self.progress.emit(completed, total) + + self.finished.emit(total) + + +# ── Verification Tab ──────────────────────────────────────────────── + +class VerificationTab(QWidget if _HAS_QT else object): # type: ignore[misc] + """Dashboard showing per-tool installation verification results. + + Columns: + - Tool ID + - Install Type + - Score (0-100%) + - Native Install + - Filesystem Compliance + - Config Redirect + - Cache Redirect + - Logs Redirect + - Launcher Accessible + - Version Detection + - Health Check + - Location + """ + + def __init__( + self, + registry: dict[str, dict[str, Any]], + tools_root: str, + base_dir: str = BASE_DIR, + parent: QWidget | None = None, + ) -> None: + if not _HAS_QT: + return + super().__init__(parent) + self.registry = registry + self.tools_root = tools_root + self.base_dir = base_dir + self._worker: VerificationWorker | None = None + self._results: dict[str, dict] = {} + self._init_ui() + self._populate_table() + + def _init_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(8) + + # ── Header ─────────────────────────────────────────────────── + hdr = QHBoxLayout() + title = QLabel( + "Installation Verification — Ankh of Jah" + ) + title.setFont(QFont("Segoe UI", 14)) + hdr.addWidget(title) + hdr.addStretch() + layout.addLayout(hdr) + + # ── Controls ─────────────────────────────────────────────── + ctrl_frame = QFrame() + ctrl_frame.setStyleSheet( + "QFrame { border: 1px solid #333; border-radius: 6px; " + "padding: 8px; background-color: #1a1a1a; }" + ) + ctrl_layout = QHBoxLayout(ctrl_frame) + + self.btn_verify_all = QPushButton("Verify All Tools") + self.btn_verify_all.setStyleSheet( + "QPushButton { background-color: #2ecc71; color: #000; " + "font-weight: bold; padding: 8px 16px; border-radius: 4px; }" + "QPushButton:hover { background-color: #27ae60; }" + ) + self.btn_verify_all.clicked.connect(self._run_batch_verify) + ctrl_layout.addWidget(self.btn_verify_all) + + self.btn_verify_selected = QPushButton("Verify Selected") + self.btn_verify_selected.setStyleSheet( + "QPushButton { background-color: #3498db; color: #fff; " + "font-weight: bold; padding: 8px 16px; border-radius: 4px; }" + "QPushButton:hover { background-color: #2980b9; }" + ) + self.btn_verify_selected.clicked.connect(self._run_selected_verify) + ctrl_layout.addWidget(self.btn_verify_selected) + + self.txt_filter = QLineEdit() + self.txt_filter.setPlaceholderText("Filter by tool ID...") + self.txt_filter.setMaximumWidth(250) + self.txt_filter.setStyleSheet( + "QLineEdit { background-color: #1e1e1e; border: 1px solid #444; " + "color: white; padding: 6px; border-radius: 4px; }" + ) + self.txt_filter.textChanged.connect(self._apply_filter) + ctrl_layout.addWidget(self.txt_filter) + + ctrl_layout.addStretch() + + # Summary label + self.lbl_summary = QLabel("Ready — no verifications run yet") + self.lbl_summary.setStyleSheet("color: #7f8c8d; font-size: 11px;") + ctrl_layout.addWidget(self.lbl_summary) + + layout.addWidget(ctrl_frame) + + # ── Progress bar ──────────────────────────────────────────── + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + self.progress_bar.setStyleSheet( + "QProgressBar { border: 1px solid #333; border-radius: 4px; " + "background-color: #1e1e1e; text-align: center; color: white; " + "min-height: 20px; }" + "QProgressBar::chunk { background-color: #2ecc71; border-radius: 3px; }" + ) + layout.addWidget(self.progress_bar) + + # ── Results table ────────────────────────────────────────── + self.table = QTableWidget() + self.table.setColumnCount(12) + self.table.setHorizontalHeaderLabels([ + "Tool ID", "Type", "Score", + "Native", "FS OK", "Config", "Cache", "Logs", + "Binary", "Version", "Health", "Location", + ]) + self.table.setAlternatingRowColors(True) + self.table.setSortingEnabled(True) + self.table.setSelectionBehavior( + QTableWidget.SelectionBehavior.SelectRows + ) + self.table.setEditTriggers( + QTableWidget.EditTrigger.NoEditTriggers + ) + + header = self.table.horizontalHeader() + # All columns interactive so user can resize to read content + for col in range(self.table.columnCount()): + header.setSectionResizeMode( + col, QHeaderView.ResizeMode.Interactive + ) + # Set sensible default widths + col_widths = [200, 80, 60, 60, 60, 60, 60, 60, 60, 60, 60, 250] + for col, width in enumerate(col_widths): + self.table.setColumnWidth(col, width) + + self.table.setStyleSheet( + "QTableWidget { background-color: #1e1e1e; " + "gridline-color: #333; border: 1px solid #333; }" + "QTableWidget::item { padding: 4px; }" + "QHeaderView::section { background-color: #2c3e50; " + "color: white; padding: 4px; border: 1px solid #1a252f; " + "font-weight: bold; }" + ) + + layout.addWidget(self.table) + + def _populate_table(self) -> None: + """Fill the table with all registered tools (unverified state).""" + self.table.setRowCount(len(self.registry)) + for row, (tool_id, meta) in enumerate( + sorted(self.registry.items()) + ): + installer = meta.get("installer", {}) + name = meta.get("name", tool_id) + + self.table.setItem( + row, 0, QTableWidgetItem(name or tool_id) + ) + self.table.setItem( + row, 1, QTableWidgetItem( + installer.get("type", "unknown") + ) + ) + self.table.setItem(row, 2, QTableWidgetItem("\u2014")) + for col in range(3, 12): + self.table.setItem(row, col, QTableWidgetItem("\u2014")) + + def _run_batch_verify(self) -> None: + """Verify all tools in a background thread.""" + if self._worker and self._worker.isRunning(): + return + + self.progress_bar.setVisible(True) + self.progress_bar.setValue(0) + self.btn_verify_all.setEnabled(False) + self.btn_verify_selected.setEnabled(False) + self.lbl_summary.setText("Verifying all tools...") + + self._worker = VerificationWorker( + self.registry, self.tools_root, self.base_dir, self, + ) + self._worker.tool_done.connect(self._on_tool_result) + self._worker.progress.connect(self._on_progress) + self._worker.finished.connect(self._on_batch_done) + self._worker.start() + + def _run_selected_verify(self) -> None: + """Verify only the currently selected rows.""" + rows = set( + i.row() for i in self.table.selectedItems() + ) + if not rows: + return + + # Build a subset of the registry for selected tools + selected_tools: dict[str, dict[str, Any]] = {} + for row in rows: + item = self.table.item(row, 0) + if item: + tool_name = item.text() + # Map display name back to tool_id + for tid, meta in self.registry.items(): + if meta.get("name", tid) == tool_name or tid == tool_name: + selected_tools[tid] = self.registry[tid] + break + + if not selected_tools: + return + + self.progress_bar.setVisible(True) + self.progress_bar.setMaximum(len(selected_tools)) + self.progress_bar.setValue(0) + self.lbl_summary.setText( + f"Verifying {len(selected_tools)} selected tools..." + ) + + self._worker = VerificationWorker( + selected_tools, self.tools_root, self.base_dir, self, + ) + self._worker.tool_done.connect(self._on_tool_result) + self._worker.progress.connect(self._on_progress) + self._worker.finished.connect(self._on_batch_done) + self._worker.start() + + def _on_progress(self, completed: int, total: int) -> None: + """Update the progress bar.""" + self.progress_bar.setMaximum(total) + self.progress_bar.setValue(completed) + + def _on_tool_result(self, tool_id: str, result: dict) -> None: + """Update a single row with verification results.""" + self._results[tool_id] = result + + # Find the row for this tool + row = -1 + for r in range(self.table.rowCount()): + item = self.table.item(r, 0) + if item is None: + continue + item_text = item.text() + expected = self.registry.get(tool_id, {}).get("name", tool_id) + if item_text == expected or item_text == tool_id: + row = r + break + + if row < 0: + return + + checks = result.get("checks", []) + score = result.get("score", 0) + location = result.get("install_location", "") + + # Build a check-name -> passed map + check_map = {c["name"]: c["passed"] for c in checks} + + # Score cell (colour-coded) + score_item = QTableWidgetItem(f"{score}%") + if score >= 80: + score_item.setForeground(QColor("#2ecc71")) + elif score >= 50: + score_item.setForeground(QColor("#f39c12")) + else: + score_item.setForeground(QColor("#e74c3c")) + score_item.setFont(QFont("Segoe UI", 10, QFont.Bold)) + self.table.setItem(row, 2, score_item) + + # Individual checks: columns 3-10 + check_columns = [ + "Native Install", "Filesystem Compliance", + "Config Redirect", "Cache Redirect", "Logs Redirect", + "Launcher Accessible", "Version Detection", "Health Check", + ] + for idx, check_name in enumerate(check_columns): + col = 3 + idx + passed = check_map.get(check_name) + if passed is None: + text = "N/A" + color = "#7f8c8d" + elif passed: + text = "PASS" + color = "#2ecc71" + else: + text = "FAIL" + color = "#e74c3c" + item = QTableWidgetItem(text) + item.setForeground(QColor(color)) + self.table.setItem(row, col, item) + + # Location + self.table.setItem(row, 11, QTableWidgetItem(location)) + + def _on_batch_done(self, total: int) -> None: + """Finalize after batch verification completes.""" + self.progress_bar.setVisible(False) + self.btn_verify_all.setEnabled(True) + self.btn_verify_selected.setEnabled(True) + + # Compute summary + scores = [r["score"] for r in self._results.values()] + if scores: + avg = sum(scores) / len(scores) + passing = sum(1 for s in scores if s >= 80) + failing = sum(1 for s in scores if s < 50) + self.lbl_summary.setText( + f"Verified {total} tools | " + f"Average: {avg:.0f}% | " + f"Passing (>=80%): {passing} | " + f"Failing (<50%): {failing}" + ) + else: + self.lbl_summary.setText( + f"No verification results for {total} tools" + ) + + def _apply_filter(self, text: str) -> None: + """Filter visible rows by tool ID substring.""" + filter_lower = text.lower().strip() + for row in range(self.table.rowCount()): + item = self.table.item(row, 0) + if item is None: + continue + visible = ( + not filter_lower + or filter_lower in item.text().lower() + ) + self.table.setRowHidden(row, not visible) + + def refresh(self) -> None: + """Re-populate table from registry (e.g. after tool install).""" + self._results.clear() + self._populate_table() + self.lbl_summary.setText("Ready \u2014 no verifications run yet") diff --git a/src/ai_lsc/ui/protocol.py b/src/ai_lsc/ui/protocol.py new file mode 100644 index 0000000..3e8c03f --- /dev/null +++ b/src/ai_lsc/ui/protocol.py @@ -0,0 +1,67 @@ +""" +AI-LSC — Main window protocol. + +Defines the interface that all UI page widgets expect from their parent +(main window) via a ``typing.Protocol``. This decouples the pages from +the concrete ``AILocalStackControl`` god class so they can be tested and +refactored independently. + +Every ``self.parent`` / ``self.main`` attribute access in the page widgets +is documented here. During Phase 2 extraction the widgets continue to +receive the concrete main window; in Phase 3 the main window formally +implements this protocol. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class MainWindowProtocol(Protocol): + """Minimal interface that every UI page widget expects from its parent. + + Attributes + ---------- + base_dir : str + tools_root : str + models_root : str + workspaces_root : str + logs_root : str + exports_root : str + config_root : str + skills_root : str + datasets_root : str + base_bin_dir : str + dtach_bin : str | None + registry_mgr : RegistryManager-like + nav_stack : QStackedWidget-like + skills_console_tab : SkillsConsole-like + """ + + # ── Paths (read-only) ────────────────────────────────────────────── + base_dir: str + tools_root: str + models_root: str + workspaces_root: str + logs_root: str + exports_root: str + config_root: str + skills_root: str + datasets_root: str + base_bin_dir: str + dtach_bin: str | None + + # ── Services ───────────────────────────────────────────────────── + registry_mgr: object # ai_lsc.registry.manager.RegistryManager + nav_stack: object # QStackedWidget + skills_console_tab: object # SkillsConsole widget + runtime: object # ai_lsc.runtime.executor.RuntimeExecutor + + # ── Methods ─────────────────────────────────────────────────────── + def log(self, message: str, source: str = "") -> None: ... + def refresh_all_models(self) -> None: ... + def verify_and_watch(self, log_file: str) -> None: ... + def sync_chat_workspace_dropdown(self) -> None: ... + def _populate_services(self) -> None: ... + def finalize_stack_export(self, backend: str) -> None: ... diff --git a/src/ai_lsc/utils/__init__.py b/src/ai_lsc/utils/__init__.py new file mode 100644 index 0000000..c2e1233 --- /dev/null +++ b/src/ai_lsc/utils/__init__.py @@ -0,0 +1 @@ +"""AI-LSC utilities sub-package.""" diff --git a/src/ai_lsc/utils/filesystem.py b/src/ai_lsc/utils/filesystem.py new file mode 100644 index 0000000..efc4a46 --- /dev/null +++ b/src/ai_lsc/utils/filesystem.py @@ -0,0 +1,59 @@ +""" +AI-LSC — Filesystem helpers. + +Directory creation, file watching, and tree-traversal utilities used +by both UI and orchestration code. All path operations use +:mod:`pathlib`. +""" + +from __future__ import annotations + +from pathlib import Path + +from ai_lsc.constants import BASE_DIR, REQUIRED_DIRS, TREE_SKIP_PATTERNS + + +def ensure_base_dirs(base_dir: str | Path | None = None) -> list[Path]: + """Create all required sub-directories under *base_dir*. + + Returns the list of created (or already-existing) directories. + """ + root = Path(base_dir) if base_dir is not None else Path(BASE_DIR) + created: list[Path] = [] + for rel in REQUIRED_DIRS: + p = root / rel + p.mkdir(parents=True, exist_ok=True) + created.append(p) + return created + + +def walk_tree( + root: str | Path, + skip_patterns: set[str] | None = None, + max_depth: int = 4, +) -> list[Path]: + """Recursively collect files, honouring *skip_patterns* and *max_depth*. + + Only files (not directories) are returned. The default skip set + matches ``TREE_SKIP_PATTERNS``. + """ + root = Path(root) + skip = skip_patterns or TREE_SKIP_PATTERNS + results: list[Path] = [] + + def _walk(directory: Path, depth: int) -> None: + if depth > max_depth: + return + try: + for entry in sorted(directory.iterdir()): + if entry.name in skip: + continue + if entry.is_dir(): + _walk(entry, depth + 1) + else: + results.append(entry) + except PermissionError: + pass + + _walk(root, 0) + return results diff --git a/src/ai_lsc/utils/logging.py b/src/ai_lsc/utils/logging.py new file mode 100644 index 0000000..da306fc --- /dev/null +++ b/src/ai_lsc/utils/logging.py @@ -0,0 +1,73 @@ +""" +AI-LSC — Centralised logging. + +The monolith did not use ``logging`` at all; messages were appended to +a QTextEdit via Qt signals. This module sets up a root logger that +*also* writes to a rotating file so we get persistent logs even when +the GUI is not running. + +Usage:: + + from ai_lsc.utils.logging import get_logger + + log = get_logger("service_row") + log.info("Ollama started on port 11434") +""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path + + +def setup_logging( + log_dir: str | Path | None = None, + level: int = logging.INFO, +) -> logging.Logger: + """Configure the root ``ai_lsc`` logger with console + file output. + + Parameters + ---------- + log_dir: + Directory for the log file. If *None*, logs go to console only. + level: + Logging verbosity. + """ + root = logging.getLogger("ai_lsc") + root.setLevel(level) + + # Avoid duplicate handlers on repeated calls + if root.handlers: + return root + + fmt = logging.Formatter( + "%(asctime)s %(levelname)-8s %(name)-20s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # Console handler + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(level) + ch.setFormatter(fmt) + root.addHandler(ch) + + # File handler (optional) + if log_dir is not None: + log_dir = Path(log_dir) + log_dir.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(log_dir / "ai_lsc.log", encoding="utf-8") + fh.setLevel(level) + fh.setFormatter(fmt) + root.addHandler(fh) + + return root + + +def get_logger(name: str) -> logging.Logger: + """Return a child logger under the ``ai_lsc`` namespace. + + ``name`` should be the module or component name, e.g. ``"registry"`` + or ``"service_row"``. + """ + return logging.getLogger(f"ai_lsc.{name}") diff --git a/src/ai_lsc/utils/ollama.py b/src/ai_lsc/utils/ollama.py new file mode 100644 index 0000000..31bcd80 --- /dev/null +++ b/src/ai_lsc/utils/ollama.py @@ -0,0 +1,87 @@ +"""Ollama server path resolution and environment helpers. + +Probes multiple candidate paths under ``BASE_DIR`` to locate +the ollama server binary, data directory, and configuration. All other +modules should import from here instead of hardcoding paths. + +Detection order (first match wins): + 1. ``/ollama`` + 2. ``/tools/ollama`` + 3. ``/runtime/ollama`` + 4. ``/bin/ollama`` + 5. System PATH fallback via ``shutil.which("ollama")`` +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + + +def detect_ollama_server_dir(base_dir: str) -> str: + """Return the first candidate ollama server directory that exists. + + Probes paths defined in ``OLLAMA_SERVER_CANDIDATES`` under *base_dir*. + Falls back to the parent of the system ``ollama`` binary if none of + the managed paths exist. + """ + from ai_lsc.constants import OLLAMA_SERVER_CANDIDATES + + for rel in OLLAMA_SERVER_CANDIDATES: + full = os.path.join(base_dir, rel) + if os.path.isdir(full): + return full + + # System PATH fallback + system_bin = shutil.which("ollama") + if system_bin: + return str(Path(system_bin).resolve().parent.parent) + + # Default: first candidate (will be created on install) + return os.path.join(base_dir, OLLAMA_SERVER_CANDIDATES[0]) + + +def ollama_models_dir(base_dir: str) -> str: + """Return the models directory for ollama. + + Checks both the dedicated ``models/ollama`` tree and the ollama + server's own ``~/.ollama/models`` directory. + """ + primary = os.path.join(base_dir, "models", "ollama") + if os.path.isdir(primary): + return primary + return os.path.join(detect_ollama_server_dir(base_dir), "models") + + +def ollama_env(base_dir: str) -> dict[str, str]: + """Build the environment dict for ollama commands. + + Sets ``OLLAMA_MODELS`` and ``OLLAMA_HOST`` to use managed paths + under *base_dir*. + """ + return { + "OLLAMA_MODELS": ollama_models_dir(base_dir), + "OLLAMA_HOST": "127.0.0.1:11434", + } + + +def ollama_binary(base_dir: str) -> str | None: + """Find the ollama binary path. + + Checks the managed ``bin`` directories first, then falls back to + the system PATH. + """ + from ai_lsc.constants import OLLAMA_SERVER_CANDIDATES + + for rel in OLLAMA_SERVER_CANDIDATES: + candidate = os.path.join(base_dir, rel, "bin", "ollama") + if os.path.isfile(candidate): + return candidate + + return shutil.which("ollama") + + +def ollama_is_installed(base_dir: str) -> bool: + """Return True if ollama appears to be installed (binary found).""" + return ollama_binary(base_dir) is not None diff --git a/src/ai_lsc/utils/paths.py b/src/ai_lsc/utils/paths.py new file mode 100644 index 0000000..2a90787 --- /dev/null +++ b/src/ai_lsc/utils/paths.py @@ -0,0 +1,82 @@ +""" +AI-LSC — Centralised path definitions. + +Every path in the application is derived from ``BASE_DIR`` +using :mod:`pathlib`. Import these in UI and orchestration code +instead of constructing paths with ``os.path.join`` ad-hoc. +""" + +from __future__ import annotations + +from pathlib import Path + +from ai_lsc.constants import BASE_DIR, REQUIRED_DIRS + + +def build_path_tree(base_dir: str | Path | None = None) -> dict[str, Path]: + """Return a dict of well-known absolute paths under *base_dir*. + + The dict keys match the attribute names that ``AILocalStackControl`` + currently assigns in ``__init__``, so migrating consumers is a + mechanical find-and-replace. + + Parameters + ---------- + base_dir: + Override the default ``BASE_DIR``. Useful for tests. + + Returns + ------- + dict[str, Path] + Example:: + + { + "base_dir": Path("/mnt/AI"), + "tools_root": Path("/mnt/AI/tools"), + "models_root": Path("/mnt/AI/models"), + "logs_root": Path("/mnt/AI/logs"), + "skills_root": Path("/mnt/AI/skills"), + "datasets_root": Path("/mnt/AI/datasets"), + "config_root": Path("/mnt/AI/config"), + "workspaces_root": Path("/mnt/AI/workspaces"), + "exports_root": Path("/mnt/AI/exports"), + "registry_root": Path("/mnt/AI/registry"), + } + """ + root = Path(base_dir) if base_dir is not None else Path(BASE_DIR) + return { + "base_dir": root, + "tools_root": root / "tools", + "models_root": root / "models", + "logs_root": root / "logs", + "skills_root": root / "skills", + "datasets_root": root / "datasets", + "config_root": root / "config", + "workspaces_root": root / "workspaces", + "exports_root": root / "exports", + "registry_root": root / "registry", + } + + +def resolve_launcher_cmd( + cmd_template: str, + base_dir: str | Path | None = None, + port: int | None = None, + model_arg: str = "", +) -> str: + """Resolve ``{placeholder}`` tokens in a launcher command template. + + Recognised placeholders (case-sensitive): + ``{port}``, ``{base_dir}``, ``{tools_root}``, + ``{models_root}``, ``{workspaces_root}``, ``{model_arg}`` + """ + paths = build_path_tree(base_dir) + return ( + cmd_template + .replace("{port}", str(port or "")) + .replace("{base_dir}", str(paths["base_dir"])) + .replace("{tools_root}", str(paths["tools_root"])) + .replace("{models_root}", str(paths["models_root"])) + .replace("{workspaces_root}", str(paths["workspaces_root"])) + .replace("{model_arg}", model_arg) + ) diff --git a/src/ai_lsc/utils/process.py b/src/ai_lsc/utils/process.py new file mode 100644 index 0000000..df89b47 --- /dev/null +++ b/src/ai_lsc/utils/process.py @@ -0,0 +1,128 @@ +""" +AI-LSC — Process utilities. + +Wrappers around ``subprocess``, ``shutil.which``, ``psutil``, and +environment construction. Every subprocess call in the application +should go through one of these helpers so we get consistent PATH +enrichment and timeout handling in one place. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import Sequence + + +def enriched_env(extra_bin_dirs: str | Sequence[str] = "") -> dict[str, str]: + """Return a copy of ``os.environ`` with extra dirs prepended to PATH. + + Parameters + ---------- + extra_bin_dirs: + One or more directory paths to prepend, separated by ``:`` + (if a single string) or as an iterable. + """ + env = os.environ.copy() + + # Remap ~/.local to /mnt/AI/tools/.local so that pip/pipx/uv + # user installs land in the managed tools directory instead of + # leaking into the user's home directory. + from ai_lsc.constants import BASE_DIR + managed_local = os.path.join(BASE_DIR, "tools", ".local", "bin") + home_local = str(Path.home() / ".local" / "bin") + + if isinstance(extra_bin_dirs, str): + dirs = [d.strip() for d in extra_bin_dirs.split(":") if d.strip()] + else: + dirs = list(extra_bin_dirs) + + extra = ":".join(d for d in dirs if d) + # Prefer managed ~/.local over real ~/.local + bin_dirs = [] + if extra: + bin_dirs.append(extra) + if os.path.isdir(managed_local): + bin_dirs.append(managed_local) + if home_local not in bin_dirs: + bin_dirs.append(home_local) + + env["PATH"] = ":".join(d for d in bin_dirs if d) + ":" + env.get("PATH", "") + return env + + +def find_binary(*candidates: str) -> str | None: + """Return the first candidate found on ``$PATH``, or ``None``.""" + return next( + (c for c in candidates if shutil.which(c)), + None, + ) + + +def run_subprocess( + cmd: str | list[str], + timeout: float = 120.0, + capture: bool = True, + env: dict[str, str] | None = None, + cwd: str | Path | None = None, +) -> subprocess.CompletedProcess: + """Centralised subprocess runner. + + Parameters + ---------- + cmd: + Command string or arg list. + timeout: + Max seconds before killing the process. + capture: + If *True* (default), capture stdout/stderr. + env: + Override environment (else inherits current). + cwd: + Working directory. + """ + return subprocess.run( + cmd if isinstance(cmd, list) else cmd, + timeout=timeout, + capture_output=capture, + text=True, + env=env or None, + cwd=str(cwd) if cwd else None, + ) + + +# ── psutil helpers ──────────────────────────────────────────────────── + +def _process_matches(proc, search_term: str) -> bool: + """Check if a process matches *search_term* by name or cmdline.""" + try: + cmdline = " ".join(proc.info.get("cmdline") or []) + return ( + search_term in (proc.info.get("name") or "") + or search_term in cmdline + ) + except Exception: + # psutil.NoSuchProcess / AccessDenied / Zombie + return False + + +def first_matching_process(search_term: str): + """Return the first ``psutil.Process`` whose name/cmdline matches.""" + import psutil + return next( + (p for p in psutil.process_iter(["name", "cmdline"]) + if _process_matches(p, search_term)), + None, + ) + + +def cpu_load_for_processes(search_term: str) -> float: + """Return the aggregate CPU % for all matching processes.""" + import psutil + return sum( + p.cpu_percent(interval=0.1) + for p in psutil.process_iter(["name", "cmdline"]) + if _process_matches(p, search_term) + )