diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69eeeb8..0f387de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,14 +17,14 @@ Contributions should keep that bias visible in the code and in the product shape - make the cockpit more reliable - improve mission routing or verification - improve durable memory and repo context -- sharpen the TUI and the `hexapus` buddy rail +- sharpen the native Rust TUI and operator ergonomics - simplify setup without hiding the system - improve docs with concrete operator value ## Ground Rules - keep the CLI useful in non-interactive environments -- prefer robust shell and Python over magic abstractions +- prefer robust shell and Rust over magic abstractions - preserve local observability - do not hardcode personal machine names, users, IPs, or paths - treat clipboard, SSH, and session behavior as product features, not side details @@ -44,9 +44,16 @@ Run at least the checks relevant to your change. Examples: ```bash -python3 -m py_compile constant/*.py +cargo test --no-run +bash -n scripts/*.sh +./scripts/Constant --help +``` + +For doc-only changes, also sanity-check that the public surface still matches the code: + +```bash +rg -n "fleet discover|fleet configure|fleet deploy|CONSTANT_USE_PYTHON|python3" README.md CONTRIBUTING.md scripts src tests bash -n scripts/*.sh -./scripts/Constant doctor ``` If your change affects routing, fleet behavior, or memory shape, include a short explanation of: diff --git a/Cargo.toml b/Cargo.toml index 1a19bd2..4cb32a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,4 @@ ratatui = "0.29" [[bin]] name = "constant" path = "src/main.rs" +test = false diff --git a/README.md b/README.md index 3cad1db..fcf9575 100644 --- a/README.md +++ b/README.md @@ -40,35 +40,33 @@ Current build status in this repo: - a 4-pane host-local `tmux` session per machine - a fleet cockpit with one window per machine plus a central `Constant` window - `Constant`, the orchestration CLI on top of the cockpit -- a terminal TUI with a central `hexapus` buddy rail +- a native Rust chat-first TUI with composer, threads, and cockpit panes - live cockpit controls for focus, capture, send, and pane restart - local mission planning and verification - host-local execution for `claude`, `codex`, and `vibe` - `copilot` as a manual lane - local message bus + cross-machine bridge -- MLX-ready model plumbing for a small local orchestrator stack on macOS +- heuristic local routing and buddy/planner responses without a Python runtime - workspace-first durable memory with lexical + local vector search The canonical script surface is `constant-*`. Legacy `zellij-ai-*` aliases remain only as compatibility shims. -Migration note: +Runtime notes: -- the entrypoint is now moving to Rust -- `doctor`, `agents`, `skills`, `tui`, `cockpit`, `mission create`, `mission plan`, `mission run`, `mission status`, `mission tail`, `mission verify`, `mission retry`, `mission summarize`, and `delegate` now run from the Rust binary -- Rust now prefers `fleet.toml`, `models.toml`, and `memory.toml`, while keeping JSON mirrors for compatibility during migration -- `memory`, `buddy`, and `fleet` still hand off to the existing Python core during migration -- the public `./scripts/Constant` wrapper now tries the Rust binary first, but runs a fast startup probe before handing control over -- if macOS stalls in code-signing / policy evaluation for freshly built binaries, the wrapper falls back automatically to the stable Python path -- when `constant` is launched from inside the Codex runner itself, the wrapper skips the Rust path entirely and goes straight to Python to avoid the known macOS startup stall in that environment -- use `CONSTANT_USE_RUST=1 ./scripts/Constant ...` to force the raw Rust path, or `CONSTANT_USE_PYTHON=1 ./scripts/Constant ...` to force Python -- after changing macOS security settings, use `CONSTANT_RUST_RECHECK=1 ./scripts/Constant ...` to force a fresh Rust startup probe instead of waiting for the fallback cache TTL +- the public runtime is now Rust-only +- `doctor`, `agents`, `skills`, `models`, `memory`, `buddy`, `fleet`, `tui`, `cockpit`, and mission commands all execute from the Rust binary +- the public `./scripts/Constant` wrapper rebuilds the Rust binary when sources change, then launches it directly +- the wrapper ad-hoc signs the rebuilt binary on macOS when `codesign` is available +- chat thread indexes now live in persisted sidecars under `~/.local/share/constant/indexes/chat` +- unread state persists separately per workspace/user view +- `constant mission delete ` is now enforced in Rust and refuses `draft`, `planned`, or `running` missions +- `constant cockpit status-line` now powers the tmux chat dock and surfaces disabled autorestart warnings macOS note: -- if Rust keeps falling back from a normal terminal app, check `System Settings -> Privacy & Security -> Developer Tools` -- allow your terminal app there so it can run locally built binaries without extra security-policy stalls -- then rerun with `CONSTANT_RUST_RECHECK=1 ./scripts/Constant --help` +- if Gatekeeper blocks a freshly built binary from your terminal, allow that terminal under `System Settings -> Privacy & Security -> Developer Tools` +- then rerun `./scripts/Constant --help` ## Why This Exists @@ -177,7 +175,7 @@ Create and route missions through these skills today with the Rust CLI: ./scripts/Constant delegate --skill architecture-brainstorm --json ``` -The richer conversation-first chat surface is still part of the Python-side migration path. +The conversation-first chat surface is now native to the Rust TUI. ### 4. Durable memory @@ -248,16 +246,18 @@ codex login --device-auth ./scripts/Constant doctor ./scripts/Constant cockpit doctor --json ./scripts/Constant cockpit status --json +./scripts/Constant cockpit status-line --workspace "$PWD" ./scripts/Constant cockpit focus --machine command-center --pane codex ./scripts/Constant cockpit capture --machine command-center --pane claude ./scripts/Constant mission create "audit the repo" --workspace "$PWD" +./scripts/Constant mission delete --json ./scripts/Constant mission status ./scripts/Constant memory rebuild --workspace "$PWD" ./scripts/Constant memory search "buddy rail" --workspace "$PWD" +./scripts/Constant memory sync-qdrant --workspace "$PWD" --json ./scripts/Constant cockpit open --workspace "$PWD" -./scripts/Constant fleet discover --json -./scripts/Constant fleet configure -./scripts/Constant fleet deploy +./scripts/Constant fleet status --json +./scripts/Constant fleet sync --json ``` If `Constant` is on your `PATH`, you can also just run: @@ -275,7 +275,7 @@ That means: - the `Constant` TUI inside the central `Constant` window - one machine window per host in the fleet -For now, the central `Constant` window is intentionally forced onto the richer Python chat-first TUI so the UX stays closer to a Claude Code-style conversation surface while the Rust TUI is still catching up. +The central `Constant` window now runs the native Rust chat-first TUI directly. The current interaction model is: @@ -310,10 +310,6 @@ Examples: ./scripts/constant-deploy.sh scan --json ./scripts/constant-deploy.sh configure ./scripts/constant-deploy.sh deploy - -Constant fleet discover --json -Constant fleet configure --host dev@builder-a --host dev@edge-a -Constant fleet deploy --repo-dir '$HOME/constant' ``` You can pass raw SSH seeds such as: @@ -327,7 +323,7 @@ If `fleet.toml` or `fleet.json` contains `repo_dir`, the shell launchers and fle For a fully non-interactive run, pass explicit hosts and `--yes`: ```bash -Constant fleet configure \ +./scripts/constant-deploy.sh configure \ --host builder-a \ --host builder-b \ --user dev \ @@ -345,7 +341,7 @@ Constant fleet configure \ - a chat-focus mode that behaves closer to Claude Code - a detailed cockpit view you can toggle back in when needed - a capture popup for pane output -- a `hexapus` buddy rail +- persistent unread tracking across sessions - a bottom status / key strip Useful keys: @@ -378,7 +374,7 @@ The command-center machine is where you run: - `Constant` - the fleet cockpit -- local MLX orchestration +- local heuristic orchestration - any human-in-the-loop supervision The workers are where execution happens. @@ -391,7 +387,7 @@ cp examples/fleet.example.toml ~/.config/constant/fleet.toml ``` Legacy `~/.config/constant/fleet.json` and `fleet.yaml` are still read for compatibility, but the native format is now TOML. -The shell launchers read both `fleet.toml` and `fleet.json`, so fleet tabs, install/check, and bridge helpers stay aligned during the migration. +The shell launchers and Rust CLI read both `fleet.toml` and `fleet.json`, so fleet tabs, install/check, and bridge helpers stay aligned. ## Messaging @@ -458,7 +454,6 @@ Not like a magic trick. Planned layers for the public `Constant` repo: - a real demoscene-style TUI -- a central `hexapus` buddy rail - durable memory with vector + lexical search - mission summaries and decision graph browsing - richer repo context and instruction fusion @@ -468,8 +463,8 @@ Planned layers for the public `Constant` repo: ## Repo Layout ```text -constant/ Python orchestration core -src/ Rust CLI, missions, cockpit, and native TUI +src/ Rust CLI, missions, cockpit, native TUI, memory, and chat store +tests/ Rust integration tests examples/fleet.example.json public fleet template examples/fleet.example.toml public fleet template in the native config format scripts/Constant canonical CLI entrypoint diff --git a/constant/__init__.py b/constant/__init__.py deleted file mode 100644 index a05eb9a..0000000 --- a/constant/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__all__ = ["__version__"] - -__version__ = "0.1.0" diff --git a/constant/__main__.py b/constant/__main__.py deleted file mode 100644 index a049ad7..0000000 --- a/constant/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .cli import main - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/constant/capabilities.py b/constant/capabilities.py deleted file mode 100644 index 4c31ecb..0000000 --- a/constant/capabilities.py +++ /dev/null @@ -1,482 +0,0 @@ -from __future__ import annotations - -from typing import Any - - -DEFAULT_AGENTS: list[dict[str, Any]] = [ - { - "id": "planner", - "label": "Planner", - "role": "planner", - "primary_cli": "claude", - "capabilities": ["spec", "planning", "review", "summarization", "delegation"], - "preferred_layers": ["reflection", "execution-prep"], - "manual_only": False, - }, - { - "id": "executor", - "label": "Executor", - "role": "executor", - "primary_cli": "codex", - "capabilities": ["implementation", "debugging", "refactor", "tooling", "ops"], - "preferred_layers": ["execution"], - "manual_only": False, - }, - { - "id": "analyst", - "label": "Analyst", - "role": "analyst", - "primary_cli": "vibe", - "capabilities": ["brainstorm", "alternatives", "research", "comparison"], - "preferred_layers": ["reflection"], - "manual_only": False, - }, - { - "id": "assistant", - "label": "Assistant", - "role": "assistant", - "primary_cli": "copilot", - "capabilities": ["interactive-help", "inline-suggestions"], - "preferred_layers": ["manual"], - "manual_only": True, - }, -] - - -DEFAULT_SKILLS: list[dict[str, Any]] = [ - { - "id": "spec-planner", - "label": "Spec Planner", - "layer": "reflection", - "visibility": "public", - "summary": "Transformer une demande floue en spec exploitable avant de coder.", - "when_to_use": [ - "quand on part d'une idee brute", - "quand il faut cadrer avant implementation", - "quand on veut eviter de coder trop tot", - ], - "input_examples": [ - "Je veux ajouter un systeme de notifications", - "On doit refondre l'auth OAuth", - ], - "output_expected": [ - "objectif", - "perimetre", - "hors_perimetre", - "contraintes", - "hypotheses", - "criteres_acceptation", - "risques", - "questions_ouvertes", - "plan_minimal", - ], - "usage_prompt": ( - "Fais une spec executable a partir de cette demande. " - "Identifie les zones floues, propose des hypotheses raisonnables, " - "liste les criteres d'acceptation et termine par un plan d'implementation minimal." - ), - "keywords": [ - "spec planner", - "spec-planner", - "spec", - "cadrer", - "clarifier", - "clarify", - "requirements", - "scope", - "perimetre", - "oauth", - "notifications", - "idea brute", - "je veux", - "i want", - "on doit", - "we need", - "refondre", - "before coding", - ], - "aliases": ["summarize", "summary", "brief"], - "preferred_cli": "claude", - "preferred_agent": "planner", - }, - { - "id": "architecture-brainstorm", - "label": "Architecture Brainstorm", - "layer": "reflection", - "visibility": "public", - "summary": "Comparer plusieurs options d'architecture avant de choisir.", - "when_to_use": [ - "quand plusieurs approches sont possibles", - "quand on veut comparer les trade-offs", - "quand il faut une vraie discussion d'architecture", - ], - "input_examples": [ - "Doit-on faire un backend event-driven ou un monolithe modulaire ?", - "Quel runtime pour le cockpit interactif ?", - ], - "output_expected": [ - "options", - "avantages", - "inconvenients", - "complexite", - "impacts_perf", - "impacts_securite", - "impacts_dx", - "recommendation", - "raisons_du_choix", - ], - "usage_prompt": ( - "Analyse ce probleme comme un review partner senior. " - "Propose plusieurs architectures possibles, compare les compromis, " - "signale les pieges, puis recommande une option avec justification." - ), - "keywords": [ - "architecture", - "brainstorm", - "architecture-brainstorm", - "trade-off", - "tradeoff", - "option", - "compare", - "alternatives", - "design choice", - "choix technique", - ], - "aliases": ["brainstorm"], - "preferred_cli": "vibe", - "preferred_agent": "analyst", - }, - { - "id": "repo-onboarding", - "label": "Repo Onboarding", - "layer": "execution-prep", - "visibility": "public", - "summary": "Comprendre rapidement un repo avant la premiere intervention.", - "when_to_use": [ - "quand on arrive sur un nouveau projet", - "quand il faut cartographier une codebase", - "quand on veut savoir ou modifier sans casser le reste", - ], - "input_examples": [ - "Explore ce repo comme si tu preparais une premiere intervention", - "Dis-moi ou intervenir pour cette tache", - ], - "output_expected": [ - "structure_repo", - "points_entree", - "modules_cles", - "conventions", - "commandes_utiles", - "zones_a_risque", - "strategie_modification", - ], - "usage_prompt": ( - "Explore ce repo comme si tu preparais une premiere intervention. " - "Resume sa structure, les modules critiques, les conventions implicites, " - "les commandes de dev/test, puis dis ou intervenir pour cette tache." - ), - "keywords": [ - "repo onboarding", - "repo-onboarding", - "onboarding", - "explore repo", - "new repo", - "codebase", - "structure du repo", - "points d'entree", - "where to change", - "cartographier", - ], - "aliases": [], - "preferred_cli": "claude", - "preferred_agent": "planner", - }, - { - "id": "task-decomposer", - "label": "Task Decomposer", - "layer": "execution-prep", - "visibility": "public", - "summary": "Transformer une spec ou direction choisie en plan d'execution operationnel.", - "when_to_use": [ - "quand la direction est choisie", - "quand on veut passer du brainstorming a l'action", - "quand il faut un ordre d'execution clair", - ], - "input_examples": [ - "Decoupe cette refonte en sous-taches concretement executables", - "Fais-moi un plan d'implementation directement actionnable", - ], - "output_expected": [ - "etapes_ordonnees", - "dependances", - "quick_wins", - "parallelisation", - "tests", - "definition_of_done", - ], - "usage_prompt": ( - "Decoupe cette tache en sous-taches concretes, ordonnees, avec dependances, " - "risques, validations et tests associes. " - "Le plan doit etre directement executable par un agent de code." - ), - "keywords": [ - "task decomposer", - "task-decomposer", - "decompose", - "plan d'execution", - "execution plan", - "break down", - "subtasks", - "steps", - "roadmap", - "implementation plan", - ], - "aliases": [], - "preferred_cli": "claude", - "preferred_agent": "planner", - }, - { - "id": "pr-review-prep", - "label": "PR Review Prep", - "layer": "execution-prep", - "visibility": "public", - "summary": "Preparer une PR review-ready et anticiper les objections du reviewer.", - "when_to_use": [ - "quand l'implementation est terminee", - "quand il faut une bonne PR", - "quand on veut preparer la review", - ], - "input_examples": [ - "Prepare une PR clean a partir de ces changements", - "Resume le pourquoi, le quoi et les risques pour le reviewer", - ], - "output_expected": [ - "resume_du_changement", - "impact_fonctionnel", - "impact_technique", - "migrations", - "tests", - "points_attention_reviewer", - "message_de_pr", - ], - "usage_prompt": ( - "Prepare une PR review-ready a partir de ces changements. " - "Resume le pourquoi, le quoi, les impacts, les risques, " - "les tests effectues et les points a surveiller pour le reviewer." - ), - "keywords": [ - "pr review prep", - "pr-review-prep", - "pr", - "pull request", - "review prep", - "release notes", - "review-ready", - "reviewer", - ], - "aliases": ["review"], - "preferred_cli": "claude", - "preferred_agent": "planner", - }, - { - "id": "implementation", - "label": "Implementation", - "layer": "execution", - "visibility": "internal", - "summary": "Ship code changes and concrete fixes.", - "when_to_use": [ - "quand il faut modifier du code maintenant", - "quand la spec est deja suffisamment claire", - ], - "input_examples": ["Fix the flaky test", "Implement the API endpoint"], - "output_expected": ["code_changes", "validation", "tests"], - "usage_prompt": "Implement the requested change directly and validate it.", - "keywords": ["fix", "implement", "build", "write", "patch", "feature", "code"], - "aliases": [], - "preferred_cli": "codex", - "preferred_agent": "executor", - }, - { - "id": "debug-restoration", - "label": "Debug Restoration", - "layer": "execution", - "visibility": "internal", - "summary": "Investigate failures and restore a working state.", - "when_to_use": [ - "quand quelque chose casse", - "quand il faut comprendre puis restaurer un etat sain", - ], - "input_examples": ["The pane dies instantly", "This build started failing today"], - "output_expected": ["root_cause", "fix", "validation"], - "usage_prompt": "Investigate the failure, isolate the cause, and restore a working state.", - "keywords": ["bug", "debug", "failure", "broken", "error", "crash", "repair"], - "aliases": ["debug"], - "preferred_cli": "codex", - "preferred_agent": "executor", - }, - { - "id": "ops-deployment", - "label": "Ops Deployment", - "layer": "execution", - "visibility": "internal", - "summary": "Handle shell, fleet, infra, network, and remote machine work.", - "when_to_use": [ - "quand il faut deployer, scanner, installer, diagnostiquer des machines", - "quand la tache est principalement shell/infra", - ], - "input_examples": ["Deploy the runtime on all machines", "Scan SSH hosts and configure the fleet"], - "output_expected": ["ops_actions", "machine_state", "validation"], - "usage_prompt": "Handle the shell, infra, fleet, and remote machine operations safely.", - "keywords": ["ssh", "shell", "fleet", "ops", "network", "infra", "deploy"], - "aliases": ["ops"], - "preferred_cli": "codex", - "preferred_agent": "executor", - }, -] - - -def _normalize_skill_id(value: str) -> str: - return value.strip().lower() - - -def _skill_entries(include_internal: bool = True) -> list[dict[str, Any]]: - if include_internal: - return DEFAULT_SKILLS - return [skill for skill in DEFAULT_SKILLS if skill.get("visibility") != "internal"] - - -def list_agents() -> list[dict[str, Any]]: - return [dict(item) for item in DEFAULT_AGENTS] - - -def list_skills(include_internal: bool = True) -> list[dict[str, Any]]: - return [dict(item) for item in _skill_entries(include_internal=include_internal)] - - -def skill_catalog(include_internal: bool = False) -> dict[str, list[dict[str, Any]]]: - payload = {"reflection": [], "execution-prep": [], "execution": [], "manual": []} - for skill in _skill_entries(include_internal=include_internal): - payload.setdefault(str(skill["layer"]), []).append(dict(skill)) - return payload - - -def skill_catalog_brief(include_internal: bool = False) -> list[dict[str, Any]]: - return [ - { - "id": skill["id"], - "layer": skill["layer"], - "summary": skill["summary"], - "preferred_cli": skill["preferred_cli"], - "preferred_agent": skill["preferred_agent"], - } - for skill in _skill_entries(include_internal=include_internal) - ] - - -def recommended_skill_stack() -> dict[str, Any]: - return { - "minimal": ["spec-planner", "architecture-brainstorm", "task-decomposer"], - "workflow": [ - "spec-planner", - "architecture-brainstorm", - "repo-onboarding", - "task-decomposer", - "pr-review-prep", - ], - "layers": { - "reflection": ["spec-planner", "architecture-brainstorm"], - "execution": ["repo-onboarding", "task-decomposer", "pr-review-prep"], - }, - } - - -def agent_by_id(agent_id: str) -> dict[str, Any]: - for agent in DEFAULT_AGENTS: - if agent["id"] == agent_id: - return dict(agent) - raise KeyError(f"Unknown agent: {agent_id}") - - -def agent_for_cli(cli: str) -> dict[str, Any]: - for agent in DEFAULT_AGENTS: - if agent["primary_cli"] == cli: - return dict(agent) - return agent_by_id("executor") - - -def agent_hint(agent_id: str) -> dict[str, Any]: - return agent_by_id(agent_id) - - -def skill_by_id(skill_id: str, include_internal: bool = True) -> dict[str, Any]: - normalized = _normalize_skill_id(skill_id) - for skill in _skill_entries(include_internal=include_internal): - aliases = [_normalize_skill_id(alias) for alias in skill.get("aliases", [])] - if normalized == skill["id"] or normalized in aliases: - return dict(skill) - raise KeyError(f"Unknown skill: {skill_id}") - - -def _score_keywords(goal_l: str, skill: dict[str, Any]) -> int: - score = 0 - for keyword in skill.get("keywords", []): - if keyword in goal_l: - score += 3 if " " in keyword or "-" in keyword else 1 - if skill["id"] in goal_l: - score += 5 - for alias in skill.get("aliases", []): - if alias in goal_l: - score += 3 - return score - - -def match_skill(goal: str, include_internal: bool = True) -> dict[str, Any]: - goal_l = goal.lower() - best = skill_by_id("implementation", include_internal=include_internal) if include_internal else skill_by_id("spec-planner", include_internal=False) - best_score = -1 - for skill in _skill_entries(include_internal=include_internal): - score = _score_keywords(goal_l, skill) - if score > best_score: - best_score = score - best = skill - if best_score <= 0: - if any(token in goal_l for token in ("repo", "codebase", "onboarding", "where to change", "first intervention")): - return skill_by_id("repo-onboarding", include_internal=include_internal) - if any(token in goal_l for token in ("je veux", "i want", "on doit", "we need", "idea brute", "high level idea")): - return skill_by_id("spec-planner", include_internal=include_internal) - if any(token in goal_l for token in ("architecture", "trade-off", "tradeoff", "option", "compare")): - return skill_by_id("architecture-brainstorm", include_internal=include_internal) - if any(token in goal_l for token in ("plan", "sub-task", "subtask", "decompose", "step-by-step", "roadmap")): - return skill_by_id("task-decomposer", include_internal=include_internal) - if any(token in goal_l for token in ("pr", "pull request", "reviewer", "release notes")): - return skill_by_id("pr-review-prep", include_internal=include_internal) - if include_internal and any(token in goal_l for token in ("bug", "crash", "failure", "broken", "error")): - return skill_by_id("debug-restoration", include_internal=True) - if include_internal and any(token in goal_l for token in ("ssh", "fleet", "deploy", "network", "machine", "infra")): - return skill_by_id("ops-deployment", include_internal=True) - return best - return dict(best) - - -def resolve_skill_and_agent( - *, - goal: str | None = None, - skill_id: str | None = None, - agent_id: str | None = None, - cli: str | None = None, -) -> dict[str, Any]: - skill = skill_by_id(skill_id) if skill_id else match_skill(goal or "") - if cli: - agent = agent_for_cli(cli) - elif agent_id: - agent = agent_by_id(agent_id) - cli = str(agent["primary_cli"]) - else: - agent = agent_by_id(str(skill["preferred_agent"])) - cli = str(skill["preferred_cli"]) - - return { - "skill": skill, - "agent": agent, - "cli": cli, - } diff --git a/constant/cli.py b/constant/cli.py deleted file mode 100644 index 4f40f59..0000000 --- a/constant/cli.py +++ /dev/null @@ -1,943 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import time -from pathlib import Path -from typing import Any - -from . import __version__ -from .capabilities import ( - agent_for_cli, - list_agents, - list_skills, - recommended_skill_stack, - resolve_skill_and_agent, - skill_catalog, - skill_by_id, -) -from .cockpit import capture_pane, cockpit_doctor, focus_machine, restart_pane, runtime_status, send_to_pane -from .daemon import daemon_status, request as daemon_request, serve_foreground, start_background, stop_background -from .executors import bridge_sync, execute_step, fleet_check -from .memory import ( - enroll_workspace, - instruction_skill_sources, - list_decisions, - memory_status, - persona_markdown, - rebuild_workspace_memory, - search_memory, - summarize_mission, - sync_qdrant, -) -from .paths import cache_root, repo_root, scripts_dir -from .state import ( - append_event, - create_mission, - first_active_step, - fleet_machine, - load_fleet_config, - load_mission, - load_models_config, - list_missions, - mission_events_file, - save_mission, - write_artifact, -) -from .tui import run_tui - - -def _print(data: Any, as_json: bool = False) -> None: - if as_json: - print(json.dumps(data, indent=2, sort_keys=True)) - elif isinstance(data, str): - print(data) - else: - print(json.dumps(data, indent=2, sort_keys=True)) - - -def _command_exists(binary: str) -> bool: - for path_dir in os.environ.get("PATH", "").split(":"): - candidate = Path(path_dir) / binary - if candidate.exists() and os.access(candidate, os.X_OK): - return True - return False - - -def _read_wrapper_stamp(path: Path) -> dict[str, Any] | None: - try: - raw = path.read_text(encoding="utf-8").strip() - except OSError: - return None - if not raw: - return None - parts = raw.split() - if len(parts) < 2: - return {"raw": raw} - signature, epoch_text = parts[0], parts[1] - payload: dict[str, Any] = {"signature": signature, "epoch": epoch_text} - try: - payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(epoch_text))) - except ValueError: - pass - return payload - - -def _read_wrapper_mode(path: Path) -> dict[str, Any] | None: - try: - raw = path.read_text(encoding="utf-8").strip() - except OSError: - return None - if not raw: - return None - parts = raw.split() - if len(parts) < 3: - return {"raw": raw} - mode, reason, epoch_text = parts[0], parts[1], parts[2] - payload: dict[str, Any] = {"mode": mode, "reason": reason, "epoch": epoch_text} - try: - payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(epoch_text))) - except ValueError: - pass - return payload - - -def _run_quick_command(command: list[str]) -> dict[str, Any]: - try: - completed = subprocess.run( - command, - capture_output=True, - text=True, - timeout=3, - check=False, - ) - except Exception as exc: # noqa: BLE001 - return {"ok": False, "error": str(exc), "command": command} - return { - "ok": completed.returncode == 0, - "returncode": completed.returncode, - "stdout": completed.stdout.strip(), - "stderr": completed.stderr.strip(), - "command": command, - } - - -def _wrapper_status() -> dict[str, Any]: - wrapper_dir = cache_root() / "wrapper" - rust_bin = repo_root() / "target" / "debug" / "constant" - payload: dict[str, Any] = { - "wrapper_dir": str(wrapper_dir), - "rust_bin": str(rust_bin), - "rust_bin_exists": rust_bin.exists(), - "forced_python": os.environ.get("CONSTANT_USE_PYTHON") == "1", - "forced_rust": os.environ.get("CONSTANT_USE_RUST") == "1", - "force_recheck": os.environ.get("CONSTANT_RUST_RECHECK") == "1", - "last_mode": _read_wrapper_mode(wrapper_dir / "last-mode"), - "probe_timeout_sec": os.environ.get("CONSTANT_RUST_PROBE_TIMEOUT_SEC", "2"), - "probe_fail_ttl_sec": os.environ.get("CONSTANT_RUST_PROBE_FAIL_TTL_SEC", "300"), - "cache": { - "rust_ok": _read_wrapper_stamp(wrapper_dir / "rust-ok"), - "rust_fail": _read_wrapper_stamp(wrapper_dir / "rust-fail"), - }, - } - if rust_bin.exists(): - payload["codesign_verify"] = _run_quick_command( - ["codesign", "--verify", "--verbose=2", str(rust_bin)] - ) - payload["spctl_assess"] = _run_quick_command(["spctl", "--assess", "-vv", str(rust_bin)]) - payload["xattr_provenance"] = _run_quick_command(["xattr", "-p", "com.apple.provenance", str(rust_bin)]) - payload["hint"] = ( - "If spctl reports a Code Signing subsystem error from a normal terminal, allow that terminal in " - "System Settings -> Privacy & Security -> Developer Tools. Then rerun with CONSTANT_RUST_RECHECK=1 " - "to force a fresh startup probe." - ) - return payload - - -def _warm_memory(workspace: str) -> dict[str, Any] | None: - try: - return rebuild_workspace_memory(workspace, enroll=True) - except Exception as exc: # noqa: BLE001 - return {"error": str(exc), "workspace": workspace} - - -def _finalize_mission_memory(mission_id: str) -> dict[str, Any] | None: - try: - return summarize_mission(mission_id) - except Exception: # noqa: BLE001 - return None - - -def _ensure_planned(mission: dict[str, Any]) -> dict[str, Any]: - if mission["steps"]: - return mission - - payload = daemon_request("plan", {"mission": mission}) - mission["steps"] = payload["plan"]["steps"] - mission["title"] = payload["plan"]["title"] - mission["status"] = "planned" - mission["planner_summary"] = payload["plan"]["summary"] - mission["buddy_review"] = payload["buddy_review"] - save_mission(mission) - append_event(mission["mission_id"], "mission.planned", payload) - return mission - - -def _mission_summary(mission: dict[str, Any]) -> dict[str, Any]: - return { - "mission_id": mission["mission_id"], - "title": mission["title"], - "status": mission["status"], - "workspace": mission["workspace"], - "steps": [ - { - "step_id": step["step_id"], - "status": step["status"], - "machine": step["machine"], - "backend": step["backend"], - "cli": step["cli"], - "agent": step["agent"], - "agent_role": step.get("agent_role"), - "skill": step.get("skill"), - "skill_summary": step.get("skill_summary"), - "attempt": step["attempt"], - } - for step in mission["steps"] - ], - } - - -def cmd_doctor(args: argparse.Namespace) -> int: - status = daemon_status() - health = daemon_request("health", auto_start=False) - fleet = load_fleet_config() - models = load_models_config() - cockpit = cockpit_doctor() - report = { - "version": __version__, - "repo_root": str(repo_root()), - "cache_root": str(cache_root()), - "commands": { - "python3": _command_exists("python3"), - "tmux": _command_exists("tmux"), - "omc": _command_exists("omc"), - "claude": _command_exists("claude"), - "codex": _command_exists("codex"), - "copilot": _command_exists("copilot"), - "vibe": _command_exists("vibe"), - "constant-fleet": (scripts_dir() / "constant-fleet.sh").exists(), - "ai-bridge": (scripts_dir() / "ai-bridge.sh").exists(), - }, - "daemon": status, - "models": models, - "health": health, - "memory": memory_status(), - "fleet": [{"label": entry["label"], "target": entry["target"]} for entry in fleet["machines"]], - "cockpit": cockpit, - "wrapper": _wrapper_status(), - } - _print(report, args.json) - return 0 - - -def cmd_daemon_start(_: argparse.Namespace) -> int: - payload = start_background() - if not payload.get("running"): - payload["mode"] = "inline-fallback" - _print(payload) - return 0 - - -def cmd_daemon_stop(_: argparse.Namespace) -> int: - _print(stop_background()) - return 0 - - -def cmd_daemon_status(args: argparse.Namespace) -> int: - payload = {"status": daemon_status(), "health": daemon_request("health", auto_start=False)} - if not payload["status"]["running"]: - payload["mode"] = "inline-fallback" - _print(payload, args.json) - return 0 - - -def cmd_daemon_logs(_: argparse.Namespace) -> int: - from .paths import daemon_log_path - - path = daemon_log_path() - if not path.exists(): - print("No daemon log yet.") - return 0 - print(path.read_text(encoding="utf-8")) - return 0 - - -def cmd_models_status(args: argparse.Namespace) -> int: - payload = {"config": load_models_config(), "daemon": daemon_status(), "health": daemon_request("health", auto_start=False)} - if not payload["daemon"]["running"]: - payload["mode"] = "inline-fallback" - _print(payload, args.json) - return 0 - - -def cmd_agents(args: argparse.Namespace) -> int: - payload = {"agents": list_agents(), "recommended_skill_stack": recommended_skill_stack()} - _print(payload, args.json) - return 0 - - -def cmd_skills(args: argparse.Namespace) -> int: - payload = { - "skills": list_skills(include_internal=not getattr(args, "public_only", False)), - "catalog": skill_catalog(include_internal=not getattr(args, "public_only", False)), - "recommended_skill_stack": recommended_skill_stack(), - } - if getattr(args, "workspace", None): - payload["instruction_skill_sources"] = instruction_skill_sources(args.workspace) - _print(payload, args.json) - return 0 - - -def cmd_fleet_status(args: argparse.Namespace) -> int: - payload = fleet_check() - _print(payload, args.json) - return 0 if payload["returncode"] == 0 else 1 - - -def cmd_fleet_sync(args: argparse.Namespace) -> int: - payload = bridge_sync() - _print(payload, args.json) - return 0 if payload["returncode"] == 0 else 1 - - -def _exec_fleet_deploy_script(mode: str, args: argparse.Namespace) -> int: - script = scripts_dir() / "constant-deploy.sh" - command = [str(script), mode] - - for host in args.host or []: - command.extend(["--host", host]) - if args.user: - command.extend(["--user", args.user]) - if args.repo_dir: - command.extend(["--repo-dir", args.repo_dir]) - if args.local_label: - command.extend(["--local-label", args.local_label]) - if args.output: - command.extend(["--output", args.output]) - if getattr(args, "json", False): - command.append("--json") - if getattr(args, "yes", False): - command.append("--yes") - if getattr(args, "all_reachable", False): - command.append("--all-reachable") - if getattr(args, "install", False): - command.append("--install") - if getattr(args, "no_ssh_config", False): - command.append("--no-ssh-config") - if getattr(args, "no_known_hosts", False): - command.append("--no-known-hosts") - if getattr(args, "no_arp", False): - command.append("--no-arp") - - os.execv(command[0], command) - return 0 - - -def cmd_fleet_discover(args: argparse.Namespace) -> int: - return _exec_fleet_deploy_script("scan", args) - - -def cmd_fleet_configure(args: argparse.Namespace) -> int: - return _exec_fleet_deploy_script("configure", args) - - -def cmd_fleet_deploy(args: argparse.Namespace) -> int: - return _exec_fleet_deploy_script("deploy", args) - - -def cmd_cockpit_open(args: argparse.Namespace) -> int: - workspace = args.workspace or os.getcwd() - command = [ - str(scripts_dir() / "constant-fleet.sh"), - "--workspace", - workspace, - "--local-session", - args.local_session or "constant-fleet", - "--session", - args.session or "constant", - ] - if getattr(args, "recreate", False): - command.append("--recreate") - if getattr(args, "remote_recreate", False): - command.append("--remote-recreate") - os.execv(command[0], command) - return 0 - - -def cmd_cockpit_attach(args: argparse.Namespace) -> int: - command = [ - str(scripts_dir() / "constant-fleet.sh"), - "--attach-only", - "--local-session", - args.local_session or "constant-fleet", - ] - os.execv(command[0], command) - return 0 - - -def cmd_cockpit_doctor(args: argparse.Namespace) -> int: - payload = cockpit_doctor(local_session=args.local_session or "constant-fleet", machine_session=args.session or "constant") - _print(payload, args.json) - return 0 if payload["tmux"]["available"] else 1 - - -def cmd_cockpit_focus(args: argparse.Namespace) -> int: - payload = focus_machine( - args.machine, - args.pane, - local_session=args.local_session or "constant-fleet", - machine_session=args.session or "constant", - ) - _print(payload, args.json) - return 0 if payload["returncode"] == 0 else 1 - - -def cmd_cockpit_send(args: argparse.Namespace) -> int: - payload = send_to_pane(args.machine, args.pane, args.command, machine_session=args.session or "constant") - _print(payload, args.json) - return 0 if payload["returncode"] == 0 else 1 - - -def cmd_cockpit_capture(args: argparse.Namespace) -> int: - payload = capture_pane(args.machine, args.pane, lines=args.lines, machine_session=args.session or "constant") - _print(payload if args.json else payload.get("stdout", ""), args.json) - return 0 if payload["returncode"] == 0 else 1 - - -def cmd_cockpit_restart(args: argparse.Namespace) -> int: - payload = restart_pane(args.machine, args.pane, machine_session=args.session or "constant") - _print(payload, args.json) - return 0 if payload["returncode"] == 0 else 1 - - -def cmd_cockpit_status(args: argparse.Namespace) -> int: - payload = runtime_status(local_session=args.local_session or "constant-fleet", machine_session=args.session or "constant") - _print(payload, args.json) - return 0 - - -def cmd_tui(args: argparse.Namespace) -> int: - workspace = str(Path(args.workspace or os.getcwd()).expanduser().resolve()) - action = run_tui( - workspace, - local_session=args.local_session or "constant-fleet", - machine_session=args.session or "constant", - ) - if action and action.get("action") == "cockpit": - return cmd_cockpit_open( - argparse.Namespace( - workspace=action.get("workspace", workspace), - local_session=args.local_session, - session=args.session, - recreate=False, - remote_recreate=False, - ) - ) - return 0 - - -def cmd_mission_create(args: argparse.Namespace) -> int: - _warm_memory(args.workspace) - mission = create_mission(args.prompt, args.workspace) - mission = _ensure_planned(mission) - _print(_mission_summary(mission), args.json) - return 0 - - -def cmd_mission_plan(args: argparse.Namespace) -> int: - mission = load_mission(args.mission_id) - _warm_memory(mission["workspace"]) - payload = daemon_request("plan", {"mission": mission}) - mission["steps"] = payload["plan"]["steps"] - mission["title"] = payload["plan"]["title"] - mission["status"] = "planned" - mission["planner_summary"] = payload["plan"]["summary"] - mission["buddy_review"] = payload["buddy_review"] - save_mission(mission) - append_event(mission["mission_id"], "mission.replanned", payload) - _print({"summary": _mission_summary(mission), "buddy_review": payload["buddy_review"]}, args.json) - return 0 - - -def _verify_and_update(mission: dict[str, Any], step: dict[str, Any], execution: dict[str, Any]) -> dict[str, Any]: - verdict = daemon_request("verify", {"mission": mission, "step": step, "execution": execution}) - step["verification"] = verdict - step["result_summary"] = verdict.get("summary", "") - return verdict - - -def cmd_mission_run(args: argparse.Namespace) -> int: - mission = load_mission(args.mission_id) - mission = _ensure_planned(mission) - - step = first_active_step(mission) - if step is None: - mission["status"] = "done" - save_mission(mission) - _print(_mission_summary(mission), args.json) - return 0 - - while step is not None: - step["status"] = "running" - step["attempt"] += 1 - mission["status"] = "running" - save_mission(mission) - append_event(mission["mission_id"], "step.started", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"]}) - - execution = execute_step(step, mission, load_fleet_config()) - artifact_path = write_artifact( - mission["mission_id"], - f"{step['step_id']}-attempt-{step['attempt']}.json", - execution, - ) - step["artifact_refs"].append(artifact_path) - append_event(mission["mission_id"], "step.executed", {"step_id": step["step_id"], "artifact": artifact_path, "returncode": execution["returncode"]}) - - verdict = _verify_and_update(mission, step, execution) - decision = verdict.get("decision", "failed") - - if decision == "done": - step["status"] = "done" - elif decision == "retry" and step["attempt"] < 2: - step["status"] = "pending" - elif decision == "needs_human": - step["status"] = "needs_human" - mission["status"] = "needs_human" - save_mission(mission) - append_event(mission["mission_id"], "step.needs_human", {"step_id": step["step_id"], "summary": verdict.get("summary", "")}) - _finalize_mission_memory(mission["mission_id"]) - _print({"summary": _mission_summary(mission), "verdict": verdict}, args.json) - return 1 - else: - step["status"] = "failed" - mission["status"] = "failed" - save_mission(mission) - append_event(mission["mission_id"], "step.failed", {"step_id": step["step_id"], "summary": verdict.get("summary", "")}) - _finalize_mission_memory(mission["mission_id"]) - _print({"summary": _mission_summary(mission), "verdict": verdict}, args.json) - return 1 - - save_mission(mission) - append_event(mission["mission_id"], "step.verified", {"step_id": step["step_id"], "verdict": verdict}) - step = first_active_step(mission) - - mission["status"] = "done" - save_mission(mission) - append_event(mission["mission_id"], "mission.done", {"mission_id": mission["mission_id"]}) - _finalize_mission_memory(mission["mission_id"]) - _print(_mission_summary(mission), args.json) - return 0 - - -def cmd_mission_status(args: argparse.Namespace) -> int: - if args.mission_id: - mission = load_mission(args.mission_id) - _print(_mission_summary(mission) if not args.verbose else mission, args.json) - return 0 - - missions = [_mission_summary(mission) for mission in list_missions()] - _print({"missions": missions}, args.json) - return 0 - - -def cmd_mission_tail(args: argparse.Namespace) -> int: - path = mission_events_file(args.mission_id) - if not path.exists(): - print(f"No events for mission {args.mission_id}") - return 1 - - if not args.follow: - print(path.read_text(encoding="utf-8"), end="") - return 0 - - seen_size = 0 - while True: - text = path.read_text(encoding="utf-8") - if len(text) > seen_size: - print(text[seen_size:], end="") - seen_size = len(text) - time.sleep(1) - - -def cmd_mission_verify(args: argparse.Namespace) -> int: - mission = load_mission(args.mission_id) - step = next((entry for entry in mission["steps"] if entry["step_id"] == args.step_id), None) if args.step_id else (mission["steps"][-1] if mission["steps"] else None) - if step is None or not step["artifact_refs"]: - print("No step artifact available to verify.") - return 1 - - artifact = json.loads(Path(step["artifact_refs"][-1]).read_text(encoding="utf-8")) - verdict = _verify_and_update(mission, step, artifact) - save_mission(mission) - append_event(mission["mission_id"], "mission.verify", {"step_id": step["step_id"], "verdict": verdict}) - _print({"step_id": step["step_id"], "verdict": verdict}, args.json) - return 0 if verdict.get("decision") == "done" else 1 - - -def cmd_mission_retry(args: argparse.Namespace) -> int: - mission = load_mission(args.mission_id) - step = next((entry for entry in mission["steps"] if entry["step_id"] == args.step_id), None) if args.step_id else first_active_step(mission) or (mission["steps"][-1] if mission["steps"] else None) - if step is None: - print("No step to retry.") - return 1 - - step["status"] = "pending" - mission["status"] = "planned" - save_mission(mission) - append_event(mission["mission_id"], "step.retry_requested", {"step_id": step["step_id"]}) - _print(_mission_summary(mission), args.json) - return 0 - - -def cmd_delegate(args: argparse.Namespace) -> int: - mission = load_mission(args.mission_id) - step = next((entry for entry in mission["steps"] if entry["step_id"] == args.step_id), None) if args.step_id else first_active_step(mission) - if step is None: - print("No active step to delegate.") - return 1 - - if args.machine: - fleet_machine(args.machine) - step["machine"] = args.machine - if args.backend: - step["backend"] = args.backend - if args.skill or args.agent or args.cli: - resolved_cli = args.cli - resolved_agent = args.agent - if not args.skill and not resolved_cli: - resolved_cli = step.get("cli") - if not args.skill and not resolved_agent: - resolved_agent = step.get("agent") - resolved = resolve_skill_and_agent( - goal=step.get("prompt") or mission.get("goal", ""), - skill_id=args.skill or step.get("skill"), - agent_id=resolved_agent, - cli=resolved_cli, - ) - step["skill"] = resolved["skill"]["id"] - step["skill_summary"] = resolved["skill"]["summary"] - step["agent"] = resolved["agent"]["id"] - step["agent_role"] = resolved["agent"]["role"] - step["cli"] = resolved["cli"] - elif step.get("cli") and not step.get("agent"): - agent = agent_for_cli(step["cli"]) - step["agent"] = agent["id"] - step["agent_role"] = agent["role"] - - if step.get("skill"): - try: - step["skill_summary"] = skill_by_id(step["skill"])["summary"] - except KeyError: - pass - step["status"] = "pending" - mission["status"] = "planned" - save_mission(mission) - append_event(mission["mission_id"], "step.delegated", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"], "agent": step["agent"], "skill": step.get("skill")}) - _print(_mission_summary(mission), args.json) - return 0 - - -def cmd_buddy_ask(args: argparse.Namespace) -> int: - mission = load_mission(args.mission_id) if args.mission_id else None - if mission: - _warm_memory(mission["workspace"]) - answer = daemon_request("buddy", {"mission": mission, "prompt": args.prompt}) - if mission: - append_event(mission["mission_id"], "buddy.ask", {"prompt": args.prompt, "answer": answer}) - _print(answer, args.json) - return 0 - - -def cmd_memory_status(args: argparse.Namespace) -> int: - _print(memory_status(args.workspace), args.json) - return 0 - - -def cmd_memory_rebuild(args: argparse.Namespace) -> int: - _print(rebuild_workspace_memory(args.workspace, enroll=not args.no_enroll), args.json) - return 0 - - -def cmd_memory_enroll(args: argparse.Namespace) -> int: - _print(enroll_workspace(args.path), args.json) - return 0 - - -def cmd_memory_search(args: argparse.Namespace) -> int: - _print(search_memory(args.query, args.workspace, args.limit), args.json) - return 0 - - -def cmd_memory_persona_show(args: argparse.Namespace) -> int: - payload = {"persona": persona_markdown()} if args.json else persona_markdown() - _print(payload, args.json) - return 0 - - -def cmd_memory_decisions(args: argparse.Namespace) -> int: - _print(list_decisions(args.workspace, args.mission_id), args.json) - return 0 - - -def cmd_memory_sync_qdrant(args: argparse.Namespace) -> int: - payload = sync_qdrant(args.workspace) - _print(payload, args.json) - return 0 if payload.get("ok") or payload.get("skipped") else 1 - - -def cmd_mission_summarize(args: argparse.Namespace) -> int: - _print(summarize_mission(args.mission_id), args.json) - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog=os.environ.get("CONSTANT_PROG_NAME", "Constant")) - parser.add_argument("-V", "--version", action="version", version=__version__) - subparsers = parser.add_subparsers(dest="command", required=True) - - doctor = subparsers.add_parser("doctor") - doctor.add_argument("--json", action="store_true") - doctor.set_defaults(func=cmd_doctor) - - tui = subparsers.add_parser("tui") - tui.add_argument("--workspace") - tui.add_argument("--local-session") - tui.add_argument("--session") - tui.set_defaults(func=cmd_tui) - - daemon = subparsers.add_parser("daemon") - daemon_sub = daemon.add_subparsers(dest="daemon_command", required=True) - daemon_start = daemon_sub.add_parser("start") - daemon_start.set_defaults(func=cmd_daemon_start) - daemon_stop = daemon_sub.add_parser("stop") - daemon_stop.set_defaults(func=cmd_daemon_stop) - daemon_status_cmd = daemon_sub.add_parser("status") - daemon_status_cmd.add_argument("--json", action="store_true") - daemon_status_cmd.set_defaults(func=cmd_daemon_status) - daemon_logs = daemon_sub.add_parser("logs") - daemon_logs.set_defaults(func=cmd_daemon_logs) - - models = subparsers.add_parser("models") - models_sub = models.add_subparsers(dest="models_command", required=True) - models_status = models_sub.add_parser("status") - models_status.add_argument("--json", action="store_true") - models_status.set_defaults(func=cmd_models_status) - - agents = subparsers.add_parser("agents") - agents.add_argument("--json", action="store_true") - agents.set_defaults(func=cmd_agents) - - skills = subparsers.add_parser("skills") - skills.add_argument("--workspace") - skills.add_argument("--public-only", action="store_true") - skills.add_argument("--json", action="store_true") - skills.set_defaults(func=cmd_skills) - - fleet = subparsers.add_parser("fleet") - fleet_sub = fleet.add_subparsers(dest="fleet_command", required=True) - fleet_status_cmd = fleet_sub.add_parser("status") - fleet_status_cmd.add_argument("--json", action="store_true") - fleet_status_cmd.set_defaults(func=cmd_fleet_status) - fleet_sync_cmd = fleet_sub.add_parser("sync") - fleet_sync_cmd.add_argument("--json", action="store_true") - fleet_sync_cmd.set_defaults(func=cmd_fleet_sync) - for fleet_cmd_name, fleet_cmd_func, allow_json in ( - ("discover", cmd_fleet_discover, True), - ("configure", cmd_fleet_configure, False), - ("deploy", cmd_fleet_deploy, False), - ): - fleet_cmd = fleet_sub.add_parser(fleet_cmd_name) - fleet_cmd.add_argument("--host", action="append") - fleet_cmd.add_argument("--user") - fleet_cmd.add_argument("--repo-dir") - fleet_cmd.add_argument("--local-label") - fleet_cmd.add_argument("--output") - fleet_cmd.add_argument("--yes", action="store_true") - fleet_cmd.add_argument("--all-reachable", action="store_true") - fleet_cmd.add_argument("--no-ssh-config", action="store_true") - fleet_cmd.add_argument("--no-known-hosts", action="store_true") - fleet_cmd.add_argument("--no-arp", action="store_true") - if fleet_cmd_name == "configure": - fleet_cmd.add_argument("--install", action="store_true") - if allow_json: - fleet_cmd.add_argument("--json", action="store_true") - fleet_cmd.set_defaults(func=fleet_cmd_func) - - cockpit = subparsers.add_parser("cockpit") - cockpit_sub = cockpit.add_subparsers(dest="cockpit_command", required=True) - cockpit_open = cockpit_sub.add_parser("open") - cockpit_open.add_argument("--workspace") - cockpit_open.add_argument("--local-session") - cockpit_open.add_argument("--session") - cockpit_open.add_argument("--recreate", action="store_true") - cockpit_open.add_argument("--remote-recreate", action="store_true") - cockpit_open.set_defaults(func=cmd_cockpit_open) - cockpit_attach = cockpit_sub.add_parser("attach") - cockpit_attach.add_argument("--local-session") - cockpit_attach.set_defaults(func=cmd_cockpit_attach) - cockpit_doctor_cmd = cockpit_sub.add_parser("doctor") - cockpit_doctor_cmd.add_argument("--local-session") - cockpit_doctor_cmd.add_argument("--session") - cockpit_doctor_cmd.add_argument("--json", action="store_true") - cockpit_doctor_cmd.set_defaults(func=cmd_cockpit_doctor) - cockpit_status_cmd = cockpit_sub.add_parser("status") - cockpit_status_cmd.add_argument("--local-session") - cockpit_status_cmd.add_argument("--session") - cockpit_status_cmd.add_argument("--json", action="store_true") - cockpit_status_cmd.set_defaults(func=cmd_cockpit_status) - cockpit_focus_cmd = cockpit_sub.add_parser("focus") - cockpit_focus_cmd.add_argument("--machine", required=True) - cockpit_focus_cmd.add_argument("--pane") - cockpit_focus_cmd.add_argument("--local-session") - cockpit_focus_cmd.add_argument("--session") - cockpit_focus_cmd.add_argument("--json", action="store_true") - cockpit_focus_cmd.set_defaults(func=cmd_cockpit_focus) - cockpit_send_cmd = cockpit_sub.add_parser("send") - cockpit_send_cmd.add_argument("--machine", required=True) - cockpit_send_cmd.add_argument("--pane", required=True) - cockpit_send_cmd.add_argument("--command", required=True) - cockpit_send_cmd.add_argument("--session") - cockpit_send_cmd.add_argument("--json", action="store_true") - cockpit_send_cmd.set_defaults(func=cmd_cockpit_send) - cockpit_capture_cmd = cockpit_sub.add_parser("capture") - cockpit_capture_cmd.add_argument("--machine", required=True) - cockpit_capture_cmd.add_argument("--pane", required=True) - cockpit_capture_cmd.add_argument("--lines", type=int, default=120) - cockpit_capture_cmd.add_argument("--session") - cockpit_capture_cmd.add_argument("--json", action="store_true") - cockpit_capture_cmd.set_defaults(func=cmd_cockpit_capture) - cockpit_restart_cmd = cockpit_sub.add_parser("restart") - cockpit_restart_cmd.add_argument("--machine", required=True) - cockpit_restart_cmd.add_argument("--pane", required=True) - cockpit_restart_cmd.add_argument("--session") - cockpit_restart_cmd.add_argument("--json", action="store_true") - cockpit_restart_cmd.set_defaults(func=cmd_cockpit_restart) - - mission = subparsers.add_parser("mission") - mission_sub = mission.add_subparsers(dest="mission_command", required=True) - mission_create = mission_sub.add_parser("create") - mission_create.add_argument("prompt") - mission_create.add_argument("--workspace", default=os.getcwd()) - mission_create.add_argument("--json", action="store_true") - mission_create.set_defaults(func=cmd_mission_create) - mission_plan = mission_sub.add_parser("plan") - mission_plan.add_argument("mission_id") - mission_plan.add_argument("--json", action="store_true") - mission_plan.set_defaults(func=cmd_mission_plan) - mission_run = mission_sub.add_parser("run") - mission_run.add_argument("mission_id") - mission_run.add_argument("--json", action="store_true") - mission_run.set_defaults(func=cmd_mission_run) - mission_status = mission_sub.add_parser("status") - mission_status.add_argument("mission_id", nargs="?") - mission_status.add_argument("--verbose", action="store_true") - mission_status.add_argument("--json", action="store_true") - mission_status.set_defaults(func=cmd_mission_status) - mission_tail = mission_sub.add_parser("tail") - mission_tail.add_argument("mission_id") - mission_tail.add_argument("--follow", action="store_true") - mission_tail.set_defaults(func=cmd_mission_tail) - mission_verify = mission_sub.add_parser("verify") - mission_verify.add_argument("mission_id") - mission_verify.add_argument("--step-id") - mission_verify.add_argument("--json", action="store_true") - mission_verify.set_defaults(func=cmd_mission_verify) - mission_retry = mission_sub.add_parser("retry") - mission_retry.add_argument("mission_id") - mission_retry.add_argument("--step-id") - mission_retry.add_argument("--json", action="store_true") - mission_retry.set_defaults(func=cmd_mission_retry) - mission_summarize = mission_sub.add_parser("summarize") - mission_summarize.add_argument("mission_id") - mission_summarize.add_argument("--json", action="store_true") - mission_summarize.set_defaults(func=cmd_mission_summarize) - - delegate = subparsers.add_parser("delegate") - delegate.add_argument("mission_id") - delegate.add_argument("--step-id") - delegate.add_argument("--machine") - delegate.add_argument("--backend") - delegate.add_argument("--cli") - delegate.add_argument("--agent") - delegate.add_argument("--skill") - delegate.add_argument("--json", action="store_true") - delegate.set_defaults(func=cmd_delegate) - - buddy = subparsers.add_parser("buddy") - buddy_sub = buddy.add_subparsers(dest="buddy_command", required=True) - buddy_ask = buddy_sub.add_parser("ask") - buddy_ask.add_argument("prompt") - buddy_ask.add_argument("--mission-id") - buddy_ask.add_argument("--json", action="store_true") - buddy_ask.set_defaults(func=cmd_buddy_ask) - - memory = subparsers.add_parser("memory") - memory_sub = memory.add_subparsers(dest="memory_command", required=True) - memory_status_cmd = memory_sub.add_parser("status") - memory_status_cmd.add_argument("--workspace") - memory_status_cmd.add_argument("--json", action="store_true") - memory_status_cmd.set_defaults(func=cmd_memory_status) - memory_rebuild_cmd = memory_sub.add_parser("rebuild") - memory_rebuild_cmd.add_argument("--workspace", default=os.getcwd()) - memory_rebuild_cmd.add_argument("--no-enroll", action="store_true") - memory_rebuild_cmd.add_argument("--json", action="store_true") - memory_rebuild_cmd.set_defaults(func=cmd_memory_rebuild) - memory_enroll_cmd = memory_sub.add_parser("enroll") - memory_enroll_cmd.add_argument("path") - memory_enroll_cmd.add_argument("--json", action="store_true") - memory_enroll_cmd.set_defaults(func=cmd_memory_enroll) - memory_search_cmd = memory_sub.add_parser("search") - memory_search_cmd.add_argument("query") - memory_search_cmd.add_argument("--workspace") - memory_search_cmd.add_argument("--limit", type=int) - memory_search_cmd.add_argument("--json", action="store_true") - memory_search_cmd.set_defaults(func=cmd_memory_search) - memory_persona_cmd = memory_sub.add_parser("persona") - memory_persona_sub = memory_persona_cmd.add_subparsers(dest="memory_persona_command", required=True) - memory_persona_show_cmd = memory_persona_sub.add_parser("show") - memory_persona_show_cmd.add_argument("--json", action="store_true") - memory_persona_show_cmd.set_defaults(func=cmd_memory_persona_show) - memory_decisions_cmd = memory_sub.add_parser("decisions") - memory_decisions_cmd.add_argument("--workspace") - memory_decisions_cmd.add_argument("--mission-id") - memory_decisions_cmd.add_argument("--json", action="store_true") - memory_decisions_cmd.set_defaults(func=cmd_memory_decisions) - memory_sync_cmd = memory_sub.add_parser("sync-qdrant") - memory_sync_cmd.add_argument("--workspace") - memory_sync_cmd.add_argument("--json", action="store_true") - memory_sync_cmd.set_defaults(func=cmd_memory_sync_qdrant) - - hidden = subparsers.add_parser("__serve") - hidden.set_defaults(func=lambda _args: serve_foreground()) - - return parser - - -def main(argv: list[str] | None = None) -> int: - if argv is None: - argv = sys.argv[1:] - if not argv: - if sys.stdin.isatty() and sys.stdout.isatty(): - argv = ["cockpit", "open", "--workspace", os.getcwd()] - else: - argv = ["doctor"] - parser = build_parser() - args = parser.parse_args(argv) - return args.func(args) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/constant/cockpit.py b/constant/cockpit.py deleted file mode 100644 index 40264e5..0000000 --- a/constant/cockpit.py +++ /dev/null @@ -1,218 +0,0 @@ -from __future__ import annotations - -import shlex -import subprocess -import socket -from pathlib import Path -from typing import Any - -from .paths import scripts_dir -from .state import fleet_machine, load_fleet_config - -ROLES = ("claude", "codex", "copilot", "vibe") - - -def _run(args: list[str]) -> dict[str, Any]: - process = subprocess.run(args, capture_output=True, text=True) - return { - "argv": args, - "returncode": process.returncode, - "stdout": process.stdout, - "stderr": process.stderr, - } - - -def _tmux_list_command(session_name: str) -> list[str]: - return [ - "tmux", - "list-panes", - "-t", - session_name, - "-F", - "#{session_name}\t#{window_name}\t#{pane_id}\t#{pane_index}\t#{@constant_role}\t#{pane_title}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}\t#{pane_dead_status}", - ] - - -def _ssh_command(target: str, inner: str) -> list[str]: - remote_shell = ( - 'PATH="$HOME/.local/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; ' - "export PATH; " - f"{inner}" - ) - return ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=1", target, remote_shell] - - -def _machine_session_name(session_name: str, machine_label: str) -> str: - return f"{session_name}:{machine_label}" - - -def _parse_panes(stdout: str) -> list[dict[str, Any]]: - panes: list[dict[str, Any]] = [] - for raw in stdout.splitlines(): - parts = raw.split("\t") - if len(parts) != 10: - continue - session_name, window_name, pane_id, pane_index, pane_role, pane_title, pane_command, pane_active, pane_dead, pane_dead_status = parts - role = pane_role or (pane_title if pane_title in ROLES else pane_command) - panes.append( - { - "session_name": session_name, - "window_name": window_name, - "pane_id": pane_id, - "pane_index": int(pane_index), - "role": role, - "pane_role": pane_role, - "pane_title": pane_title, - "pane_command": pane_command, - "active": pane_active == "1", - "dead": pane_dead == "1", - "dead_status": int(pane_dead_status or "0"), - } - ) - return sorted(panes, key=lambda item: item["pane_index"]) - - -def _machine_tmux_status(machine: dict[str, Any], session: str) -> dict[str, Any]: - label = machine["label"] - target = machine["target"] - local_names = {socket.gethostname(), socket.getfqdn(), socket.gethostname().split(".")[0], "local", "localhost", "127.0.0.1", "::1"} - is_local = target in local_names - session_target = _machine_session_name(session, label) - - if is_local: - result = _run(_tmux_list_command(session_target)) - if result["returncode"] != 0: - for fallback_label in (socket.gethostname().split(".")[0], socket.getfqdn(), socket.gethostname()): - fallback_target = _machine_session_name(session, fallback_label) - result = _run(_tmux_list_command(fallback_target)) - if result["returncode"] == 0: - session_target = fallback_target - break - else: - inner = shlex.join(_tmux_list_command(session_target)) - result = _run(_ssh_command(target, inner)) - - panes = _parse_panes(result["stdout"]) if result["returncode"] == 0 else [] - roles = { - role: next((pane for pane in panes if pane["role"] == role), None) - for role in ROLES - } - return { - "label": label, - "target": target, - "session": session_target, - "reachable": result["returncode"] == 0 or is_local, - "attached_window": label, - "session_exists": result["returncode"] == 0, - "panes": panes, - "roles": roles, - "stderr": result["stderr"].strip(), - } - - -def runtime_status(local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any]: - fleet = load_fleet_config() - local_tmux = _run(["tmux", "list-windows", "-t", local_session, "-F", "#{window_name}\t#{window_active}"]) - fleet_windows: list[str] = [] - focused_machine = None - if local_tmux["returncode"] == 0: - for raw in local_tmux["stdout"].splitlines(): - window_name, _, *rest = raw.split("\t") + [""] - active = raw.split("\t")[1] if "\t" in raw else "0" - fleet_windows.append(window_name) - if active == "1": - focused_machine = window_name - machines = [_machine_tmux_status(machine, machine_session) for machine in fleet["machines"]] - focused_role = None - for machine in machines: - if machine["label"] != focused_machine: - continue - for role in ROLES: - pane = machine["roles"].get(role) - if pane and pane.get("active"): - focused_role = role - break - break - return { - "local_session": local_session, - "machine_session": machine_session, - "fleet_session_exists": local_tmux["returncode"] == 0, - "fleet_windows": fleet_windows, - "focused_machine": focused_machine, - "focused_role": focused_role, - "machines": machines, - "fleet_stderr": local_tmux["stderr"].strip(), - } - - -def cockpit_doctor(local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any]: - tmux_check = _run(["tmux", "-V"]) - payload = runtime_status(local_session=local_session, machine_session=machine_session) - payload["tmux"] = { - "available": tmux_check["returncode"] == 0, - "stdout": tmux_check["stdout"].strip(), - "stderr": tmux_check["stderr"].strip(), - } - return payload - - -def _machine_control_script(machine_label: str, machine_session: str) -> tuple[dict[str, Any], Path]: - machine = fleet_machine(machine_label) - script = scripts_dir() / "constant-machine.sh" - return machine, script - - -def _machine_command_args(machine: dict[str, Any], args: list[str]) -> list[str]: - return ["env", f"ZELLIJ_AI_MACHINE_NAME={machine['label']}", *args] - - -def _run_machine_command(machine: dict[str, Any], args: list[str]) -> dict[str, Any]: - command_args = _machine_command_args(machine, args) - target = machine["target"] - local_names = {socket.gethostname(), socket.getfqdn(), socket.gethostname().split(".")[0], "local", "localhost", "127.0.0.1", "::1"} - if target in local_names: - return _run(command_args) - - quoted = shlex.join(command_args) - return _run(_ssh_command(target, quoted)) - - -def focus_machine(machine_label: str, pane_role: str | None, local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any]: - machine, script = _machine_control_script(machine_label, machine_session) - if pane_role: - payload = _run_machine_command(machine, [str(script), "--session", machine_session, "--focus-pane", pane_role]) - if payload["returncode"] != 0: - return payload - - select_window = _run(["tmux", "select-window", "-t", f"{local_session}:{machine_label}"]) - return { - "returncode": select_window["returncode"], - "stdout": select_window["stdout"], - "stderr": select_window["stderr"], - "machine": machine_label, - "pane": pane_role, - } - - -def send_to_pane(machine_label: str, pane_role: str, command: str, machine_session: str = "constant") -> dict[str, Any]: - machine, script = _machine_control_script(machine_label, machine_session) - return _run_machine_command( - machine, - [str(script), "--session", machine_session, "--send-pane", pane_role, "--command", command], - ) - - -def capture_pane(machine_label: str, pane_role: str, lines: int = 120, machine_session: str = "constant") -> dict[str, Any]: - machine, script = _machine_control_script(machine_label, machine_session) - return _run_machine_command( - machine, - [str(script), "--session", machine_session, "--capture-pane", pane_role, "--lines", str(lines)], - ) - - -def restart_pane(machine_label: str, pane_role: str, machine_session: str = "constant") -> dict[str, Any]: - machine, script = _machine_control_script(machine_label, machine_session) - return _run_machine_command( - machine, - [str(script), "--session", machine_session, "--restart-pane", pane_role], - ) diff --git a/constant/daemon.py b/constant/daemon.py deleted file mode 100644 index 0319c31..0000000 --- a/constant/daemon.py +++ /dev/null @@ -1,240 +0,0 @@ -from __future__ import annotations - -import json -import os -import signal -import socket -import socketserver -import subprocess -import sys -import threading -import time -from pathlib import Path -from typing import Any - -from .paths import daemon_log_path, daemon_pid_path, daemon_port_path -from .planner import PlannerEngine - - -_INLINE_ENGINE = PlannerEngine() - - -def _pid_is_alive(pid: int) -> bool: - try: - os.kill(pid, 0) - except OSError: - return False - return True - - -def daemon_status() -> dict[str, Any]: - pid_path = daemon_pid_path() - port_path = daemon_port_path() - pid = None - running = False - port = None - - if pid_path.exists(): - try: - pid = int(pid_path.read_text(encoding="utf-8").strip()) - running = _pid_is_alive(pid) - except ValueError: - pid = None - - if port_path.exists(): - try: - port = int(port_path.read_text(encoding="utf-8").strip()) - except ValueError: - port = None - - endpoint = f"tcp://127.0.0.1:{port}" if port else None - return { - "running": running, - "pid": pid, - "port": port, - "endpoint": endpoint, - "log": str(daemon_log_path()), - } - - -class _Handler(socketserver.StreamRequestHandler): - engine = _INLINE_ENGINE - - def handle(self) -> None: - raw = self.rfile.readline() - if not raw: - return - - try: - request = json.loads(raw.decode("utf-8")) - op = request["op"] - if op == "health": - payload = self.engine.health() - elif op == "plan": - payload = self.engine.plan_mission(request["mission"]) - elif op == "verify": - payload = self.engine.verify_step(request["mission"], request["step"], request["execution"]) - elif op == "buddy": - payload = self.engine.buddy_ask(request.get("mission"), request["prompt"]) - elif op == "chat": - payload = self.engine.chat( - request["message"], - request.get("mission"), - request["workspace"], - request.get("selected_machine"), - request.get("selected_role"), - request.get("chat_history"), - ) - else: - raise KeyError(f"Unsupported operation: {op}") - response = {"ok": True, "payload": payload} - except Exception as exc: # noqa: BLE001 - response = {"ok": False, "error": str(exc)} - - self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) - - -class _TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): - daemon_threads = True - allow_reuse_address = True - - -def _write_port(port: int) -> None: - daemon_port_path().write_text(str(port), encoding="utf-8") - - -def _remove_port() -> None: - port_path = daemon_port_path() - if port_path.exists(): - port_path.unlink() - - -def serve_foreground() -> int: - pid_path = daemon_pid_path() - log_path = daemon_log_path() - port_path = daemon_port_path() - port_path.parent.mkdir(parents=True, exist_ok=True) - log_path.parent.mkdir(parents=True, exist_ok=True) - - _remove_port() - server = _TcpServer(("127.0.0.1", 0), _Handler) - _write_port(int(server.server_address[1])) - pid_path.write_text(str(os.getpid()), encoding="utf-8") - - def _shutdown(*_: object) -> None: - thread = threading.Thread(target=server.shutdown, daemon=True) - thread.start() - - signal.signal(signal.SIGTERM, _shutdown) - signal.signal(signal.SIGINT, _shutdown) - - try: - server.serve_forever() - finally: - server.server_close() - _remove_port() - if pid_path.exists(): - pid_path.unlink() - - return 0 - - -def start_background() -> dict[str, Any]: - status = daemon_status() - if status["running"]: - return status - - log_path = Path(status["log"]) - log_path.parent.mkdir(parents=True, exist_ok=True) - - with log_path.open("a", encoding="utf-8") as handle: - process = subprocess.Popen( - [sys.executable, "-m", "constant.cli", "__serve"], - stdout=handle, - stderr=handle, - start_new_session=True, - ) - - for _ in range(50): - status = daemon_status() - if status["running"] and status["port"]: - return status - time.sleep(0.1) - - status = daemon_status() - status["mode"] = "inline-fallback" - return status - - -def stop_background() -> dict[str, Any]: - status = daemon_status() - if not status["running"] or not status["pid"]: - return status - - os.kill(status["pid"], signal.SIGTERM) - for _ in range(50): - next_status = daemon_status() - if not next_status["running"]: - return next_status - time.sleep(0.1) - return daemon_status() - - -def _direct_request(op: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: - request_payload = payload or {} - if op == "health": - return _INLINE_ENGINE.health() - if op == "plan": - return _INLINE_ENGINE.plan_mission(request_payload["mission"]) - if op == "verify": - return _INLINE_ENGINE.verify_step( - request_payload["mission"], - request_payload["step"], - request_payload["execution"], - ) - if op == "buddy": - return _INLINE_ENGINE.buddy_ask( - request_payload.get("mission"), - request_payload["prompt"], - ) - if op == "chat": - return _INLINE_ENGINE.chat( - request_payload["message"], - request_payload.get("mission"), - request_payload["workspace"], - request_payload.get("selected_machine"), - request_payload.get("selected_role"), - request_payload.get("chat_history"), - ) - raise RuntimeError(f"Unsupported operation: {op}") - - -def request(op: str, payload: dict[str, Any] | None = None, auto_start: bool = True) -> dict[str, Any]: - if auto_start and not daemon_status()["running"]: - start_background() - - status = daemon_status() - if not status["running"] or not status["port"]: - return _direct_request(op, payload) - - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect(("127.0.0.1", int(status["port"]))) - with sock: - message = {"op": op} - if payload: - message.update(payload) - sock.sendall((json.dumps(message) + "\n").encode("utf-8")) - raw = b"" - while not raw.endswith(b"\n"): - chunk = sock.recv(65536) - if not chunk: - break - raw += chunk - except OSError: - return _direct_request(op, payload) - - response = json.loads(raw.decode("utf-8")) - if not response.get("ok"): - raise RuntimeError(response.get("error", "daemon request failed")) - return response["payload"] diff --git a/constant/executors.py b/constant/executors.py deleted file mode 100644 index fd4ea3d..0000000 --- a/constant/executors.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import shlex -import subprocess -import time -from pathlib import Path -from typing import Any - -from .paths import repo_root, scripts_dir -from .state import fleet_machine - - -def _run_command(args: list[str], cwd: str | None = None) -> dict[str, Any]: - started = time.time() - process = subprocess.run(args, capture_output=True, text=True, cwd=cwd) - return { - "argv": args, - "returncode": process.returncode, - "stdout": process.stdout, - "stderr": process.stderr, - "duration_s": round(time.time() - started, 3), - } - - -def _local_agent_path_export() -> str: - return 'export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH";' - - -def _claude_command(prompt: str) -> list[str]: - return ["claude", "-p", "--output-format", "json", "--permission-mode", "acceptEdits", prompt] - - -def _codex_command(prompt: str, workspace: str) -> list[str]: - return ["codex", "exec", "--json", "--full-auto", "--skip-git-repo-check", "-C", workspace, prompt] - - -def _vibe_command(prompt: str, workspace: str) -> list[str]: - return ["vibe", "-p", prompt, "--output", "json", "--workdir", workspace] - - -def _omc_command(cli: str, prompt: str) -> list[str]: - if cli not in {"claude", "codex"}: - raise RuntimeError(f"OMC backend only supports claude/codex in v1, not {cli}") - return ["omc", "ask", cli, "--print", prompt] - - -def build_local_command(step: dict[str, Any], workspace: str) -> list[str]: - cli = step["cli"] - prompt = step["prompt"] - backend = step["backend"] - - if backend == "omc": - return _omc_command(cli, prompt) - if cli == "claude": - return _claude_command(prompt) - if cli == "codex": - return _codex_command(prompt, workspace) - if cli == "vibe": - return _vibe_command(prompt, workspace) - raise RuntimeError(f"Unsupported auto CLI: {cli}") - - -def execute_step(step: dict[str, Any], mission: dict[str, Any], fleet: dict[str, Any]) -> dict[str, Any]: - workspace = mission["workspace"] - machine = fleet_machine(step["machine"]) - backend = step["backend"] - - if step["cli"] == "copilot": - return { - "argv": [], - "returncode": 1, - "stdout": "", - "stderr": "copilot is manual-only in Constant v1", - "duration_s": 0.0, - } - - if backend in {"zellij", "cockpit"}: - cockpit = str(scripts_dir() / "constant-fleet.sh") - return { - "argv": [cockpit, "--workspace", workspace], - "returncode": 0, - "stdout": f"Open cockpit manually with: {cockpit} --workspace {workspace}", - "stderr": "", - "duration_s": 0.0, - } - - command = build_local_command(step, workspace) - - if backend == "cli-local" or backend == "omc": - return _run_command(command, cwd=workspace) - - if backend == "cli-ssh": - quoted = shlex.join(command) - remote_shell = f'{_local_agent_path_export()} cd {shlex.quote(workspace)} && {quoted}' - return _run_command(["ssh", machine["target"], "bash", "-lc", remote_shell]) - - raise RuntimeError(f"Unsupported backend: {backend}") - - -def fleet_check() -> dict[str, Any]: - check_script = repo_root() / "scripts" / "constant-fleet-install.sh" - process = subprocess.run([str(check_script), "check"], capture_output=True, text=True) - - machines: list[dict[str, Any]] = [] - current: dict[str, Any] | None = None - - for raw_line in process.stdout.splitlines(): - line = raw_line.strip() - if not line: - continue - if line.startswith("===") and "(" in line: - if current: - machines.append(current) - label = line.split()[1] - current = {"label": label} - continue - if "=" in line and current is not None: - key, value = line.split("=", 1) - current[key] = value - - if current: - machines.append(current) - - return { - "returncode": process.returncode, - "machines": machines, - "stderr": process.stderr, - } - - -def bridge_sync() -> dict[str, Any]: - bridge_script = repo_root() / "scripts" / "ai-bridge.sh" - process = subprocess.run([str(bridge_script), "sync"], capture_output=True, text=True) - return { - "returncode": process.returncode, - "stdout": process.stdout, - "stderr": process.stderr, - } diff --git a/constant/memory.py b/constant/memory.py deleted file mode 100644 index bbeba20..0000000 --- a/constant/memory.py +++ /dev/null @@ -1,1199 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import math -import os -import re -import sqlite3 -import urllib.error -import urllib.request -from collections import Counter -from pathlib import Path -from typing import Any - -from .paths import memory_store_path, persona_path -from .state import ( - append_event, - load_memory_config, - load_mission, - now_utc, - save_memory_config, -) - - -TEXT_SUFFIXES = { - ".c", - ".cc", - ".cfg", - ".conf", - ".cpp", - ".css", - ".go", - ".h", - ".hpp", - ".html", - ".java", - ".js", - ".json", - ".jsx", - ".md", - ".prompt", - ".py", - ".rb", - ".rs", - ".sh", - ".sql", - ".swift", - ".toml", - ".ts", - ".tsx", - ".txt", - ".yaml", - ".yml", - ".zsh", -} - -IGNORED_DIR_NAMES = { - ".git", - ".hg", - ".idea", - ".next", - ".venv", - "__pycache__", - "build", - "cache", - "dist", - "ide", - "logs", - "node_modules", - "output", - "session-state", - "target", - "tmp", - "venv", -} - -INSTRUCTION_DIRS = (".claude", ".copilot", ".agents", ".agent") -INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md") -INSTRUCTION_SUFFIXES = {".json", ".md", ".prompt", ".toml", ".txt", ".yaml", ".yml"} -MAX_TEXT_BYTES = 256 * 1024 -MAX_INSTRUCTION_BYTES = 128 * 1024 -SQLITE_TIMEOUT_SECONDS = 10.0 -SQLITE_BUSY_TIMEOUT_MS = 10_000 - - -def _enable_wal(connection: sqlite3.Connection) -> None: - try: - row = connection.execute("pragma journal_mode").fetchone() - current_mode = str(row[0]).lower() if row else "" - if current_mode == "wal": - return - connection.execute("pragma journal_mode = wal") - except sqlite3.OperationalError: - # Another process may already hold the database. Keep the connection usable - # instead of failing the whole command or TUI refresh. - return - - -def _connect() -> sqlite3.Connection: - path = memory_store_path() - path.parent.mkdir(parents=True, exist_ok=True) - connection = sqlite3.connect(path, timeout=SQLITE_TIMEOUT_SECONDS) - connection.row_factory = sqlite3.Row - connection.execute(f"pragma busy_timeout = {SQLITE_BUSY_TIMEOUT_MS}") - connection.execute("pragma foreign_keys = on") - _enable_wal(connection) - _ensure_schema(connection) - return connection - - -def _ensure_schema(connection: sqlite3.Connection) -> None: - connection.executescript( - """ - create table if not exists workspaces ( - path text primary key, - repo_root text not null, - enrolled_at text not null, - last_indexed_at text, - last_summary_at text - ); - - create table if not exists documents ( - id integer primary key, - workspace text not null, - path text not null unique, - rel_path text not null, - kind text not null, - language text, - source_type text not null, - content_hash text not null, - mtime real not null, - size integer not null, - weight real not null default 0, - updated_at text not null - ); - - create table if not exists chunks ( - id integer primary key, - document_id integer not null references documents(id) on delete cascade, - workspace text not null, - path text not null, - chunk_index integer not null, - content text not null, - content_hash text not null, - token_count integer not null, - updated_at text not null, - unique(document_id, chunk_index) - ); - - create virtual table if not exists chunk_fts using fts5( - chunk_id unindexed, - workspace, - path, - content - ); - - create table if not exists embeddings ( - chunk_id integer primary key references chunks(id) on delete cascade, - dims integer not null, - vector_json text not null - ); - - create table if not exists instruction_sources ( - id integer primary key, - workspace text, - path text not null unique, - scope text not null, - source_kind text not null, - weight real not null, - content text not null, - content_hash text not null, - updated_at text not null - ); - - create virtual table if not exists instruction_fts using fts5( - source_id unindexed, - path, - content - ); - - create table if not exists persona_facts ( - id integer primary key, - fact text not null unique, - weight real not null, - source_path text, - updated_at text not null - ); - - create table if not exists mission_summaries ( - mission_id text primary key, - workspace text not null, - title text not null, - status text not null, - summary text not null, - keywords_json text not null, - updated_at text not null - ); - - create virtual table if not exists mission_fts using fts5( - mission_id unindexed, - workspace, - title, - summary - ); - - create table if not exists decisions ( - decision_id text primary key, - mission_id text not null, - step_id text, - workspace text not null, - title text not null, - summary text not null, - status text not null, - weight real not null, - updated_at text not null - ); - - create virtual table if not exists decision_fts using fts5( - decision_id unindexed, - workspace, - title, - summary - ); - - create table if not exists decision_edges ( - id integer primary key, - from_decision_id text not null, - to_decision_id text not null, - edge_type text not null, - created_at text not null, - unique(from_decision_id, to_decision_id, edge_type) - ); - - create table if not exists entities ( - id integer primary key, - name text not null, - entity_type text not null, - source text, - updated_at text not null, - unique(name, entity_type) - ); - """ - ) - connection.commit() - - -def _normalize_workspace(path: str | Path) -> Path: - return Path(path).expanduser().resolve() - - -def _detect_repo_root(workspace: Path) -> Path: - for candidate in [workspace, *workspace.parents]: - if (candidate / ".git").exists(): - return candidate - return workspace - - -def _sanitize_fts_query(query: str) -> str: - raw_tokens = re.findall(r"[A-Za-z0-9_]+", query.lower()) - tokens = [token for token in raw_tokens if len(token) >= 2][:12] - if not tokens: - return "\"constant\"" - return " ".join(f"\"{token}\"" for token in tokens) - - -def _tokenize(text: str) -> list[str]: - return re.findall(r"[A-Za-z0-9_./:-]{2,}", text.lower()) - - -def _sha256_text(text: str) -> str: - return hashlib.sha256(text.encode("utf-8")).hexdigest() - - -def _embed_text(text: str, dims: int) -> list[float]: - vector = [0.0] * dims - if dims <= 0: - return vector - for token, count in Counter(_tokenize(text)).items(): - digest = hashlib.sha256(token.encode("utf-8")).digest() - index = int.from_bytes(digest[:4], "big") % dims - sign = 1.0 if digest[4] % 2 == 0 else -1.0 - vector[index] += sign * (1.0 + math.log1p(count)) - norm = math.sqrt(sum(value * value for value in vector)) - if norm == 0: - return vector - return [round(value / norm, 6) for value in vector] - - -def _cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float: - if not vec_a or not vec_b or len(vec_a) != len(vec_b): - return 0.0 - return sum(a * b for a, b in zip(vec_a, vec_b)) - - -def _relative_path(path: Path, root: Path) -> str: - try: - return str(path.relative_to(root)) - except ValueError: - return str(path) - - -def _language_for(path: Path) -> str: - suffix = path.suffix.lower() - return { - ".py": "python", - ".sh": "shell", - ".zsh": "shell", - ".js": "javascript", - ".jsx": "javascript", - ".ts": "typescript", - ".tsx": "typescript", - ".json": "json", - ".yaml": "yaml", - ".yml": "yaml", - ".md": "markdown", - ".toml": "toml", - ".rs": "rust", - ".go": "go", - ".swift": "swift", - ".html": "html", - ".css": "css", - }.get(suffix, suffix.lstrip(".") or "text") - - -def _is_probably_text(path: Path, max_bytes: int) -> bool: - if not path.is_file(): - return False - try: - if path.stat().st_size > max_bytes: - return False - with path.open("rb") as handle: - sample = handle.read(2048) - return b"\x00" not in sample - except OSError: - return False - - -def _read_text(path: Path, max_bytes: int) -> str | None: - if not _is_probably_text(path, max_bytes): - return None - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - try: - return path.read_text(encoding="utf-8", errors="ignore") - except OSError: - return None - except OSError: - return None - - -def _should_index_repo_file(path: Path) -> bool: - if path.name.startswith(".") and path.name not in {".gitignore", ".env.example"}: - return path.name in INSTRUCTION_FILES - if path.suffix.lower() in TEXT_SUFFIXES: - return True - return path.name in {"Dockerfile", "Makefile", "justfile"} - - -def _walk_repo_files(workspace: Path) -> list[Path]: - files: list[Path] = [] - for root, dirnames, filenames in os.walk(workspace): - dirnames[:] = sorted(name for name in dirnames if name not in IGNORED_DIR_NAMES) - for filename in sorted(filenames): - path = Path(root) / filename - if _should_index_repo_file(path): - files.append(path) - return files - - -def _iter_instruction_candidates(base: Path) -> list[Path]: - candidates: list[Path] = [] - for name in INSTRUCTION_FILES: - candidate = base / name - if candidate.exists(): - candidates.append(candidate) - - for dirname in INSTRUCTION_DIRS: - directory = base / dirname - if not directory.exists() or not directory.is_dir(): - continue - for root, dirnames, filenames in os.walk(directory): - dirnames[:] = sorted(name for name in dirnames if name not in IGNORED_DIR_NAMES) - depth = len(Path(root).relative_to(directory).parts) - if depth > 3: - dirnames[:] = [] - continue - for filename in sorted(filenames): - path = Path(root) / filename - if path.suffix.lower() in INSTRUCTION_SUFFIXES or filename in {"config.json", "settings.json"}: - candidates.append(path) - return candidates - - -def _instruction_scope(path: Path, workspace: Path, repo_root: Path, config: dict[str, Any]) -> tuple[str, float]: - weights = config["instruction_weights"] - home = Path.home().resolve() - if path == workspace or workspace in path.parents: - return "workspace", float(weights["workspace"]) - if path == repo_root or repo_root in path.parents: - return "repo", float(weights["repo"]) - if home == path.parent or home in path.parents: - return "user", float(weights["user"]) - return "ancestor", float(weights["ancestor"]) - - -def _discover_instruction_files(workspace: Path) -> list[dict[str, Any]]: - config = load_memory_config() - repo_root = _detect_repo_root(workspace) - seen: set[str] = set() - entries: list[dict[str, Any]] = [] - bases = [workspace, repo_root, *repo_root.parents] - home = Path.home().resolve() - if home not in bases: - bases.append(home) - - for base in bases: - if not str(base).startswith(str(home)) and base != workspace and base != repo_root: - continue - for candidate in _iter_instruction_candidates(base): - key = str(candidate.resolve()) - if key in seen: - continue - seen.add(key) - content = _read_text(candidate, MAX_INSTRUCTION_BYTES) - if not content or not content.strip(): - continue - scope, weight = _instruction_scope(candidate.resolve(), workspace, repo_root, config) - entries.append( - { - "path": candidate.resolve(), - "scope": scope, - "weight": weight, - "kind": candidate.parent.name if candidate.parent != candidate.parent.parent else candidate.name, - "content": content, - } - ) - return sorted(entries, key=lambda item: (-item["weight"], str(item["path"]))) - - -def _chunk_text(text: str, target_chars: int = 1200, overlap_lines: int = 3) -> list[str]: - lines = text.splitlines() - if not lines: - return [] - - chunks: list[str] = [] - start = 0 - while start < len(lines): - total = 0 - end = start - while end < len(lines) and total < target_chars: - total += len(lines[end]) + 1 - end += 1 - chunk = "\n".join(lines[start:end]).strip() - if chunk: - chunks.append(chunk) - if end >= len(lines): - break - start = max(end - overlap_lines, start + 1) - return chunks - - -def _upsert_workspace(connection: sqlite3.Connection, workspace: Path) -> None: - repo_root = _detect_repo_root(workspace) - connection.execute( - """ - insert into workspaces(path, repo_root, enrolled_at, last_indexed_at, last_summary_at) - values (?, ?, ?, null, null) - on conflict(path) do update set repo_root=excluded.repo_root - """, - (str(workspace), str(repo_root), now_utc()), - ) - - -def _prune_missing_documents(connection: sqlite3.Connection, workspace: Path, keep_paths: set[str]) -> int: - removed = 0 - rows = connection.execute("select id, path from documents where workspace = ?", (str(workspace),)).fetchall() - for row in rows: - if row["path"] not in keep_paths: - connection.execute("delete from chunk_fts where chunk_id in (select id from chunks where document_id = ?)", (row["id"],)) - connection.execute("delete from documents where id = ?", (row["id"],)) - removed += 1 - return removed - - -def _index_repo_documents(connection: sqlite3.Connection, workspace: Path, dims: int) -> dict[str, int]: - indexed = 0 - skipped = 0 - chunks_written = 0 - files = _walk_repo_files(workspace) - keep_paths = {str(path.resolve()) for path in files} - pruned = _prune_missing_documents(connection, workspace, keep_paths) - - for path in files: - resolved = path.resolve() - stat = resolved.stat() - existing = connection.execute( - "select id, mtime, size from documents where path = ?", - (str(resolved),), - ).fetchone() - if existing and float(existing["mtime"]) == stat.st_mtime and int(existing["size"]) == stat.st_size: - skipped += 1 - continue - - content = _read_text(resolved, MAX_TEXT_BYTES) - if not content or not content.strip(): - skipped += 1 - continue - - digest = _sha256_text(content) - if existing: - connection.execute("delete from chunk_fts where chunk_id in (select id from chunks where document_id = ?)", (existing["id"],)) - connection.execute("delete from documents where id = ?", (existing["id"],)) - - cursor = connection.execute( - """ - insert into documents(workspace, path, rel_path, kind, language, source_type, content_hash, mtime, size, weight, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - str(workspace), - str(resolved), - _relative_path(resolved, workspace), - "repo", - _language_for(resolved), - "repo", - digest, - stat.st_mtime, - stat.st_size, - 1.0, - now_utc(), - ), - ) - document_id = int(cursor.lastrowid) - for chunk_index, chunk in enumerate(_chunk_text(content), start=1): - chunk_hash = _sha256_text(chunk) - chunk_cursor = connection.execute( - """ - insert into chunks(document_id, workspace, path, chunk_index, content, content_hash, token_count, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - document_id, - str(workspace), - str(resolved), - chunk_index, - chunk, - chunk_hash, - len(_tokenize(chunk)), - now_utc(), - ), - ) - chunk_id = int(chunk_cursor.lastrowid) - connection.execute( - "insert into chunk_fts(chunk_id, workspace, path, content) values (?, ?, ?, ?)", - (chunk_id, str(workspace), str(resolved), chunk), - ) - connection.execute( - "insert into embeddings(chunk_id, dims, vector_json) values (?, ?, ?)", - (chunk_id, dims, json.dumps(_embed_text(chunk, dims))), - ) - chunks_written += 1 - indexed += 1 - - connection.execute( - "update workspaces set last_indexed_at = ? where path = ?", - (now_utc(), str(workspace)), - ) - return {"indexed": indexed, "skipped": skipped, "pruned": pruned, "chunks": chunks_written} - - -def _extract_persona_facts(text: str) -> list[str]: - facts: list[str] = [] - for raw_line in text.splitlines(): - line = raw_line.strip() - if not line: - continue - if line.startswith(("#", "```", "{", "}", "[", "]")): - continue - if len(line) < 12 or len(line) > 220: - continue - cleaned = re.sub(r"^[*-]\s*", "", line) - if cleaned and cleaned not in facts: - facts.append(cleaned) - if len(facts) >= 24: - break - return facts - - -def _refresh_instruction_sources(connection: sqlite3.Connection, workspace: Path) -> dict[str, int]: - discovered = _discover_instruction_files(workspace) - keep_paths = {str(entry["path"]) for entry in discovered} - current = connection.execute("select path from instruction_sources").fetchall() - removed = 0 - for row in current: - if row["path"] not in keep_paths: - connection.execute("delete from instruction_fts where path = ?", (row["path"],)) - connection.execute("delete from instruction_sources where path = ?", (row["path"],)) - removed += 1 - - refreshed = 0 - facts = 0 - for entry in discovered: - path = entry["path"] - content = entry["content"] - digest = _sha256_text(content) - existing = connection.execute( - "select content_hash from instruction_sources where path = ?", - (str(path),), - ).fetchone() - if existing and existing["content_hash"] == digest: - continue - connection.execute("delete from instruction_fts where path = ?", (str(path),)) - connection.execute( - """ - insert into instruction_sources(workspace, path, scope, source_kind, weight, content, content_hash, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?) - on conflict(path) do update set - workspace=excluded.workspace, - scope=excluded.scope, - source_kind=excluded.source_kind, - weight=excluded.weight, - content=excluded.content, - content_hash=excluded.content_hash, - updated_at=excluded.updated_at - """, - ( - str(workspace), - str(path), - entry["scope"], - entry["kind"], - float(entry["weight"]), - content, - digest, - now_utc(), - ), - ) - connection.execute( - "insert into instruction_fts(source_id, path, content) values ((select id from instruction_sources where path = ?), ?, ?)", - (str(path), str(path), content), - ) - for fact in _extract_persona_facts(content): - connection.execute( - """ - insert into persona_facts(fact, weight, source_path, updated_at) - values (?, ?, ?, ?) - on conflict(fact) do update set - weight=max(weight, excluded.weight), - source_path=excluded.source_path, - updated_at=excluded.updated_at - """, - (fact, float(entry["weight"]), str(path), now_utc()), - ) - facts += 1 - refreshed += 1 - - return {"sources": len(discovered), "refreshed": refreshed, "removed": removed, "persona_facts": facts} - - -def _render_persona(connection: sqlite3.Connection) -> str: - rows = connection.execute( - "select fact, weight, source_path from persona_facts order by weight desc, fact asc limit 32" - ).fetchall() - mission_rows = connection.execute( - "select mission_id, title, summary from mission_summaries order by updated_at desc limit 6" - ).fetchall() - decision_rows = connection.execute( - "select decision_id, title, summary from decisions order by updated_at desc limit 8" - ).fetchall() - - lines = [ - "# Constant Persona", - "", - "## Durable Rules", - ] - if rows: - for row in rows: - lines.append(f"- {row['fact']} ({Path(row['source_path']).name})") - else: - lines.append("- No durable rules extracted yet.") - - lines.extend(["", "## Recent Mission Summaries"]) - if mission_rows: - for row in mission_rows: - lines.append(f"- `{row['mission_id']}` {row['title']}: {row['summary']}") - else: - lines.append("- No mission summaries yet.") - - lines.extend(["", "## Decision Graph Snapshot"]) - if decision_rows: - for row in decision_rows: - lines.append(f"- `{row['decision_id']}` {row['title']}: {row['summary']}") - else: - lines.append("- No decisions captured yet.") - return "\n".join(lines).strip() + "\n" - - -def rebuild_workspace_memory(workspace: str | Path, enroll: bool = True) -> dict[str, Any]: - workspace_path = _normalize_workspace(workspace) - config = load_memory_config() - if enroll and str(workspace_path) not in config["workspace_enrollments"]: - config["workspace_enrollments"].append(str(workspace_path)) - config["workspace_enrollments"] = sorted(set(config["workspace_enrollments"])) - save_memory_config(config) - - connection = _connect() - try: - _upsert_workspace(connection, workspace_path) - dims = int(config.get("vector_dimensions", 96)) - repo_stats = _index_repo_documents(connection, workspace_path, dims) - instruction_stats = _refresh_instruction_sources(connection, workspace_path) - persona_markdown = _render_persona(connection) - persona_path().parent.mkdir(parents=True, exist_ok=True) - persona_path().write_text(persona_markdown, encoding="utf-8") - connection.commit() - return { - "workspace": str(workspace_path), - "repo_root": str(_detect_repo_root(workspace_path)), - "repo": repo_stats, - "instructions": instruction_stats, - "persona_path": str(persona_path()), - "store_path": str(memory_store_path()), - } - finally: - connection.close() - - -def prime_workspace_memory(workspace: str | Path, enroll: bool = True) -> dict[str, Any]: - workspace_path = _normalize_workspace(workspace) - config = load_memory_config() - if enroll and str(workspace_path) not in config["workspace_enrollments"]: - config["workspace_enrollments"].append(str(workspace_path)) - config["workspace_enrollments"] = sorted(set(config["workspace_enrollments"])) - save_memory_config(config) - - connection = _connect() - try: - _upsert_workspace(connection, workspace_path) - row = connection.execute( - """ - select count(*) as document_count, max(updated_at) as last_document_at - from documents - where workspace = ? - """, - (str(workspace_path),), - ).fetchone() - workspace_row = connection.execute( - "select repo_root, last_indexed_at from workspaces where path = ?", - (str(workspace_path),), - ).fetchone() - connection.commit() - return { - "workspace": str(workspace_path), - "repo_root": str(workspace_row["repo_root"]) if workspace_row else str(_detect_repo_root(workspace_path)), - "enrolled": str(workspace_path) in load_memory_config()["workspace_enrollments"], - "documents": int(row["document_count"] or 0) if row else 0, - "last_document_at": row["last_document_at"] if row else None, - "last_indexed_at": workspace_row["last_indexed_at"] if workspace_row else None, - "store_path": str(memory_store_path()), - "mode": "prime", - } - finally: - connection.close() - - -def memory_status(workspace: str | None = None) -> dict[str, Any]: - connection = _connect() - try: - payload = { - "store_path": str(memory_store_path()), - "persona_path": str(persona_path()), - "enrollments": load_memory_config()["workspace_enrollments"], - "counts": { - "workspaces": connection.execute("select count(*) from workspaces").fetchone()[0], - "documents": connection.execute("select count(*) from documents").fetchone()[0], - "chunks": connection.execute("select count(*) from chunks").fetchone()[0], - "instruction_sources": connection.execute("select count(*) from instruction_sources").fetchone()[0], - "persona_facts": connection.execute("select count(*) from persona_facts").fetchone()[0], - "mission_summaries": connection.execute("select count(*) from mission_summaries").fetchone()[0], - "decisions": connection.execute("select count(*) from decisions").fetchone()[0], - "decision_edges": connection.execute("select count(*) from decision_edges").fetchone()[0], - }, - } - if workspace: - payload["workspace"] = str(_normalize_workspace(workspace)) - payload["workspace_counts"] = { - "documents": connection.execute("select count(*) from documents where workspace = ?", (payload["workspace"],)).fetchone()[0], - "mission_summaries": connection.execute("select count(*) from mission_summaries where workspace = ?", (payload["workspace"],)).fetchone()[0], - "decisions": connection.execute("select count(*) from decisions where workspace = ?", (payload["workspace"],)).fetchone()[0], - } - return payload - finally: - connection.close() - - -def enroll_workspace(workspace: str | Path) -> dict[str, Any]: - workspace_path = str(_normalize_workspace(workspace)) - config = load_memory_config() - config["workspace_enrollments"] = sorted(set(config["workspace_enrollments"] + [workspace_path])) - save_memory_config(config) - return rebuild_workspace_memory(workspace_path, enroll=False) - - -def _snippet(text: str, limit: int = 220) -> str: - compact = " ".join(text.split()) - return compact[:limit] + ("..." if len(compact) > limit else "") - - -def search_memory(query: str, workspace: str | None = None, limit: int | None = None) -> dict[str, Any]: - config = load_memory_config() - max_hits = limit or int(config.get("max_chunks_per_query", 8)) - workspace_path = str(_normalize_workspace(workspace)) if workspace else None - connection = _connect() - try: - hits: list[dict[str, Any]] = [] - fts_query = _sanitize_fts_query(query) - params: list[Any] = [fts_query] - chunk_sql = ( - "select chunk_id, path, content, bm25(chunk_fts) as rank from chunk_fts where chunk_fts match ?" - ) - if workspace_path: - chunk_sql += " and workspace = ?" - params.append(workspace_path) - chunk_sql += " order by rank limit ?" - params.append(max_hits * 3) - try: - rows = connection.execute(chunk_sql, tuple(params)).fetchall() - except sqlite3.OperationalError: - rows = [] - for row in rows: - hits.append( - { - "kind": "repo", - "path": row["path"], - "score": round(2.0 - float(row["rank"]), 4), - "snippet": _snippet(row["content"]), - } - ) - - query_vector = _embed_text(query, int(config.get("vector_dimensions", 96))) - vector_rows = connection.execute( - "select chunks.path, chunks.content, embeddings.vector_json from chunks join embeddings on embeddings.chunk_id = chunks.id" - + (" where chunks.workspace = ?" if workspace_path else ""), - ((workspace_path,) if workspace_path else ()), - ).fetchall() - for row in vector_rows: - score = _cosine_similarity(query_vector, json.loads(row["vector_json"])) - if score <= 0: - continue - hits.append( - { - "kind": "repo-vector", - "path": row["path"], - "score": round(score, 4), - "snippet": _snippet(row["content"]), - } - ) - - source_rows = connection.execute( - "select path, scope, weight, content from instruction_sources order by weight desc" - ).fetchall() - for row in source_rows: - haystack = row["content"].lower() - if query.lower() in haystack or any(token in haystack for token in _tokenize(query)[:4]): - hits.append( - { - "kind": "instruction", - "path": row["path"], - "score": round(float(row["weight"]) + 1.0, 4), - "snippet": _snippet(row["content"]), - "scope": row["scope"], - } - ) - - summary_rows = connection.execute( - "select mission_id, title, summary from mission_summaries order by updated_at desc" - + (" limit 64" if not workspace_path else ""), - ).fetchall() - for row in summary_rows: - text = f"{row['title']} {row['summary']}".lower() - if query.lower() in text or any(token in text for token in _tokenize(query)[:4]): - hits.append( - { - "kind": "mission", - "path": row["mission_id"], - "score": 1.25, - "snippet": _snippet(row["summary"]), - } - ) - - decision_rows = connection.execute( - "select decision_id, title, summary, status from decisions order by updated_at desc limit 128" - ).fetchall() - for row in decision_rows: - text = f"{row['title']} {row['summary']}".lower() - if query.lower() in text or any(token in text for token in _tokenize(query)[:4]): - hits.append( - { - "kind": "decision", - "path": row["decision_id"], - "score": 1.1, - "snippet": _snippet(row["summary"]), - "status": row["status"], - } - ) - - ranked = sorted(hits, key=lambda item: item["score"], reverse=True) - deduped: list[dict[str, Any]] = [] - seen: set[tuple[str, str]] = set() - for hit in ranked: - key = (hit["kind"], hit["path"]) - if key in seen: - continue - seen.add(key) - deduped.append(hit) - if len(deduped) >= max_hits: - break - - return { - "query": query, - "workspace": workspace_path, - "hits": deduped, - } - finally: - connection.close() - - -def instruction_skill_sources(workspace: str | Path, query: str | None = None, limit: int = 6) -> list[dict[str, Any]]: - workspace_path = str(_normalize_workspace(workspace)) - connection = _connect() - try: - rows = connection.execute( - """ - select path, scope, source_kind, weight, content, updated_at - from instruction_sources - where workspace = ? or scope = 'user' - order by weight desc, updated_at desc - limit 64 - """, - (workspace_path,), - ).fetchall() - hits: list[dict[str, Any]] = [] - query_l = query.lower() if query else "" - query_tokens = _tokenize(query)[:6] if query else [] - for row in rows: - path = row["path"] - score = float(row["weight"]) - haystack = row["content"].lower() - if query: - matched = query_l in haystack or any(token in haystack for token in query_tokens) or any(token in path.lower() for token in query_tokens) - if not matched: - continue - score += 1.0 - hits.append( - { - "path": path, - "scope": row["scope"], - "source_kind": row["source_kind"], - "weight": score, - "snippet": _snippet(row["content"], 160), - } - ) - hits.sort(key=lambda item: item["weight"], reverse=True) - return hits[:limit] - finally: - connection.close() - - -def persona_markdown() -> str: - if persona_path().exists(): - return persona_path().read_text(encoding="utf-8") - rebuild_workspace_memory(Path.cwd(), enroll=False) - return persona_path().read_text(encoding="utf-8") if persona_path().exists() else "# Constant Persona\n" - - -def list_decisions(workspace: str | None = None, mission_id: str | None = None) -> dict[str, Any]: - connection = _connect() - try: - clauses = [] - params: list[Any] = [] - if workspace: - clauses.append("workspace = ?") - params.append(str(_normalize_workspace(workspace))) - if mission_id: - clauses.append("mission_id = ?") - params.append(mission_id) - sql = "select decision_id, mission_id, step_id, workspace, title, summary, status, weight, updated_at from decisions" - if clauses: - sql += " where " + " and ".join(clauses) - sql += " order by updated_at desc, decision_id asc" - rows = connection.execute(sql, tuple(params)).fetchall() - return {"decisions": [dict(row) for row in rows]} - finally: - connection.close() - - -def _mission_keywords(mission: dict[str, Any]) -> list[str]: - tokens = set(_tokenize(mission["title"] + " " + mission["goal"])) - for step in mission["steps"]: - tokens.update([step.get("machine", ""), step.get("cli", ""), step.get("backend", ""), step.get("status", "")]) - return sorted(token for token in tokens if token)[:24] - - -def summarize_mission(mission_id: str) -> dict[str, Any]: - mission = load_mission(mission_id) - workspace = str(_normalize_workspace(mission["workspace"])) - connection = _connect() - try: - _upsert_workspace(connection, Path(workspace)) - status_counts = Counter(step.get("status", "unknown") for step in mission["steps"]) - route_bits = [] - for step in mission["steps"]: - route_bits.append(f"{step['step_id']}={step['machine']}/{step['cli']}/{step['backend']}:{step['status']}") - summary = ( - f"Mission {mission['title']} ended as {mission['status']}. " - f"Steps={len(mission['steps'])}. " - f"Status mix={dict(status_counts)}. " - f"Routes: {'; '.join(route_bits[:6]) or 'none'}." - ) - keywords = _mission_keywords(mission) - connection.execute( - """ - insert into mission_summaries(mission_id, workspace, title, status, summary, keywords_json, updated_at) - values (?, ?, ?, ?, ?, ?, ?) - on conflict(mission_id) do update set - workspace=excluded.workspace, - title=excluded.title, - status=excluded.status, - summary=excluded.summary, - keywords_json=excluded.keywords_json, - updated_at=excluded.updated_at - """, - ( - mission_id, - workspace, - mission["title"], - mission["status"], - summary, - json.dumps(keywords), - now_utc(), - ), - ) - connection.execute( - "delete from mission_fts where mission_id = ?", - (mission_id,), - ) - connection.execute( - "insert into mission_fts(mission_id, workspace, title, summary) values (?, ?, ?, ?)", - (mission_id, workspace, mission["title"], summary), - ) - - previous_decision_id: str | None = None - decision_count = 0 - for step in mission["steps"]: - decision_id = f"{mission_id}:{step['step_id']}" - decision_summary = ( - f"Route {step['machine']}/{step['cli']}/{step['backend']} ended as {step['status']}. " - f"{step.get('result_summary', '')}".strip() - ) - connection.execute( - """ - insert into decisions(decision_id, mission_id, step_id, workspace, title, summary, status, weight, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?) - on conflict(decision_id) do update set - mission_id=excluded.mission_id, - step_id=excluded.step_id, - workspace=excluded.workspace, - title=excluded.title, - summary=excluded.summary, - status=excluded.status, - weight=excluded.weight, - updated_at=excluded.updated_at - """, - ( - decision_id, - mission_id, - step["step_id"], - workspace, - step["title"], - decision_summary, - step["status"], - 1.0 if step["status"] == "done" else 0.7, - now_utc(), - ), - ) - connection.execute("delete from decision_fts where decision_id = ?", (decision_id,)) - connection.execute( - "insert into decision_fts(decision_id, workspace, title, summary) values (?, ?, ?, ?)", - (decision_id, workspace, step["title"], decision_summary), - ) - if previous_decision_id: - connection.execute( - """ - insert or ignore into decision_edges(from_decision_id, to_decision_id, edge_type, created_at) - values (?, ?, ?, ?) - """, - (previous_decision_id, decision_id, "depends_on", now_utc()), - ) - previous_decision_id = decision_id - decision_count += 1 - - connection.execute( - "update workspaces set last_summary_at = ? where path = ?", - (now_utc(), workspace), - ) - persona_markdown_value = _render_persona(connection) - persona_path().parent.mkdir(parents=True, exist_ok=True) - persona_path().write_text(persona_markdown_value, encoding="utf-8") - connection.commit() - append_event(mission_id, "memory.summary_written", {"summary": summary, "decisions": decision_count}) - return { - "mission_id": mission_id, - "summary": summary, - "keywords": keywords, - "decisions": decision_count, - "persona_path": str(persona_path()), - } - finally: - connection.close() - - -def planner_context(workspace: str | Path, query: str) -> dict[str, Any]: - workspace_path = str(_normalize_workspace(workspace)) - rebuild_workspace_memory(workspace_path, enroll=True) - search = search_memory(query, workspace_path) - connection = _connect() - try: - instruction_rows = connection.execute( - """ - select path, scope, weight, content - from instruction_sources - where workspace = ? or scope = 'user' - order by weight desc, updated_at desc - limit 8 - """, - (workspace_path,), - ).fetchall() - decision_rows = connection.execute( - """ - select decision_id, title, summary, status - from decisions - where workspace = ? - order by updated_at desc - limit 6 - """, - (workspace_path,), - ).fetchall() - persona_rows = connection.execute( - "select fact from persona_facts order by weight desc, updated_at desc limit 12" - ).fetchall() - return { - "workspace": workspace_path, - "instruction_excerpt": [ - { - "path": row["path"], - "scope": row["scope"], - "weight": row["weight"], - "snippet": _snippet(row["content"], 180), - } - for row in instruction_rows - ], - "repo_hits": search["hits"], - "recent_decisions": [dict(row) for row in decision_rows], - "persona_facts": [row["fact"] for row in persona_rows], - } - finally: - connection.close() - - -def sync_qdrant(workspace: str | None = None) -> dict[str, Any]: - config = load_memory_config() - url = str(config.get("qdrant_url", "")).strip() - if not url: - return {"ok": False, "skipped": True, "reason": "qdrant_url is not configured"} - - search_payload = search_memory("*", workspace, limit=32) - collection = config.get("qdrant_collection", "constant_memory") - request_body = { - "points": [ - { - "id": abs(hash((hit["kind"], hit["path"]))) % 2_147_483_647, - "vector": _embed_text(hit["snippet"], int(config.get("vector_dimensions", 96))), - "payload": hit, - } - for hit in search_payload["hits"] - ] - } - req = urllib.request.Request( - url.rstrip("/") + f"/collections/{collection}/points?wait=true", - data=json.dumps(request_body).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="PUT", - ) - try: - with urllib.request.urlopen(req, timeout=5) as response: - payload = json.loads(response.read().decode("utf-8")) - return {"ok": True, "response": payload, "points": len(request_body["points"])} - except urllib.error.URLError as exc: - return {"ok": False, "skipped": False, "reason": str(exc), "points": len(request_body["points"])} diff --git a/constant/paths.py b/constant/paths.py deleted file mode 100644 index 87e36fc..0000000 --- a/constant/paths.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -def repo_root() -> Path: - return Path(__file__).resolve().parents[1] - - -def scripts_dir() -> Path: - return repo_root() / "scripts" - - -def cache_root() -> Path: - return Path.home() / ".cache" / "constant" - - -def config_root() -> Path: - return Path.home() / ".config" / "constant" - - -def data_root() -> Path: - return Path.home() / ".local" / "share" / "constant" - - -def planner_dir() -> Path: - return cache_root() / "planner" - - -def missions_dir() -> Path: - return cache_root() / "missions" - - -def chat_root() -> Path: - return cache_root() / "chat" - - -def fleet_config_path() -> Path: - return config_root() / "fleet.json" - - -def models_config_path() -> Path: - return config_root() / "models.json" - - -def memory_config_path() -> Path: - return config_root() / "memory.json" - - -def daemon_pid_path() -> Path: - return planner_dir() / "daemon.pid" - - -def daemon_log_path() -> Path: - return planner_dir() / "daemon.log" - - -def daemon_port_path() -> Path: - return planner_dir() / "daemon.port" - - -def memory_store_path() -> Path: - return data_root() / "memory.sqlite" - - -def persona_path() -> Path: - return data_root() / "persona.md" - - -def indexes_dir() -> Path: - return data_root() / "indexes" - - -def memory_sources_dir() -> Path: - return data_root() / "sources" diff --git a/constant/planner.py b/constant/planner.py deleted file mode 100644 index de3f81c..0000000 --- a/constant/planner.py +++ /dev/null @@ -1,739 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import re -import subprocess -import sys -import threading -from dataclasses import dataclass -from typing import Any - -from .capabilities import ( - agent_for_cli, - list_agents, - list_skills, - match_skill, - recommended_skill_stack, - resolve_skill_and_agent, - skill_by_id, - skill_catalog_brief, -) -from .memory import instruction_skill_sources, search_memory -from .state import load_fleet_config, load_models_config - -CHAT_ROLES = ("claude", "codex", "copilot", "vibe") - - -def _fleet_labels() -> dict[str, str]: - fleet = load_fleet_config() - labels = [machine["label"] for machine in fleet["machines"]] - local_machine = fleet.get("local_machine", labels[0] if labels else "command-center") - return { - "local": local_machine, - "builder_a": labels[1] if len(labels) > 1 else "builder-a", - "builder_b": labels[2] if len(labels) > 2 else "builder-b", - "edge_a": labels[3] if len(labels) > 3 else "edge-a", - "lab_a": labels[4] if len(labels) > 4 else "lab-a", - } - - -def _strip_code_fences(text: str) -> str: - stripped = text.strip() - if stripped.startswith("```"): - stripped = re.sub(r"^```[a-zA-Z0-9_-]*\n", "", stripped) - stripped = re.sub(r"\n```$", "", stripped) - return stripped.strip() - - -def _extract_json(text: str) -> dict[str, Any]: - stripped = _strip_code_fences(text) - try: - return json.loads(stripped) - except json.JSONDecodeError: - start = stripped.find("{") - end = stripped.rfind("}") - if start >= 0 and end > start: - return json.loads(stripped[start : end + 1]) - raise - - -def _lower(value: str) -> str: - return value.lower() - - -def _public_skill_ids() -> list[str]: - return [item["id"] for item in list_skills(include_internal=False)] - - -def _extract_explicit_skill(message: str) -> tuple[str, dict[str, Any] | None]: - raw = message.strip() - lowered = raw.lower() - - if lowered.startswith("skill:"): - payload = raw[len("skill:") :].strip() - if payload: - parts = payload.split(maxsplit=1) - try: - skill = skill_by_id(parts[0], include_internal=False) - except KeyError: - skill = None - if skill: - remainder = parts[1].strip() if len(parts) > 1 else "" - return remainder or raw, skill - - if lowered.startswith("/skill "): - payload = raw[len("/skill ") :].strip() - if payload: - parts = payload.split(maxsplit=1) - try: - skill = skill_by_id(parts[0], include_internal=False) - except KeyError: - skill = None - if skill: - remainder = parts[1].strip() if len(parts) > 1 else "" - return remainder or raw, skill - - if raw.startswith("/"): - parts = raw[1:].split(maxsplit=1) - if parts: - try: - skill = skill_by_id(parts[0], include_internal=False) - except KeyError: - skill = None - if skill: - remainder = parts[1].strip() if len(parts) > 1 else "" - return remainder or raw, skill - - for skill_id in _public_skill_ids(): - token = skill_id.lower() - if token in lowered: - try: - return raw, skill_by_id(skill_id, include_internal=False) - except KeyError: - continue - return raw, None - - -def _route_machine(goal: str, skill_id: str | None = None) -> str: - labels = _fleet_labels() - goal_l = _lower(goal) - if skill_id in {"spec-planner", "repo-onboarding", "task-decomposer"}: - return labels["local"] - if skill_id == "architecture-brainstorm": - return labels["lab_a"] - if skill_id == "pr-review-prep": - return labels["builder_a"] - if skill_id == "ops-deployment": - return labels["edge_a"] - if skill_id == "debug-restoration" and any(token in goal_l for token in ("performance", "deep", "benchmark", "compiler", "cuda")): - return labels["builder_b"] - if any(token in goal_l for token in ("ssh", "shell", "fleet", "ops", "network", "infra")): - return labels["edge_a"] - if any(token in goal_l for token in ("refactor", "performance", "deep", "cuda", "compiler", "benchmark")): - return labels["builder_b"] - if any(token in goal_l for token in ("review", "audit", "test", "qa", "docs")): - return labels["builder_a"] - if any(token in goal_l for token in ("experiment", "prototype", "sandbox", "branch")): - return labels["lab_a"] - return labels["local"] - - -def _route_cli(goal: str) -> str: - skill = match_skill(goal) - if skill.get("preferred_cli"): - return str(skill["preferred_cli"]) - goal_l = _lower(goal) - if any(token in goal_l for token in ("brainstorm", "idea", "alternative", "explore", "compare")): - return "vibe" - if any(token in goal_l for token in ("spec", "summary", "summarize", "review", "docs")): - return "claude" - return "codex" - - -def _route_backend(machine: str, cli: str, goal: str) -> str: - local_machine = _fleet_labels()["local"] - goal_l = _lower(goal) - if machine == local_machine and cli in {"claude", "codex"} and any(token in goal_l for token in ("parallel", "team", "multi-agent", "compare")): - return "omc" - if machine == local_machine: - return "cli-local" - return "cli-ssh" - - -def _route_agent(cli: str) -> str: - return agent_for_cli(cli)["id"] - - -def _heuristic_plan(goal: str, workspace: str, mission_id: str, overrides: dict[str, Any] | None = None) -> dict[str, Any]: - overrides = overrides or {} - resolved = resolve_skill_and_agent( - goal=goal, - skill_id=overrides.get("skill"), - agent_id=overrides.get("agent"), - cli=overrides.get("cli"), - ) - skill = resolved["skill"] - cli = str(resolved["cli"]) - agent = resolved["agent"] - machine = str(overrides.get("machine") or _route_machine(goal, str(skill["id"]))) - backend = str(overrides.get("backend") or _route_backend(machine, cli, goal)) - skill_sources = instruction_skill_sources(workspace, query=goal, limit=4) if workspace else [] - return { - "title": goal.strip().splitlines()[0][:80] or mission_id, - "summary": f"Route the mission to {machine} using {cli} via {backend} for skill {skill['id']}.", - "steps": [ - { - "step_id": "step-1", - "kind": "task", - "title": f"Execute mission on {machine}", - "prompt": goal, - "machine": machine, - "backend": backend, - "cli": cli, - "agent": agent["id"], - "agent_role": agent["role"], - "skill": skill["id"], - "skill_summary": skill["summary"], - "skill_sources": [item["path"] for item in skill_sources], - "status": "pending", - "attempt": 0, - "depends_on": [], - "llama_plan": "heuristic planner fallback", - "qwen_review": "", - "result_summary": "", - "artifact_refs": [], - } - ], - } - - -def _heuristic_buddy_review(goal: str, plan: dict[str, Any]) -> dict[str, Any]: - step = plan["steps"][0] - cli = step["cli"] - machine = step["machine"] - local_machine = _fleet_labels()["local"] - suggestions: list[str] = [] - agrees = True - - if cli == "copilot": - agrees = False - suggestions.append("copilot is manual-only in v1; use codex or claude instead") - if any(token in _lower(goal) for token in ("fix", "implement", "refactor", "bug")) and cli != "codex": - agrees = False - suggestions.append("technical execution is better routed to codex") - if machine == local_machine and step["backend"] == "cli-ssh": - agrees = False - suggestions.append("local machine should use cli-local or omc, not cli-ssh") - - summary = "Buddy review agrees with the plan." if agrees else "; ".join(suggestions) - return { - "agrees": agrees, - "summary": summary, - "suggested_cli": "codex" if any("codex" in entry for entry in suggestions) else None, - "suggested_backend": "cli-local" if machine == local_machine and step["backend"] == "cli-ssh" else None, - } - - -def _heuristic_verify(step: dict[str, Any], execution: dict[str, Any]) -> dict[str, Any]: - stdout = execution.get("stdout", "") - stderr = execution.get("stderr", "") - combined = f"{stdout}\n{stderr}".lower() - return_code = execution.get("returncode", 1) - - if any(token in combined for token in ("not logged in", "/login", "device-auth", "device auth", "authentication required", "please login", "please log in")): - return { - "decision": "needs_human", - "summary": "The CLI needs an interactive login or credentials refresh.", - "confidence": "high", - } - - if return_code != 0: - if step.get("attempt", 0) <= 1: - return { - "decision": "retry", - "summary": "Execution failed once; retry is reasonable.", - "confidence": "medium", - } - return { - "decision": "failed", - "summary": "Execution failed after retry budget was exhausted.", - "confidence": "high", - } - - if "error" in combined and "success" not in combined: - return { - "decision": "needs_human", - "summary": "Output contains an error marker despite zero exit status.", - "confidence": "medium", - } - - return { - "decision": "done", - "summary": "Execution completed successfully.", - "confidence": "medium", - } - - -def _heuristic_buddy_answer(prompt: str, mission: dict[str, Any] | None) -> dict[str, Any]: - title = mission["title"] if mission else "mission" - skill = match_skill(prompt) - return { - "answer": ( - f"Qwen buddy heuristic view for {title}: " - f"skill={skill['id']}, focus on route correctness, CLI fit, and whether the selected agent matches the workflow stage. " - f"Prompt: {prompt}" - ), - "mode": "heuristic", - } - - -def _match_machine_label(message: str) -> str | None: - message_l = _lower(message) - for machine in load_fleet_config()["machines"]: - label = machine["label"] - if label.lower() in message_l: - return label - return None - - -def _match_role(message: str) -> str | None: - message_l = _lower(message) - for role in CHAT_ROLES: - if role in message_l: - return role - return None - - -def _heuristic_chat( - message: str, - mission: dict[str, Any] | None, - workspace: str, - selected_machine: str | None, - selected_role: str | None, -) -> dict[str, Any]: - prompt, explicit_skill = _extract_explicit_skill(message) - prompt = prompt.strip() - prompt_l = _lower(prompt) - memory_hits = search_memory(prompt, workspace=workspace, limit=4).get("hits", []) if workspace else [] - skill_sources = instruction_skill_sources(workspace, query=prompt, limit=4) if workspace else [] - memory_lines = [f"{hit['kind']} {hit['path']} :: {hit['snippet']}" for hit in memory_hits[:3]] - buddy_note = None - cockpit_action: dict[str, Any] | None = None - intent = "plain_chat" - - target_machine = _match_machine_label(prompt) or selected_machine or _fleet_labels()["local"] - target_role = _match_role(prompt) or selected_role or "codex" - matched_skill = explicit_skill or (match_skill(prompt) if prompt else None) - - if any(token in prompt_l for token in ("open cockpit", "attach cockpit", "show cockpit")): - intent = "cockpit_open" - cockpit_action = {"type": "open"} - reply = "I can hand off to the full cockpit now." - elif any(token in prompt_l for token in ("restart", "relance", "respawn")): - intent = "cockpit_restart" - cockpit_action = {"type": "restart", "machine": target_machine, "pane": target_role} - reply = f"I'll restart {target_machine}:{target_role}." - elif any(token in prompt_l for token in ("capture", "log", "logs", "show pane", "see pane")): - intent = "cockpit_capture" - cockpit_action = {"type": "capture", "machine": target_machine, "pane": target_role} - reply = f"I'll capture {target_machine}:{target_role}." - elif any(token in prompt_l for token in ("focus", "jump", "go to", "ouvre", "open machine")): - intent = "cockpit_focus" - cockpit_action = {"type": "focus", "machine": target_machine, "pane": target_role} - reply = f"I'll focus {target_machine}:{target_role}." - elif any(token in prompt_l for token in ("memory", "remember", "decision", "persona", "what do we know", "qu'est-ce qu", "souviens")): - intent = "memory_lookup" - reply = "Memory lookup ready." - elif explicit_skill is None and prompt.endswith("?") and not any( - token in prompt_l for token in ("fix", "build", "implement", "write", "create", "deploy", "restart", "capture", "focus") - ): - intent = "plain_chat" - reply = "Here's the operator view." - else: - intent = "mission_create" - routing_overrides = { - "skill": matched_skill["id"] if matched_skill else None, - "agent": matched_skill["preferred_agent"] if matched_skill else None, - "cli": matched_skill["preferred_cli"] if matched_skill else None, - } - preview = _heuristic_plan(prompt, workspace, "chat-preview", routing_overrides) - review = _heuristic_buddy_review(prompt, preview) - step = preview["steps"][0] - buddy_note = { - "answer": review["summary"], - "mode": "heuristic", - } - matched_skill_id = preview["steps"][0]["skill"] - reply = ( - f"I turned that into a mission. Route preview: " - f"{step['machine']}/{step['cli']}/{step['backend']} " - f"skill={matched_skill_id} agent={step['agent']}." - ) - - if intent != "mission_create" and any( - token in prompt_l for token in ("route", "reroute", "which machine", "which cli", "codex", "claude", "vibe", "copilot") - ): - buddy_note = _heuristic_buddy_answer(prompt, mission) - - if intent == "memory_lookup": - if memory_lines: - reply = "Memory echoes:\n- " + "\n- ".join(memory_lines[:3]) - else: - reply = "No strong memory hits for that query yet." - elif intent == "plain_chat": - title = mission["title"] if mission else "global cockpit" - route_hint = f" selected={selected_machine or '-'}:{selected_role or '-'}" - reply = f"Constant view for {title}.{route_hint}" - if memory_lines: - reply += "\nMemory echoes:\n- " + "\n- ".join(memory_lines[:2]) - - return { - "intent": intent, - "reply": reply, - "message": prompt, - "mode": "heuristic", - "cockpit_action": cockpit_action, - "buddy_note": buddy_note, - "memory_hits": memory_hits, - "skill_sources": skill_sources, - "workspace": workspace, - "mission_goal": prompt if intent == "mission_create" else None, - "skill": matched_skill, - "routing_overrides": { - "skill": matched_skill["id"] if matched_skill else None, - "agent": matched_skill["preferred_agent"] if matched_skill else None, - "cli": matched_skill["preferred_cli"] if matched_skill else None, - } - if intent == "mission_create" and matched_skill - else {}, - } - - -def _budget_chat_history(chat_history: list[dict[str, Any]] | None, limit: int = 8) -> list[dict[str, Any]]: - if not chat_history: - return [] - important = [ - entry for entry in chat_history - if entry.get("intent") in {"mission_create", "cockpit_error", "buddy_answer", "memory_lookup"} - ] - recent = list(chat_history[-limit:]) - merged: list[dict[str, Any]] = [] - seen: set[tuple[str, str]] = set() - for entry in [*important[-3:], *recent]: - key = (str(entry.get("timestamp", "")), str(entry.get("content", ""))) - if key in seen: - continue - seen.add(key) - merged.append(entry) - return merged[-limit:] - - -@dataclass -class ModelHealth: - role: str - model_id: str - available: bool - loaded: bool - backend: str - - -class PlannerEngine: - def __init__(self) -> None: - self._models = load_models_config() - self._loaded: dict[str, tuple[Any, Any]] = {} - self._lock = threading.Lock() - self._mlx_probe = self._probe_mlx() - self._mlx_python = self._mlx_probe["available"] - - def _probe_mlx(self) -> dict[str, Any]: - enable_setting = self._models.get("enable_mlx", "auto") - requested = str(enable_setting).lower() not in {"0", "false", "off", "no"} - package_present = importlib.util.find_spec("mlx_lm") is not None - if not requested: - return {"requested": False, "package_present": package_present, "available": False, "reason": "disabled"} - if not package_present: - return {"requested": True, "package_present": False, "available": False, "reason": "mlx_lm not installed"} - probe = subprocess.run( - [sys.executable, "-c", "import mlx.core as mx; print(mx.default_device())"], - capture_output=True, - text=True, - ) - return { - "requested": True, - "package_present": True, - "available": probe.returncode == 0, - "reason": probe.stderr.strip() or ("ok" if probe.returncode == 0 else "mlx probe failed"), - "stdout": probe.stdout.strip(), - "returncode": probe.returncode, - } - - def health(self) -> dict[str, Any]: - health = {} - for role in ("planner", "buddy", "verify"): - spec = self._models[role] - health[role] = ModelHealth( - role=role, - model_id=spec["model_id"], - available=self._mlx_python, - loaded=role in self._loaded, - backend="mlx-python" if self._mlx_python else "heuristic", - ).__dict__ - return { - "mlx_python": self._mlx_python, - "mlx_probe": self._mlx_probe, - "models": health, - "fallback_mode": self._models.get("fallback_mode", "heuristic"), - "agents": list_agents(), - "skills": list_skills(), - "recommended_skill_stack": recommended_skill_stack(), - } - - def _load_model(self, role: str) -> tuple[Any, Any]: - if role in self._loaded: - return self._loaded[role] - - if not self._mlx_python: - raise RuntimeError("mlx_lm is not available") - - with self._lock: - if role in self._loaded: - return self._loaded[role] - from mlx_lm import load # type: ignore - - model_id = self._models[role]["model_id"] - model, tokenizer = load(model_id) - self._loaded[role] = (model, tokenizer) - return model, tokenizer - - def _call_model_json(self, role: str, system_prompt: str, user_prompt: str) -> dict[str, Any]: - if not self._mlx_python: - raise RuntimeError("mlx_lm is not available") - - model, tokenizer = self._load_model(role) - from mlx_lm import generate # type: ignore - - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) - text = generate( - model, - tokenizer, - prompt=prompt, - max_tokens=self._models[role]["max_tokens"], - verbose=False, - ) - return _extract_json(text) - - def _call_model_text(self, role: str, system_prompt: str, user_prompt: str) -> str: - if not self._mlx_python: - raise RuntimeError("mlx_lm is not available") - - model, tokenizer = self._load_model(role) - from mlx_lm import generate # type: ignore - - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) - return generate( - model, - tokenizer, - prompt=prompt, - max_tokens=self._models[role]["max_tokens"], - verbose=False, - ).strip() - - def plan_mission(self, mission: dict[str, Any]) -> dict[str, Any]: - overrides = mission.get("routing_overrides") or {} - plan = _heuristic_plan(mission["goal"], mission["workspace"], mission["mission_id"], overrides) - review = _heuristic_buddy_review(mission["goal"], plan) - fleet = load_fleet_config() - machine_labels = [machine["label"] for machine in fleet["machines"]] - local_machine = fleet.get("local_machine", machine_labels[0] if machine_labels else "command-center") - - if self._mlx_python: - system = ( - "You are Llama, the main orchestrator for a 5-machine coding fleet. " - "Return strict JSON with keys: title, summary, steps. " - "Each step must include: step_id, kind, title, prompt, machine, backend, cli, agent, depends_on." - ) - user = json.dumps( - { - "mission_id": mission["mission_id"], - "goal": mission["goal"], - "workspace": mission["workspace"], - "routing_overrides": overrides, - "machines": machine_labels, - "allowed_backends": ["omc", "cli-local", "cli-ssh", "cockpit"], - "allowed_clis": ["claude", "codex", "vibe"], - "skill_catalog": skill_catalog_brief(include_internal=True), - }, - indent=2, - ) - try: - llama_plan = self._call_model_json("planner", system, user) - if "steps" in llama_plan and llama_plan["steps"]: - plan = { - "title": llama_plan.get("title", plan["title"]), - "summary": llama_plan.get("summary", plan["summary"]), - "steps": [], - } - for index, step in enumerate(llama_plan["steps"], start=1): - resolved = resolve_skill_and_agent( - goal=step.get("prompt", mission["goal"]), - skill_id=step.get("skill") or overrides.get("skill"), - agent_id=step.get("agent") or overrides.get("agent"), - cli=step.get("cli") or overrides.get("cli"), - ) - resolved_skill = resolved["skill"] - resolved_agent = resolved["agent"] - resolved_cli = str(resolved["cli"]) - resolved_machine = step.get("machine") or overrides.get("machine") or _route_machine(step.get("prompt", mission["goal"]), resolved_skill["id"]) - plan["steps"].append( - { - "step_id": step.get("step_id", f"step-{index}"), - "kind": step.get("kind", "task"), - "title": step.get("title", f"Step {index}"), - "prompt": step.get("prompt", mission["goal"]), - "machine": resolved_machine, - "backend": step.get("backend", _route_backend(resolved_machine, resolved_cli, mission["goal"])), - "cli": resolved_cli, - "agent": resolved_agent["id"], - "agent_role": resolved_agent.get("role"), - "skill": resolved_skill["id"], - "skill_summary": step.get("skill_summary", resolved_skill["summary"]), - "skill_sources": step.get("skill_sources", [item["path"] for item in instruction_skill_sources(mission["workspace"], query=step.get("prompt", mission["goal"]), limit=4)]), - "status": "pending", - "attempt": 0, - "depends_on": step.get("depends_on", []), - "llama_plan": llama_plan.get("summary", ""), - "qwen_review": "", - "result_summary": "", - "artifact_refs": [], - } - ) - except Exception: - pass - - buddy_system = ( - "You are Qwen, the local technical buddy. Return strict JSON with keys: " - "agrees, summary, suggested_cli, suggested_backend." - ) - buddy_user = json.dumps({"goal": mission["goal"], "plan": plan}, indent=2) - try: - review = self._call_model_json("buddy", buddy_system, buddy_user) - except Exception: - pass - - for step in plan["steps"]: - step["qwen_review"] = review.get("summary", "") - if not review.get("agrees", True): - if review.get("suggested_cli") and step["cli"] == "claude": - step["cli"] = review["suggested_cli"] - agent = agent_for_cli(step["cli"]) - step["agent"] = agent["id"] - step["agent_role"] = agent["role"] - if review.get("suggested_backend"): - step["backend"] = review["suggested_backend"] - - return { - "plan": plan, - "buddy_review": review, - } - - def verify_step(self, mission: dict[str, Any], step: dict[str, Any], execution: dict[str, Any]) -> dict[str, Any]: - result = _heuristic_verify(step, execution) - - if self._mlx_python: - system = ( - "You are the orchestrator verifier. Return strict JSON with keys: " - "decision, summary, confidence. Decisions: done, retry, failed, needs_human." - ) - user = json.dumps( - { - "mission_title": mission["title"], - "step": step, - "execution": { - "returncode": execution.get("returncode"), - "stdout_tail": execution.get("stdout", "")[-4000:], - "stderr_tail": execution.get("stderr", "")[-4000:], - }, - }, - indent=2, - ) - try: - result = self._call_model_json("verify", system, user) - except Exception: - pass - - return result - - def buddy_ask(self, mission: dict[str, Any] | None, prompt: str) -> dict[str, Any]: - result = _heuristic_buddy_answer(prompt, mission) - - if self._mlx_python: - system = "You are Qwen, the local buddy. Give a concise technical answer." - user = json.dumps({"mission": mission, "prompt": prompt}, indent=2) - try: - result = { - "answer": self._call_model_text("buddy", system, user), - "mode": "mlx", - } - except Exception: - pass - - return result - - def chat( - self, - message: str, - mission: dict[str, Any] | None, - workspace: str, - selected_machine: str | None, - selected_role: str | None, - chat_history: list[dict[str, Any]] | None = None, - ) -> dict[str, Any]: - result = _heuristic_chat(message, mission, workspace, selected_machine, selected_role) - - if self._mlx_python: - system = ( - "You are Constant, the main cockpit operator for a multi-machine coding fleet. " - "Return strict JSON with keys: intent, reply. " - "Valid intents: mission_create, cockpit_focus, cockpit_capture, cockpit_restart, cockpit_open, " - "buddy_answer, memory_lookup, plain_chat." - ) - user = json.dumps( - { - "message": message, - "workspace": workspace, - "mission": mission, - "selected_machine": selected_machine, - "selected_role": selected_role, - "chat_history": _budget_chat_history(chat_history), - "memory_hits": result.get("memory_hits", [])[:4], - "buddy_note": result.get("buddy_note"), - "skills": list_skills(), - "skill_catalog": skill_catalog_brief(include_internal=True), - "recommended_skill_stack": recommended_skill_stack(), - "agents": list_agents(), - }, - indent=2, - ) - try: - model_result = self._call_model_json("planner", system, user) - result["intent"] = str(model_result.get("intent", result["intent"])) - result["reply"] = str(model_result.get("reply", result["reply"])) - result["mode"] = "mlx" - except Exception: - pass - - if result["intent"] in {"plain_chat", "memory_lookup"} and result.get("buddy_note") is None: - prompt_l = _lower(message) - if any(token in prompt_l for token in ("route", "reroute", "machine", "cli", "pane", "codex", "claude", "vibe", "copilot")): - result["buddy_note"] = self.buddy_ask(mission, message) - - return result diff --git a/constant/state.py b/constant/state.py deleted file mode 100644 index ebf5229..0000000 --- a/constant/state.py +++ /dev/null @@ -1,329 +0,0 @@ -from __future__ import annotations - -import json -import hashlib -import uuid -from copy import deepcopy -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -from . import __version__ -from .paths import ( - cache_root, - chat_root, - config_root, - data_root, - fleet_config_path, - indexes_dir, - memory_config_path, - memory_sources_dir, - missions_dir, - models_config_path, -) - - -DEFAULT_FLEET: dict[str, Any] = { - "version": 1, - "local_machine": "command-center", - "repo_dir": "$HOME/constant", - "machines": [ - { - "label": "command-center", - "target": "local", - "auto_clis": ["codex", "vibe", "claude"], - "manual_clis": ["copilot"], - "backends": ["omc", "cli-local", "cockpit"], - }, - { - "label": "builder-a", - "target": "dev@builder-a", - "auto_clis": ["codex", "vibe", "claude"], - "manual_clis": ["copilot"], - "backends": ["cli-ssh", "cockpit"], - }, - { - "label": "builder-b", - "target": "dev@builder-b", - "auto_clis": ["codex", "vibe", "claude"], - "manual_clis": ["copilot"], - "backends": ["cli-ssh", "cockpit"], - }, - { - "label": "edge-a", - "target": "dev@edge-a", - "auto_clis": ["codex", "vibe", "claude"], - "manual_clis": ["copilot"], - "backends": ["cli-ssh", "cockpit"], - }, - { - "label": "lab-a", - "target": "dev@lab-a", - "auto_clis": ["codex", "vibe", "claude"], - "manual_clis": ["copilot"], - "backends": ["cli-ssh", "cockpit"], - }, - ], -} - - -DEFAULT_MODELS: dict[str, Any] = { - "version": 1, - "enable_mlx": "auto", - "planner": { - "role": "planner", - "model_id": "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit", - "max_tokens": 900, - }, - "buddy": { - "role": "buddy", - "model_id": "mlx-community/Qwen2.5-Coder-3B-Instruct-4bit", - "max_tokens": 900, - }, - "verify": { - "role": "verify", - "model_id": "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit", - "max_tokens": 700, - }, - "fallback_mode": "heuristic", -} - - -DEFAULT_MEMORY: dict[str, Any] = { - "version": 1, - "local_store_path": str(data_root() / "memory.sqlite"), - "qdrant_url": "", - "qdrant_collection": "constant_memory", - "workspace_enrollments": [], - "instruction_weights": { - "workspace": 1.0, - "repo": 0.85, - "ancestor": 0.65, - "user": 0.45, - "default": 0.2, - }, - "max_chunks_per_query": 8, - "vector_dimensions": 96, -} - - -def now_utc() -> str: - return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -def ensure_runtime_dirs() -> None: - for path in (cache_root(), chat_root(), config_root(), data_root(), missions_dir(), indexes_dir(), memory_sources_dir()): - path.mkdir(parents=True, exist_ok=True) - - -def _legacy_config_path(path: Path) -> Path: - if path.suffix == ".json": - return path.with_suffix(".yaml") - return path - - -def _read_json_yaml(path: Path, default: dict[str, Any]) -> dict[str, Any]: - ensure_runtime_dirs() - legacy_path = _legacy_config_path(path) - - if not path.exists() and legacy_path != path and legacy_path.exists(): - return json.loads(legacy_path.read_text(encoding="utf-8")) - - if not path.exists(): - _write_json_yaml(path, default) - return deepcopy(default) - - return json.loads(path.read_text(encoding="utf-8")) - - -def _write_json_yaml(path: Path, payload: dict[str, Any]) -> None: - ensure_runtime_dirs() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def load_fleet_config() -> dict[str, Any]: - payload = _read_json_yaml(fleet_config_path(), DEFAULT_FLEET) - for machine in payload.get("machines", []): - backends = [] - for backend in machine.get("backends", []): - backends.append("cockpit" if backend == "zellij" else backend) - machine["backends"] = backends - return payload - - -def load_models_config() -> dict[str, Any]: - payload = _read_json_yaml(models_config_path(), DEFAULT_MODELS) - merged = deepcopy(DEFAULT_MODELS) - for key, value in payload.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key].update(value) - else: - merged[key] = value - return merged - - -def load_memory_config() -> dict[str, Any]: - return _read_json_yaml(memory_config_path(), DEFAULT_MEMORY) - - -def save_memory_config(payload: dict[str, Any]) -> None: - _write_json_yaml(memory_config_path(), payload) - - -def fleet_machine(label: str) -> dict[str, Any]: - fleet = load_fleet_config() - for machine in fleet["machines"]: - if machine["label"] == label or machine["target"] == label: - return machine - raise KeyError(f"Unknown machine: {label}") - - -def mission_dir(mission_id: str) -> Path: - return missions_dir() / mission_id - - -def mission_file(mission_id: str) -> Path: - return mission_dir(mission_id) / "mission.json" - - -def mission_events_file(mission_id: str) -> Path: - return mission_dir(mission_id) / "events.ndjson" - - -def mission_artifacts_dir(mission_id: str) -> Path: - return mission_dir(mission_id) / "artifacts" - - -def _workspace_chat_slug(workspace: str) -> str: - normalized = str(Path(workspace).expanduser().resolve()) - name = Path(normalized).name or "workspace" - digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:10] - return f"{name}-{digest}" - - -def chat_file(workspace: str, mission_id: str | None = None) -> Path: - ensure_runtime_dirs() - if mission_id: - return chat_root() / "missions" / f"{mission_id}.ndjson" - return chat_root() / "workspaces" / f"{_workspace_chat_slug(workspace)}.ndjson" - - -def read_chat_history(workspace: str, mission_id: str | None = None, limit: int = 80) -> list[dict[str, Any]]: - path = chat_file(workspace, mission_id=mission_id) - if not path.exists(): - return [] - - entries: list[dict[str, Any]] = [] - for raw in path.read_text(encoding="utf-8").splitlines()[-limit:]: - try: - entries.append(json.loads(raw)) - except json.JSONDecodeError: - continue - return entries - - -def append_chat_message( - role: str, - content: str, - *, - workspace: str, - mission_id: str | None = None, - intent: str | None = None, - machine: str | None = None, - pane: str | None = None, - meta: dict[str, Any] | None = None, -) -> dict[str, Any]: - path = chat_file(workspace, mission_id=mission_id) - path.parent.mkdir(parents=True, exist_ok=True) - entry = { - "timestamp": now_utc(), - "role": role, - "content": content, - "intent": intent or "plain_chat", - "workspace": str(Path(workspace).expanduser().resolve()), - "mission_id": mission_id, - "machine": machine, - "pane": pane, - "meta": meta or {}, - } - with path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(entry, sort_keys=True) + "\n") - return entry - - -def create_mission(goal: str, workspace: str, routing_overrides: dict[str, Any] | None = None) -> dict[str, Any]: - mission_id = uuid.uuid4().hex[:12] - mission = { - "mission_id": mission_id, - "title": goal.strip().splitlines()[0][:80] or f"mission-{mission_id}", - "goal": goal, - "workspace": workspace, - "status": "draft", - "priority": "normal", - "created_at": now_utc(), - "updated_at": now_utc(), - "planner_model": load_models_config()["planner"]["model_id"], - "buddy_model": load_models_config()["buddy"]["model_id"], - "verify_model": load_models_config()["verify"]["model_id"], - "owner": "Constant", - "routing_overrides": routing_overrides or {}, - "steps": [], - "artifacts": [], - "meta": { - "schema_version": 1, - "tool_version": __version__, - }, - } - save_mission(mission) - append_event(mission_id, "mission.created", {"goal": goal, "workspace": workspace}) - return mission - - -def save_mission(mission: dict[str, Any]) -> None: - path = mission_file(mission["mission_id"]) - path.parent.mkdir(parents=True, exist_ok=True) - mission["updated_at"] = now_utc() - path.write_text(json.dumps(mission, indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def load_mission(mission_id: str) -> dict[str, Any]: - return json.loads(mission_file(mission_id).read_text(encoding="utf-8")) - - -def list_missions() -> list[dict[str, Any]]: - ensure_runtime_dirs() - missions: list[dict[str, Any]] = [] - for path in sorted(missions_dir().glob("*/mission.json")): - missions.append(json.loads(path.read_text(encoding="utf-8"))) - return missions - - -def append_event(mission_id: str, event_type: str, payload: dict[str, Any]) -> None: - path = mission_events_file(mission_id) - path.parent.mkdir(parents=True, exist_ok=True) - event = { - "timestamp": now_utc(), - "type": event_type, - "payload": payload, - } - with path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(event, sort_keys=True) + "\n") - - -def write_artifact(mission_id: str, name: str, payload: dict[str, Any]) -> str: - artifact_dir = mission_artifacts_dir(mission_id) - artifact_dir.mkdir(parents=True, exist_ok=True) - path = artifact_dir / name - path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - mission = load_mission(mission_id) - mission["artifacts"].append(str(path)) - save_mission(mission) - return str(path) - - -def first_active_step(mission: dict[str, Any]) -> dict[str, Any] | None: - for step in mission["steps"]: - if step["status"] not in {"done", "failed", "needs_human"}: - return step - return None diff --git a/constant/tui.py b/constant/tui.py deleted file mode 100644 index 81aaeab..0000000 --- a/constant/tui.py +++ /dev/null @@ -1,1725 +0,0 @@ -from __future__ import annotations - -import curses -import json -import textwrap -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from .capabilities import list_skills -from .cockpit import ROLES, capture_pane, focus_machine, restart_pane, runtime_status -from .daemon import request as daemon_request -from .memory import list_decisions, memory_status, persona_markdown, prime_workspace_memory, rebuild_workspace_memory, summarize_mission -from .state import ( - append_chat_message, - append_event, - create_mission, - list_missions, - load_fleet_config, - mission_events_file, - read_chat_history, - save_mission, -) - - -CHAT_LABELS = { - "user": "YOU", - "constant": "CONSTANT", - "buddy": "BUDDY", - "system": "SYSTEM", -} - -HEXAPUS_FRAMES = ( - [ - " .-====-.", - " .-' .--. `-.", - " / ( oo ) \\", - " | \\/\\/ |", - " | .-____-. |", - " \\__/_/ || \\_\\__/", - " /_ /||\\ _\\", - " .-' \\/ || \\/ `-.", - " <_____/ || \\_____>", - " /___/\\___\\", - ], - [ - " .-====-.", - " .-' .--. `-.", - " / ( oo ) \\", - " | \\/\\/ |", - " | .-____-. |", - " _/\\__/ / || \\ \\__/\\_", - " /_ _\\/ || \\/_ _\\", - " `-. / || \\ .-'", - " <_____/ || \\_____>", - " /___/\\___\\", - ], - [ - " .-====-.", - " .-' .--. `-.", - " / ( -- ) \\", - " | \\/\\/ |", - " | .-____-. |", - " \\__/_/ || \\_\\__/", - " /_ /||\\ _\\", - " .--' \\/ || \\/ `--.", - " <_____/ || \\_____>", - " /___/\\___\\", - ], - [ - " .-====-.", - " .-' .--. `-.", - " / ( oo ) \\", - " | \\/\\/ |", - " | .-____-. |", - " _/\\__/_/ || \\_\\__ /\\_", - " /_ _/ /||\\ \\_ _\\", - " `-. / || \\ .-'", - " <_____/ || \\_____>", - " /___/\\___\\", - ], - [ - " .-====-.", - " .-' .--. `-.", - " / ( xx ) \\", - " | \\/\\/ |", - " | .-____-. |", - " \\__/__/||\\__\\__/", - " _\\_ /||\\ _/_", - " .--' \\/ || \\/ `--.", - " <_____/ || \\_____>", - " /___/\\___\\", - ], -) - - -@dataclass -class TuiLayout: - height: int - width: int - top: int - footer_height: int - timeline_height: int - main_height: int - left_width: int - right_width: int - center_width: int - conversation_height: int - runtime_height: int - - -@dataclass -class TuiState: - selected_index: int = 0 - selected_machine: int = 0 - selected_role_index: int = 1 - flash: str = "" - flash_until: float = 0.0 - runtime_cache: dict[str, Any] | None = None - runtime_refresh_at: float = 0.0 - input_mode: bool = True - input_buffer: str = "" - slash_menu_index: int = 0 - capture_state: dict[str, Any] | None = None - pending_select_mission_id: str | None = None - chat_focus: bool = True - stream_text: str = "" - stream_started_at: float = 0.0 - stream_duration: float = 0.0 - stream_mission_id: str | None = None - - @property - def selected_role(self) -> str: - return ROLES[self.selected_role_index] - - def set_flash(self, message: str, duration: float = 2.5) -> None: - self.flash = message - self.flash_until = time.time() + duration - - -def _error_line(prefix: str, exc: Exception) -> str: - return f"{prefix}: {str(exc).strip() or exc.__class__.__name__}" - - -def _safe_memory_status(workspace: str) -> dict[str, Any]: - try: - return memory_status(workspace) - except Exception as exc: # noqa: BLE001 - return { - "workspace": workspace, - "counts": {}, - "error": _error_line("memory", exc), - } - - -def _safe_persona_lines() -> list[str]: - try: - return [ - line[2:] - for line in persona_markdown().splitlines() - if line.startswith("- ") - ] - except Exception as exc: # noqa: BLE001 - return [_error_line("persona", exc)] - - -def _safe_decision_lines(workspace: str) -> list[str]: - try: - decisions = list_decisions(workspace=workspace).get("decisions", [])[:3] - return [f"{item['decision_id'].split(':')[-1]} {item['status']} {item['title']}" for item in decisions] - except Exception as exc: # noqa: BLE001 - return [_error_line("decisions", exc)] - - -def _safe_runtime_status(local_session: str, machine_session: str) -> dict[str, Any]: - try: - return runtime_status(local_session=local_session, machine_session=machine_session) - except Exception as exc: # noqa: BLE001 - return { - "fleet_session_exists": False, - "machines": [], - "fleet_windows": [], - "fleet_stderr": _error_line("cockpit", exc), - "focused_machine": None, - "focused_role": None, - } - - -def _clip(text: str, width: int) -> str: - if width <= 0: - return "" - if len(text) <= width: - return text - if width <= 3: - return text[:width] - return text[: width - 3] + "..." - - -def _safe_addstr(stdscr: Any, y: int, x: int, text: str, attr: int = 0) -> None: - height, width = stdscr.getmaxyx() - if y < 0 or y >= height or x >= width: - return - text = _clip(text, width - x) - if not text: - return - try: - stdscr.addstr(y, x, text, attr) - except curses.error: - pass - - -def _draw_box(stdscr: Any, y: int, x: int, height: int, width: int, title: str, title_attr: int = 0) -> None: - if height < 3 or width < 4: - return - _safe_addstr(stdscr, y, x, "+" + ("-" * max(0, width - 2)) + "+") - for row in range(y + 1, y + height - 1): - _safe_addstr(stdscr, row, x, "|" + (" " * max(0, width - 2)) + "|") - _safe_addstr(stdscr, y + height - 1, x, "+" + ("-" * max(0, width - 2)) + "+") - _safe_addstr(stdscr, y, x + 2, f"[ {title} ]", title_attr) - - -def _write_wrapped(stdscr: Any, y: int, x: int, width: int, lines: list[str], attr: int = 0) -> int: - row = y - for line in lines: - wrapped = textwrap.wrap(line, max(8, width)) or [""] - for item in wrapped: - _safe_addstr(stdscr, row, x, item, attr) - row += 1 - return row - - -def _recent_events(mission_id: str | None, limit: int = 8) -> list[str]: - if mission_id: - path = mission_events_file(mission_id) - if not path.exists(): - return [] - lines = path.read_text(encoding="utf-8").splitlines()[-limit:] - events = [] - for raw in lines: - try: - event = json.loads(raw) - except json.JSONDecodeError: - continue - event_type = event.get("type", "event") - timestamp = str(event.get("timestamp", ""))[-9:-1] - payload = event.get("payload", {}) - detail = payload.get("summary") or payload.get("step_id") or payload.get("machine") or payload.get("mission_id") or "" - line = f"{timestamp} {event_type}" - if detail: - line += f" :: {detail}" - events.append(line) - return events[-limit:] - - events: list[str] = [] - for mission in sorted(list_missions(), key=lambda item: item.get("updated_at", ""), reverse=True)[:5]: - events.extend(_recent_events(mission["mission_id"], limit=3)) - return events[-limit:] - - -def _normalize_buddy(review: dict[str, Any] | None) -> dict[str, str]: - if not review: - return { - "verdict": "idle", - "confidence": "--", - "why": "No buddy review yet.", - "change": "none", - "memory": "idle", - } - - if "verdict" in review: - change = review.get("change") or {} - target = "/".join( - part for part in [change.get("machine"), change.get("cli"), change.get("backend")] if part - ) or "none" - memory = review.get("memory", {}) - memory_hint = "store" if memory.get("store") else "ignore" - return { - "verdict": str(review.get("verdict", "warn")), - "confidence": str(review.get("confidence", "medium")), - "why": str(review.get("why", review.get("summary", ""))) or "No reason provided.", - "change": target, - "memory": memory_hint, - } - - agrees = bool(review.get("agrees", True)) - verdict = "agree" if agrees else ("reroute" if review.get("suggested_cli") or review.get("suggested_backend") else "warn") - change = "/".join(part for part in [review.get("suggested_cli"), review.get("suggested_backend")] if part) or "none" - return { - "verdict": verdict, - "confidence": "medium" if agrees else "high", - "why": str(review.get("summary", "No review summary.")), - "change": change, - "memory": "consider", - } - - -def _hexapus_lines(verdict: str, tick: int) -> list[str]: - if verdict in {"block", "failed"}: - return HEXAPUS_FRAMES[4] - if verdict == "reroute": - return HEXAPUS_FRAMES[tick % 4] - if verdict == "warn": - return HEXAPUS_FRAMES[(tick // 2) % 4] - return HEXAPUS_FRAMES[(tick // 3) % 2] - - -def _init_colors() -> dict[str, int]: - palette = { - "base": 0, - "accent": 0, - "muted": 0, - "warn": 0, - "good": 0, - "bad": 0, - "hot": 0, - "user": 0, - "system": 0, - "buddy": 0, - } - if not curses.has_colors(): - return palette - curses.start_color() - try: - curses.use_default_colors() - except curses.error: - pass - curses.init_pair(1, curses.COLOR_CYAN, -1) - curses.init_pair(2, curses.COLOR_BLUE, -1) - curses.init_pair(3, curses.COLOR_YELLOW, -1) - curses.init_pair(4, curses.COLOR_GREEN, -1) - curses.init_pair(5, curses.COLOR_RED, -1) - curses.init_pair(6, curses.COLOR_MAGENTA, -1) - curses.init_pair(7, curses.COLOR_WHITE, -1) - palette = { - "base": curses.color_pair(1), - "accent": curses.color_pair(6) | curses.A_BOLD, - "muted": curses.color_pair(2), - "warn": curses.color_pair(3) | curses.A_BOLD, - "good": curses.color_pair(4) | curses.A_BOLD, - "bad": curses.color_pair(5) | curses.A_BOLD, - "hot": curses.color_pair(6) | curses.A_BOLD, - "user": curses.color_pair(7) | curses.A_BOLD, - "system": curses.color_pair(3), - "buddy": curses.color_pair(4), - } - return palette - - -def _verdict_attr(review: dict[str, str], colors: dict[str, int]) -> int: - verdict = review["verdict"] - if verdict in {"agree", "done"}: - return colors["good"] - if verdict in {"reroute", "warn"}: - return colors["warn"] - if verdict in {"block", "failed"}: - return colors["bad"] - return colors["accent"] - - -def _status_tag(status: str) -> str: - return { - "done": "DONE", - "planned": "PLAN", - "running": "RUN ", - "failed": "FAIL", - "needs_human": "HELP", - "pending": "PEND", - "draft": "DRAF", - }.get(status, status[:4].upper()) - - -def _role_state(machine: dict[str, Any], role: str) -> str: - pane = machine.get("roles", {}).get(role) - if not pane: - return ".." - if pane.get("dead"): - return "xx" - if pane.get("active"): - return ">>" - return "ok" - - -def _render_role_token(state: str, selected: bool, focused: bool) -> str: - if selected and focused: - return f"*{state}*" - if selected: - return f"<{state}>" - if focused: - return f"{{{state}}}" - return f"[{state}]" - - -def _message_attr(role: str, colors: dict[str, int]) -> int: - if role == "user": - return colors["user"] - if role == "system": - return colors["system"] - if role == "buddy": - return colors["buddy"] - if role == "constant": - return colors["accent"] - return colors["base"] - - -def _thread_preview(workspace: str, mission_id: str | None = None) -> str: - history = read_chat_history(workspace=workspace, mission_id=mission_id, limit=1) - if not history: - return "Start a conversation" - last = history[-1] - content = str(last.get("content", "")).strip() or "(empty)" - role = CHAT_LABELS.get(str(last.get("role", "system")), str(last.get("role", "system")).upper()) - return f"{role}: {content}" - - -def _streamed_text(state: TuiState, mission_id: str | None, text: str) -> str: - if not state.stream_text or state.stream_text != text: - return text - if state.stream_mission_id != mission_id: - return text - elapsed = max(0.0, time.time() - state.stream_started_at) - if elapsed >= state.stream_duration: - return text - total = max(1, len(text)) - progress = max(1, min(total, int(total * (elapsed / max(0.1, state.stream_duration))))) - return text[:progress] - - -def _public_skill_menu() -> list[dict[str, Any]]: - return list_skills(include_internal=False) - - -def _skill_menu_candidates(input_buffer: str) -> list[dict[str, Any]]: - if not input_buffer.startswith("/"): - return [] - payload = input_buffer[1:] - if not payload: - return _public_skill_menu() - if payload.startswith("skill "): - query = payload[len("skill ") :].strip().lower() - if " " in query: - return [] - else: - stripped = payload.strip().lower() - if " " in stripped: - return [] - query = stripped - skills = _public_skill_menu() - if not query: - return skills - ranked: list[tuple[int, dict[str, Any]]] = [] - for skill in skills: - score = 0 - skill_id = str(skill["id"]).lower() - label = str(skill["label"]).lower() - summary = str(skill["summary"]).lower() - aliases = " ".join(str(alias).lower() for alias in skill.get("aliases", [])) - if skill_id.startswith(query): - score += 5 - if query in skill_id: - score += 3 - if query in label: - score += 2 - if query in aliases: - score += 2 - if query in summary: - score += 1 - if score > 0: - ranked.append((score, skill)) - ranked.sort(key=lambda item: (-item[0], item[1]["id"])) - return [item[1] for item in ranked] or skills - - -def _slash_menu_pending(input_buffer: str) -> bool: - return input_buffer.startswith("/") and bool(_skill_menu_candidates(input_buffer)) - - -def _complete_slash_skill(input_buffer: str, selection: dict[str, Any]) -> str: - if input_buffer.startswith("/skill "): - return f"/skill {selection['id']} " - return f"/{selection['id']} " - - -def _draw_skill_menu( - stdscr: Any, - y: int, - x: int, - width: int, - candidates: list[dict[str, Any]], - selected_index: int, - colors: dict[str, int], -) -> None: - if not candidates or width < 28: - return - visible = candidates[: min(5, len(candidates))] - height = len(visible) + 3 - _draw_box(stdscr, y, x, height, width, "Skill Menu", colors["accent"]) - _safe_addstr(stdscr, y + 1, x + 2, "Tab/↑↓ browse | Enter complete", colors["muted"]) - row = y + 2 - selected = max(0, min(selected_index, len(visible) - 1)) - for index, skill in enumerate(visible): - marker = ">" if index == selected else " " - skill_id = str(skill["id"]) - summary = str(skill["summary"]) - line = f"{marker} /{skill_id:<24} {summary}" - attr = colors["accent"] if index == selected else colors["base"] - _safe_addstr(stdscr, row, x + 1, _clip(line, width - 3), attr) - row += 1 - - -def _draw_header( - stdscr: Any, - workspace: str, - mission_count: int, - fleet_count: int, - runtime: dict[str, Any], - memory: dict[str, Any], - health: dict[str, Any], - colors: dict[str, int], - chat_scope_label: str, - selected_machine_label: str | None, - selected_role: str, -) -> None: - memory_counts = memory.get("counts", {}) - planner_backend = health.get("models", {}).get("planner", {}).get("backend", "heuristic") - buddy_backend = health.get("models", {}).get("buddy", {}).get("backend", "heuristic") - cockpit_state = "up" if runtime.get("fleet_session_exists") else "down" - focused = f"{runtime.get('focused_machine') or '-'}:{runtime.get('focused_role') or '-'}" - selected = f"{selected_machine_label or '-'}:{selected_role}" - _safe_addstr(stdscr, 0, 2, "Constant :: chat-first fleet cockpit", colors["accent"]) - _safe_addstr( - stdscr, - 1, - 2, - _clip( - f"workspace={workspace} scope={chat_scope_label} cockpit={cockpit_state} fleet={fleet_count} threads={mission_count}", - max(20, stdscr.getmaxyx()[1] - 4), - ), - colors["base"], - ) - _safe_addstr( - stdscr, - 2, - 2, - _clip( - f"planner={planner_backend} buddy={buddy_backend} selected={selected} focused={focused} docs={memory_counts.get('documents', 0)} decisions={memory_counts.get('decisions', 0)}", - max(20, stdscr.getmaxyx()[1] - 4), - ), - colors["muted"], - ) - memory_error = memory.get("error") - if memory_error: - _safe_addstr( - stdscr, - 3, - 2, - _clip(memory_error, max(20, stdscr.getmaxyx()[1] - 4)), - colors["bad"], - ) - - -def _draw_missions(stdscr: Any, y: int, x: int, height: int, width: int, missions: list[dict[str, Any]], selected: int, colors: dict[str, int]) -> None: - _draw_box(stdscr, y, x, height, width, "Threads", colors["accent"]) - row = y + 1 - if not missions: - _safe_addstr(stdscr, row, x + 2, "No missions yet. Start typing.", colors["muted"]) - return - for index, mission in enumerate(missions[: max(1, height - 2)]): - marker = ">" if index == selected else " " - status = _status_tag(mission.get("status", "unknown")) - line = f"{marker} {status} {mission['mission_id'][:6]} {mission['title']}" - attr = colors["accent"] if index == selected else colors["base"] - _safe_addstr(stdscr, row, x + 1, _clip(line, width - 3), attr) - row += 1 - if row >= y + height - 1: - break - - -def _draw_threads_focus( - stdscr: Any, - y: int, - x: int, - height: int, - width: int, - workspace: str, - missions: list[dict[str, Any]], - selected: int, - colors: dict[str, int], -) -> None: - _safe_addstr(stdscr, y, x + 1, "Chats", colors["accent"]) - row = y + 2 - _safe_addstr(stdscr, row, x + 1, _clip("global", width - 2), colors["hot"]) - row += 1 - _safe_addstr(stdscr, row, x + 2, _clip(_thread_preview(workspace), width - 3), colors["muted"]) - row += 2 - if not missions: - _safe_addstr(stdscr, row, x + 1, "No mission threads yet.", colors["muted"]) - return - for index, mission in enumerate(missions[: max(1, height - 6)]): - marker = "›" if index == selected else " " - title = f"{marker} {mission['title']}" - attr = colors["accent"] if index == selected else colors["base"] - _safe_addstr(stdscr, row, x + 1, _clip(title, width - 2), attr) - row += 1 - _safe_addstr( - stdscr, - row, - x + 2, - _clip(_thread_preview(mission.get("workspace", workspace), mission["mission_id"]), width - 3), - colors["muted"], - ) - row += 2 - if row >= y + height - 1: - break - - -def _draw_conversation( - stdscr: Any, - y: int, - x: int, - height: int, - width: int, - entries: list[dict[str, Any]], - mission: dict[str, Any] | None, - state: TuiState, - colors: dict[str, int], - focus_mode: bool = False, -) -> None: - if mission and mission.get("steps"): - step = next((entry for entry in mission["steps"] if entry.get("status") not in {"done", "failed"}), mission["steps"][0]) - scope = f"{mission['title']} :: {step.get('skill', '-')} / {step.get('agent', '-')}" - compact_scope = f"{mission['title']} · {step.get('skill', '-')} / {step.get('agent', '-')}" - else: - scope = f"mission {mission['mission_id'][:6]}" if mission else "workspace" - compact_scope = scope - if focus_mode: - _safe_addstr(stdscr, y, x + 1, "Constant", colors["accent"]) - _safe_addstr(stdscr, y + 1, x + 1, _clip(compact_scope, width - 2), colors["muted"]) - row = y + 3 - else: - _draw_box(stdscr, y, x, height, width, f"Constant :: {scope}", colors["accent"]) - row = y + 1 - if not entries: - hints = [ - "No conversation yet.", - "", - "Type directly to talk to Constant.", - "Use / for workflow skills.", - "Actionable prompts create missions immediately.", - ] - _write_wrapped(stdscr, row, x + 2, width - 4, hints, colors["muted"]) - return - - rendered: list[tuple[str, int]] = [] - mission_id = mission.get("mission_id") if mission else None - for entry in entries: - role = str(entry.get("role", "system")) - label = CHAT_LABELS.get(role, role.upper()) - timestamp = str(entry.get("timestamp", ""))[-9:-1] - meta = entry.get("meta") or {} - skill_tag = str(meta.get("skill", "")).strip() - badge = f"[{skill_tag}] " if skill_tag else "" - prefix = f"{timestamp} {label:<8}> {badge}" if not focus_mode else f"{label.lower():>8} " - content = str(entry.get("content", "")).strip() or "(empty)" - if role == "constant": - content = _streamed_text(state, mission_id, content) - wrapped = textwrap.wrap(content, max(10, width - 4 - len(prefix)), subsequent_indent=" " * len(prefix)) or [content] - rendered.append((prefix + wrapped[0], _message_attr(role, colors))) - for line in wrapped[1:]: - rendered.append((" " * len(prefix) + line.lstrip(), _message_attr(role, colors))) - if meta.get("created_mission_id"): - rendered.append((f"{' ' * len(prefix)}-> mission {meta['created_mission_id']}", colors["system"])) - route_bits = [str(meta.get("agent", "")).strip(), str(meta.get("cli", "")).strip()] - route = " / ".join(part for part in route_bits if part) - if route: - rendered.append((f"{' ' * len(prefix)}-> route {route}", colors["muted"])) - - visible = rendered[-max(1, height - 2):] - for line, attr in visible: - if row >= y + height - 1: - break - _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), attr) - row += 1 - - -def _draw_runtime( - stdscr: Any, - y: int, - x: int, - height: int, - width: int, - runtime: dict[str, Any], - mission: dict[str, Any] | None, - selected_machine: int, - selected_role: str, - colors: dict[str, int], -) -> None: - _draw_box(stdscr, y, x, height, width, "Fleet Context", colors["accent"]) - row = y + 1 - machines = runtime.get("machines", []) - if not machines: - _safe_addstr(stdscr, row, x + 2, "No runtime data yet. Open the cockpit with z.", colors["muted"]) - return - - header = "sel live machine claude codex copilot vibe state target" - _safe_addstr(stdscr, row, x + 2, _clip(header, width - 4), colors["hot"]) - row += 1 - - mission_steps = mission.get("steps", []) if mission else [] - for index, machine in enumerate(machines[: max(1, height - 3)]): - selected = index == selected_machine - focused = machine["label"] == runtime.get("focused_machine") - marker = ">" if selected else " " - live = "*" if focused else " " - attr = colors["accent"] if selected else (colors["hot"] if focused else colors["base"]) - status = "up" if machine.get("session_exists") else "down" - role_bits = [] - for role in ROLES: - state = _role_state(machine, role) - token = _render_role_token(state, selected and role == selected_role, focused and role == runtime.get("focused_role")) - role_bits.append(token) - line = f"{marker} {live} {machine['label']:<16} {' '.join(role_bits)} {status:<7} {machine['target']}" - _safe_addstr(stdscr, row, x + 1, _clip(line, width - 3), attr) - row += 1 - matching = sorted( - (step for step in mission_steps if step.get("machine") == machine["label"]), - key=lambda step: (step.get("status") in {"done", "failed"}, step.get("attempt", 0)), - ) - for step in matching[:1]: - route_line = ( - f" route {step.get('cli', '-')}/{step.get('backend', '-')} " - f"skill={step.get('skill', '-')} agent={step.get('agent', '-')}" - ) - route_attr = colors["muted"] if not selected else colors["good"] - if row < y + height - 1: - _safe_addstr(stdscr, row, x + 1, _clip(route_line, width - 3), route_attr) - row += 1 - if row >= y + height - 1: - break - - -def _draw_capture_view( - stdscr: Any, - y: int, - x: int, - height: int, - width: int, - capture_state: dict[str, Any], - colors: dict[str, int], -) -> None: - title = f"Capture View :: {capture_state['machine']}:{capture_state['pane']}" - _draw_box(stdscr, y, x, height, width, title, colors["accent"]) - lines = capture_state.get("lines", []) or ["(empty capture)"] - row = y + 1 - body_height = max(1, height - 2) - max_scroll = max(0, len(lines) - body_height) - scroll = max(0, min(capture_state.get("scroll", 0), max_scroll)) - capture_state["scroll"] = scroll - status_bits = [f"scroll {scroll + 1}/{max(1, max_scroll + 1)}"] - if capture_state.get("returncode"): - status_bits.append(f"rc={capture_state['returncode']}") - _safe_addstr(stdscr, row, x + 2, _clip(" | ".join(status_bits), width - 4), colors["muted"]) - row += 1 - visible = lines[scroll : scroll + max(1, body_height - 1)] - for line in visible: - if row >= y + height - 1: - break - attr = colors["bad"] if capture_state.get("returncode") else colors["base"] - _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), attr) - row += 1 - - -def _draw_buddy( - stdscr: Any, - y: int, - x: int, - height: int, - width: int, - review: dict[str, str], - memory: dict[str, Any], - colors: dict[str, int], - tick: int, -) -> None: - _draw_box(stdscr, y, x, height, width, "Buddy", colors["accent"]) - row = y + 1 - verdict_attr = _verdict_attr(review, colors) - for line in _hexapus_lines(review["verdict"], tick): - if row >= y + height - 1: - return - _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), verdict_attr) - row += 1 - row += 1 - for line in [ - f"verdict: {review['verdict']}", - f"confidence: {review['confidence']}", - f"change: {review['change']}", - f"memory: {review['memory']}", - ]: - if row >= y + height - 1: - return - _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), colors["base"]) - row += 1 - row += 1 - row = _write_wrapped(stdscr, row, x + 2, width - 4, [review["why"]], colors["muted"]) - row += 1 - counts = memory.get("counts", {}) - for line in [ - f"persona facts : {counts.get('persona_facts', 0)}", - f"decisions : {counts.get('decisions', 0)}", - f"mission notes : {counts.get('mission_summaries', 0)}", - ]: - if row >= y + height - 1: - return - _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), colors["base"]) - row += 1 - - -def _draw_context_rail( - stdscr: Any, - y: int, - x: int, - height: int, - width: int, - review: dict[str, str], - runtime: dict[str, Any], - mission: dict[str, Any] | None, - selected_machine_label: str | None, - selected_role: str, - colors: dict[str, int], - tick: int, -) -> None: - _draw_box(stdscr, y, x, height, width, "Live", colors["accent"]) - row = y + 1 - verdict_attr = _verdict_attr(review, colors) - for line in _hexapus_lines(review["verdict"], tick)[:8]: - if row >= y + height - 1: - return - _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), verdict_attr) - row += 1 - row += 1 - lines = [ - f"buddy {review['verdict']} / {review['confidence']}", - f"selected {selected_machine_label or '-'}:{selected_role}", - f"focused {runtime.get('focused_machine') or '-'}:{runtime.get('focused_role') or '-'}", - ] - if mission and mission.get("steps"): - step = next((entry for entry in mission["steps"] if entry.get("status") not in {"done", "failed"}), mission["steps"][0]) - lines.extend( - [ - f"skill {step.get('skill', '-')}", - f"agent {step.get('agent', '-')}", - f"route {step.get('cli', '-')}/{step.get('backend', '-')}", - ] - ) - for line in lines: - if row >= y + height - 1: - return - _safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), colors["base"]) - row += 1 - row += 1 - if row < y + height - 1: - _write_wrapped(stdscr, row, x + 2, width - 4, [review["why"]], colors["muted"]) - - -def _draw_timeline(stdscr: Any, y: int, x: int, height: int, width: int, events: list[str], persona_lines: list[str], decision_lines: list[str], colors: dict[str, int]) -> None: - _draw_box(stdscr, y, x, height, width, "Timeline + Memory", colors["accent"]) - half = max(20, width // 2) - _safe_addstr(stdscr, y + 1, x + 2, "events", colors["hot"]) - _safe_addstr(stdscr, y + 1, x + half, "memory echoes", colors["hot"]) - row_left = y + 2 - for event in events[: max(1, height - 3)]: - _safe_addstr(stdscr, row_left, x + 2, _clip(event, half - 4), colors["base"]) - row_left += 1 - if row_left >= y + height - 1: - break - row_right = y + 2 - memory_lines = [*persona_lines[:3], *decision_lines[:3]] - for line in memory_lines[: max(1, height - 3)]: - _safe_addstr(stdscr, row_right, x + half, _clip(line, width - half - 2), colors["muted"]) - row_right += 1 - if row_right >= y + height - 1: - break - - -def _compute_layout(height: int, width: int, chat_focus: bool) -> TuiLayout: - top = 4 - footer_height = 2 - timeline_height = 0 if chat_focus else min(6, max(4, height // 8)) - main_height = max(12, height - top - timeline_height - footer_height) - if chat_focus: - left_width = max(20, min(24, width // 6)) - right_width = max(22, min(28, width // 5)) - else: - left_width = max(22, min(28, width // 5)) - right_width = max(24, min(30, width // 4)) - center_width = max(28, width - left_width - right_width - 4) - conversation_height = main_height if chat_focus else max(10, int(main_height * 0.74)) - runtime_height = 0 if chat_focus else max(6, main_height - conversation_height) - return TuiLayout( - height=height, - width=width, - top=top, - footer_height=footer_height, - timeline_height=timeline_height, - main_height=main_height, - left_width=left_width, - right_width=right_width, - center_width=center_width, - conversation_height=conversation_height, - runtime_height=runtime_height, - ) - - -def _collect_snapshot(default_workspace: str, selected_index: int, local_session: str, machine_session: str, runtime_snapshot: dict[str, Any] | None = None) -> dict[str, Any]: - missions = sorted(list_missions(), key=lambda item: item.get("updated_at", ""), reverse=True) - if missions: - selected_index = max(0, min(selected_index, len(missions) - 1)) - selected_mission = missions[selected_index] - workspace = str(selected_mission.get("workspace", default_workspace)) - else: - selected_mission = None - workspace = default_workspace - - health = daemon_request("health", auto_start=False) - memory = _safe_memory_status(workspace) - review = _normalize_buddy(selected_mission.get("buddy_review") if selected_mission else None) - persona_lines = _safe_persona_lines() - decision_lines = _safe_decision_lines(workspace) - events = _recent_events(selected_mission["mission_id"] if selected_mission else None) - fleet = load_fleet_config() - runtime = runtime_snapshot if runtime_snapshot is not None else _safe_runtime_status(local_session, machine_session) - chat_history = read_chat_history(workspace=workspace, mission_id=selected_mission["mission_id"] if selected_mission else None, limit=80) - chat_scope_label = f"mission:{selected_mission['mission_id'][:6]}" if selected_mission else "workspace" - return { - "missions": missions, - "selected_index": selected_index, - "selected_mission": selected_mission, - "workspace": workspace, - "health": health, - "memory": memory, - "review": review, - "persona_lines": persona_lines, - "decision_lines": decision_lines, - "events": events, - "fleet_count": len(fleet["machines"]), - "runtime": runtime, - "chat_history": chat_history, - "chat_scope_label": chat_scope_label, - } - - -def _ensure_mission_planned(mission: dict[str, Any]) -> dict[str, Any]: - if mission["steps"]: - return mission - payload = daemon_request("plan", {"mission": mission}) - mission["steps"] = payload["plan"]["steps"] - mission["title"] = payload["plan"]["title"] - mission["status"] = "planned" - mission["planner_summary"] = payload["plan"]["summary"] - mission["buddy_review"] = payload["buddy_review"] - save_mission(mission) - append_event(mission["mission_id"], "mission.planned", payload) - return mission - - -def _selected_machine_label(snapshot: dict[str, Any], selected_machine: int) -> str | None: - machines = snapshot["runtime"].get("machines", []) - if not machines: - return None - return machines[selected_machine]["label"] - - -def _open_capture(machine: str, pane: str, machine_session: str, current_height: int, current_scroll: int | None = None) -> dict[str, Any]: - capture = capture_pane(machine, pane, lines=200, machine_session=machine_session) - if capture.get("returncode", 0) != 0: - error_text = capture.get("stderr") or capture.get("stdout") or "capture command failed" - lines = [line for line in error_text.splitlines() if line] or ["capture command failed"] - else: - text = capture.get("stdout") or "" - lines = text.splitlines() or ["(empty capture)"] - view_height = max(8, current_height - 2) - scroll = max(0, len(lines) - view_height) if current_scroll is None else min(max(0, current_scroll), max(0, len(lines) - view_height)) - return { - "machine": machine, - "pane": pane, - "lines": lines, - "scroll": scroll, - "returncode": int(capture.get("returncode", 0)), - "raw": capture, - } - - -def _process_chat_submission( - text: str, - snapshot: dict[str, Any], - selected_machine: int, - selected_role: str, - local_session: str, - machine_session: str, - runtime_height: int, -) -> dict[str, Any]: - message = text.strip() - if not message: - return { - "flash": "empty prompt", - "force_select_mission_id": None, - "capture_state": None, - "invalidate_runtime": False, - "next_action": None, - } - - mission = snapshot["selected_mission"] - mission_id = mission["mission_id"] if mission else None - workspace = snapshot["workspace"] - machine_label = _selected_machine_label(snapshot, selected_machine) - - append_chat_message( - "user", - message, - workspace=workspace, - mission_id=mission_id, - machine=machine_label, - pane=selected_role, - ) - if mission_id: - append_event(mission_id, "chat.user", {"content": message, "machine": machine_label, "pane": selected_role}) - - payload = daemon_request( - "chat", - { - "message": message, - "mission": mission, - "workspace": workspace, - "selected_machine": machine_label, - "selected_role": selected_role, - "chat_history": snapshot["chat_history"][-12:], - }, - ) - - reply = str(payload.get("reply", "")).strip() or "No reply." - buddy_note = payload.get("buddy_note") - intent = payload.get("intent", "plain_chat") - action = payload.get("cockpit_action") - requested_skill = payload.get("skill") or {} - requested_skill_id = str(requested_skill.get("id", "")).strip() - routing_overrides = payload.get("routing_overrides") or {} - - capture_state = None - next_action = None - force_select_mission_id = None - thread_mission_id = mission_id - - if intent == "mission_create": - mission = create_mission( - payload.get("mission_goal") or message, - workspace, - routing_overrides=routing_overrides, - ) - mission = _ensure_mission_planned(mission) - force_select_mission_id = mission["mission_id"] - step = mission["steps"][0] if mission.get("steps") else {} - step_skill = step.get("skill", requested_skill_id) - append_chat_message( - "system", - f"Promoted to mission {mission['mission_id']} :: {mission['title']}", - workspace=workspace, - mission_id=mission_id, - intent="mission_create", - machine=machine_label, - pane=selected_role, - meta={ - "created_mission_id": mission["mission_id"], - "skill": step_skill, - "agent": step.get("agent"), - "cli": step.get("cli"), - }, - ) - append_chat_message( - "user", - message, - workspace=workspace, - mission_id=mission["mission_id"], - intent="mission_create", - machine=machine_label, - pane=selected_role, - meta={"skill": step_skill}, - ) - append_chat_message( - "constant", - reply, - workspace=workspace, - mission_id=mission["mission_id"], - intent="mission_create", - machine=machine_label, - pane=selected_role, - meta={ - "created_mission_id": mission["mission_id"], - "skill": step_skill, - "agent": step.get("agent"), - "cli": step.get("cli"), - }, - ) - if buddy_note: - append_chat_message( - "buddy", - str(buddy_note.get("answer", "")), - workspace=workspace, - mission_id=mission["mission_id"], - intent="buddy_answer", - meta={"skill": step_skill}, - ) - append_event(mission["mission_id"], "chat.constant", {"intent": intent, "reply": reply}) - return { - "flash": f"mission created {mission['mission_id']} -> {mission['title']} [{step.get('skill', '-')} / {step.get('agent', '-')}]", - "force_select_mission_id": force_select_mission_id, - "capture_state": None, - "invalidate_runtime": False, - "stream_text": reply, - "stream_mission_id": mission["mission_id"], - "next_action": None, - } - - append_chat_message( - "constant", - reply, - workspace=workspace, - mission_id=thread_mission_id, - intent=intent, - machine=machine_label, - pane=selected_role, - meta={ - "memory_hits": payload.get("memory_hits", [])[:3], - "skill": requested_skill_id, - "agent": routing_overrides.get("agent"), - "cli": routing_overrides.get("cli"), - }, - ) - if thread_mission_id: - append_event(thread_mission_id, "chat.constant", {"intent": intent, "reply": reply}) - - if buddy_note: - append_chat_message( - "buddy", - str(buddy_note.get("answer", "")), - workspace=workspace, - mission_id=thread_mission_id, - intent="buddy_answer", - meta={"skill": requested_skill_id}, - ) - - if action: - action_type = action.get("type") - action_machine = action.get("machine") or machine_label - action_pane = action.get("pane") or selected_role - try: - if action_type == "focus" and action_machine: - focus_machine(action_machine, action_pane, local_session=local_session, machine_session=machine_session) - append_chat_message("system", f"Focused {action_machine}:{action_pane}", workspace=workspace, mission_id=thread_mission_id, intent=intent) - return { - "flash": f"focused {action_machine}:{action_pane}", - "force_select_mission_id": None, - "capture_state": None, - "invalidate_runtime": True, - "next_action": None, - } - if action_type == "restart" and action_machine: - restart_pane(action_machine, action_pane, machine_session=machine_session) - append_chat_message("system", f"Restart sent to {action_machine}:{action_pane}", workspace=workspace, mission_id=thread_mission_id, intent=intent) - return { - "flash": f"restart sent to {action_machine}:{action_pane}", - "force_select_mission_id": None, - "capture_state": None, - "invalidate_runtime": True, - "next_action": None, - } - if action_type == "capture" and action_machine: - capture_state = _open_capture(action_machine, action_pane, machine_session, runtime_height) - append_chat_message("system", f"Captured {action_machine}:{action_pane}", workspace=workspace, mission_id=thread_mission_id, intent=intent) - return { - "flash": f"capture loaded for {action_machine}:{action_pane}", - "force_select_mission_id": None, - "capture_state": capture_state, - "invalidate_runtime": False, - "next_action": None, - } - if action_type == "open": - append_chat_message("system", "Opening full cockpit view", workspace=workspace, mission_id=thread_mission_id, intent=intent) - return { - "flash": "opening full cockpit", - "force_select_mission_id": None, - "capture_state": None, - "invalidate_runtime": False, - "next_action": {"action": "cockpit", "workspace": workspace}, - } - except Exception as exc: # noqa: BLE001 - append_chat_message("system", _error_line("cockpit action failed", exc), workspace=workspace, mission_id=thread_mission_id, intent="cockpit_error") - return { - "flash": _error_line("cockpit action failed", exc), - "force_select_mission_id": None, - "capture_state": None, - "invalidate_runtime": False, - "next_action": None, - } - - return { - "flash": "message routed through Constant", - "force_select_mission_id": None, - "capture_state": None, - "invalidate_runtime": False, - "stream_text": reply, - "stream_mission_id": thread_mission_id, - "next_action": next_action, - } - - -def _draw_status_and_prompt( - stdscr: Any, - height: int, - width: int, - *, - flash: str, - flash_until: float, - input_mode: bool, - input_buffer: str, - capture_state: dict[str, Any] | None, - snapshot: dict[str, Any], - selected_machine_label: str | None, - selected_role: str, - colors: dict[str, int], -) -> None: - now = time.time() - if flash and now < flash_until: - status = flash - status_attr = colors["warn"] - elif capture_state: - status = "capture | j/k scroll | PgUp/PgDn page | c refresh | x close | q quit" - status_attr = colors["muted"] - elif input_mode: - status = "chat | Enter send | / skills | Tab menu | Esc cockpit | Ctrl-U clear" - status_attr = colors["muted"] - else: - focused = f"{snapshot['runtime'].get('focused_machine') or '-'}:{snapshot['runtime'].get('focused_role') or '-'}" - selected = f"{selected_machine_label or '-'}:{selected_role}" - view = "chat-focus" if snapshot.get("chat_focus") else "cockpit-detail" - status = f"cockpit | type to chat | f view | [/] machine | 1..4 pane | o focus | x capture | r restart | z tabs | q quit | {view} | sel={selected} | live={focused}" - status_attr = colors["muted"] - - prompt_prefix = "› " - prompt_text = prompt_prefix + input_buffer - if input_mode: - prompt_text += "_" - - _safe_addstr(stdscr, height - 2, 2, _clip(status, width - 4), status_attr) - _safe_addstr(stdscr, height - 1, 2, _clip(prompt_text, width - 4), colors["hot"] if input_mode else colors["base"]) - if input_mode: - try: - cursor_x = min(width - 2, 2 + len(prompt_prefix) + len(input_buffer)) - stdscr.move(height - 1, max(2, cursor_x)) - except curses.error: - pass - - -def _refresh_snapshot(state: TuiState, workspace: str, local_session: str, machine_session: str) -> dict[str, Any]: - now = time.time() - if state.runtime_cache is None or now >= state.runtime_refresh_at: - state.runtime_cache = _safe_runtime_status(local_session, machine_session) - state.runtime_refresh_at = now + 2.0 - - snapshot = _collect_snapshot(workspace, state.selected_index, local_session, machine_session, runtime_snapshot=state.runtime_cache) - if state.pending_select_mission_id: - for index, mission in enumerate(snapshot["missions"]): - if mission["mission_id"] == state.pending_select_mission_id: - state.selected_index = index - snapshot = _collect_snapshot(workspace, state.selected_index, local_session, machine_session, runtime_snapshot=state.runtime_cache) - break - state.pending_select_mission_id = None - - state.selected_index = snapshot["selected_index"] - runtime_machines = snapshot["runtime"].get("machines", []) - if runtime_machines: - state.selected_machine = max(0, min(state.selected_machine, len(runtime_machines) - 1)) - else: - state.selected_machine = 0 - return snapshot - - -def _draw_frame(stdscr: Any, snapshot: dict[str, Any], state: TuiState, colors: dict[str, int]) -> TuiLayout: - stdscr.erase() - height, width = stdscr.getmaxyx() - layout = _compute_layout(height, width, state.chat_focus and not state.capture_state) - snapshot["chat_focus"] = state.chat_focus and not state.capture_state - machine_label = _selected_machine_label(snapshot, state.selected_machine) - - _draw_header( - stdscr, - snapshot["workspace"], - len(snapshot["missions"]), - snapshot["fleet_count"], - snapshot["runtime"], - snapshot["memory"], - snapshot["health"], - colors, - snapshot["chat_scope_label"], - machine_label, - state.selected_role, - ) - if state.chat_focus and not state.capture_state: - _draw_threads_focus( - stdscr, - layout.top, - 0, - layout.main_height, - layout.left_width, - snapshot["workspace"], - snapshot["missions"], - state.selected_index, - colors, - ) - else: - _draw_missions(stdscr, layout.top, 0, layout.main_height, layout.left_width, snapshot["missions"], state.selected_index, colors) - _draw_conversation( - stdscr, - layout.top, - layout.left_width + 1, - layout.conversation_height, - layout.center_width, - snapshot["chat_history"], - snapshot["selected_mission"], - state, - colors, - state.chat_focus and not state.capture_state, - ) - skill_candidates = _skill_menu_candidates(state.input_buffer) if state.input_mode else [] - if skill_candidates: - menu_width = min(max(44, layout.center_width - 4), layout.center_width) - _draw_skill_menu( - stdscr, - layout.top + 2, - layout.left_width + 3, - menu_width, - skill_candidates, - state.slash_menu_index, - colors, - ) - if state.capture_state: - _draw_capture_view( - stdscr, - layout.top + layout.conversation_height, - layout.left_width + 1, - layout.runtime_height, - layout.center_width, - state.capture_state, - colors, - ) - elif not state.chat_focus: - _draw_runtime( - stdscr, - layout.top + layout.conversation_height, - layout.left_width + 1, - layout.runtime_height, - layout.center_width, - snapshot["runtime"], - snapshot["selected_mission"], - state.selected_machine, - state.selected_role, - colors, - ) - if state.chat_focus and not state.capture_state: - _draw_context_rail( - stdscr, - layout.top, - layout.left_width + layout.center_width + 2, - layout.main_height, - layout.right_width, - snapshot["review"], - snapshot["runtime"], - snapshot["selected_mission"], - machine_label, - state.selected_role, - colors, - int(time.time() * 4), - ) - else: - _draw_buddy( - stdscr, - layout.top, - layout.left_width + layout.center_width + 2, - layout.main_height, - layout.right_width, - snapshot["review"], - snapshot["memory"], - colors, - int(time.time() * 4), - ) - if layout.timeline_height > 0: - _draw_timeline( - stdscr, - layout.top + layout.main_height, - 0, - layout.timeline_height, - layout.width, - snapshot["events"], - snapshot["persona_lines"], - snapshot["decision_lines"], - colors, - ) - _draw_status_and_prompt( - stdscr, - layout.height, - layout.width, - flash=state.flash, - flash_until=state.flash_until, - input_mode=state.input_mode, - input_buffer=state.input_buffer, - capture_state=state.capture_state, - snapshot=snapshot, - selected_machine_label=machine_label, - selected_role=state.selected_role, - colors=colors, - ) - stdscr.refresh() - return layout - - -def _handle_input_mode_key( - key: int, - state: TuiState, - snapshot: dict[str, Any], - local_session: str, - machine_session: str, - runtime_height: int, -) -> dict[str, Any] | None: - if key in (10, 13, curses.KEY_ENTER): - candidates = _skill_menu_candidates(state.input_buffer) - if _slash_menu_pending(state.input_buffer) and candidates: - selected = candidates[max(0, min(state.slash_menu_index, len(candidates) - 1))] - state.input_buffer = _complete_slash_skill(state.input_buffer, selected) - state.slash_menu_index = 0 - state.set_flash(f"selected /{selected['id']}", 1.5) - return None - outcome = _process_chat_submission( - state.input_buffer, - snapshot, - state.selected_machine, - state.selected_role, - local_session, - machine_session, - runtime_height, - ) - state.input_mode = True - state.input_buffer = "" - stream_text = outcome.get("stream_text", "") - if stream_text: - state.stream_text = stream_text - state.stream_started_at = time.time() - state.stream_duration = min(3.8, max(0.7, len(stream_text) / 72.0)) - state.stream_mission_id = outcome.get("stream_mission_id") - else: - state.stream_text = "" - state.stream_duration = 0.0 - state.stream_mission_id = None - if outcome["capture_state"] is not None: - state.capture_state = outcome["capture_state"] - if outcome["invalidate_runtime"]: - state.runtime_cache = None - if outcome["force_select_mission_id"]: - state.pending_select_mission_id = outcome["force_select_mission_id"] - state.set_flash(outcome["flash"], 3.0) - return outcome["next_action"] - if key == 27: - state.input_mode = False - state.input_buffer = "" - state.slash_menu_index = 0 - state.set_flash("chat canceled", 1.5) - return None - candidates = _skill_menu_candidates(state.input_buffer) - if candidates and key in (9, curses.KEY_DOWN): - state.slash_menu_index = (state.slash_menu_index + 1) % len(candidates) - return None - if candidates and key == curses.KEY_UP: - state.slash_menu_index = (state.slash_menu_index - 1) % len(candidates) - return None - if key in (curses.KEY_BACKSPACE, 127, 8): - state.input_buffer = state.input_buffer[:-1] - state.slash_menu_index = 0 - return None - if key == 21: - state.input_buffer = "" - state.slash_menu_index = 0 - return None - if 32 <= key <= 126: - state.input_buffer += chr(key) - state.slash_menu_index = 0 - return None - - -def _handle_capture_key(key: int, state: TuiState, machine_session: str, runtime_height: int) -> bool: - if not state.capture_state: - return False - if key == ord("x"): - state.capture_state = None - state.set_flash("capture view closed", 1.5) - return True - if key == ord("c"): - try: - state.capture_state = _open_capture( - state.capture_state["machine"], - state.capture_state["pane"], - machine_session, - runtime_height, - state.capture_state.get("scroll"), - ) - if state.capture_state.get("returncode"): - state.set_flash( - f"capture error for {state.capture_state['machine']}:{state.capture_state['pane']}", - 2.5, - ) - else: - state.set_flash( - f"capture refreshed for {state.capture_state['machine']}:{state.capture_state['pane']}", - 2.5, - ) - except Exception as exc: # noqa: BLE001 - state.set_flash(_error_line("capture refresh failed", exc), 2.5) - return True - if key in (ord("j"), curses.KEY_DOWN): - max_scroll = max(0, len(state.capture_state.get("lines", [])) - max(1, runtime_height - 3)) - state.capture_state["scroll"] = min(max_scroll, state.capture_state.get("scroll", 0) + 1) - return True - if key in (ord("k"), curses.KEY_UP): - state.capture_state["scroll"] = max(0, state.capture_state.get("scroll", 0) - 1) - return True - if key == curses.KEY_NPAGE: - max_scroll = max(0, len(state.capture_state.get("lines", [])) - max(1, runtime_height - 3)) - state.capture_state["scroll"] = min(max_scroll, state.capture_state.get("scroll", 0) + max(4, runtime_height // 2)) - return True - if key == curses.KEY_PPAGE: - state.capture_state["scroll"] = max(0, state.capture_state.get("scroll", 0) - max(4, runtime_height // 2)) - return True - if key == ord("/"): - state.input_mode = True - state.input_buffer = "/" - state.slash_menu_index = 0 - return True - return False - - -def _handle_normal_key( - key: int, - state: TuiState, - snapshot: dict[str, Any], - local_session: str, - machine_session: str, - runtime_height: int, -) -> dict[str, Any] | None: - if key in (ord("q"), 27): - return {"action": "quit"} - if key == ord("/"): - state.input_mode = True - state.input_buffer = "/" - state.slash_menu_index = 0 - return None - if key == ord("f"): - state.chat_focus = not state.chat_focus - state.set_flash("chat-focus enabled" if state.chat_focus else "cockpit-detail enabled", 1.5) - return None - if key == ord("p"): - state.input_mode = True - state.input_buffer = "/spec-planner " - state.slash_menu_index = 0 - state.set_flash("spec-planner primed", 1.5) - return None - if key == ord("b"): - state.input_mode = True - state.input_buffer = "/architecture-brainstorm " - state.slash_menu_index = 0 - state.set_flash("architecture-brainstorm primed", 1.5) - return None - if key == ord("t"): - state.input_mode = True - state.input_buffer = "/task-decomposer " - state.slash_menu_index = 0 - state.set_flash("task-decomposer primed", 1.5) - return None - if key == ord("P"): - state.input_mode = True - state.input_buffer = "/pr-review-prep " - state.slash_menu_index = 0 - state.set_flash("pr-review-prep primed", 1.5) - return None - if 32 <= key <= 126 and chr(key) not in {"[", "]", "1", "2", "3", "4", "o", "r", "x", "z", "j", "k", "q", "e", "s", "c", "/", "p", "b", "t", "P", "f"}: - state.input_mode = True - state.input_buffer = chr(key) - return None - - machines = snapshot["runtime"].get("machines", []) - machine_label = _selected_machine_label(snapshot, state.selected_machine) - - if key in (ord("j"), curses.KEY_DOWN): - if snapshot["missions"]: - state.selected_index = min(len(snapshot["missions"]) - 1, state.selected_index + 1) - return None - if key in (ord("k"), curses.KEY_UP): - if snapshot["missions"]: - state.selected_index = max(0, state.selected_index - 1) - return None - if key == ord("["): - if machines: - state.selected_machine = max(0, state.selected_machine - 1) - return None - if key == ord("]"): - if machines: - state.selected_machine = min(len(machines) - 1, state.selected_machine + 1) - return None - if key in (ord("1"), ord("2"), ord("3"), ord("4")) and machines: - state.selected_role_index = int(chr(key)) - 1 - state.set_flash(f"selected {machine_label}:{state.selected_role}", 1.5) - return None - if key == ord("o") and machines: - machine = machines[state.selected_machine] - try: - focus_machine( - machine["label"], - state.selected_role, - local_session=local_session, - machine_session=machine_session, - ) - state.runtime_cache = None - state.set_flash(f"focused {machine['label']}:{state.selected_role}", 2.5) - except Exception as exc: # noqa: BLE001 - state.set_flash(_error_line("focus failed", exc), 2.5) - return None - if key == ord("r") and machines: - machine = machines[state.selected_machine] - try: - restart_pane(machine["label"], state.selected_role, machine_session=machine_session) - state.runtime_cache = None - state.set_flash(f"restart sent to {machine['label']}:{state.selected_role}", 2.5) - except Exception as exc: # noqa: BLE001 - state.set_flash(_error_line("restart failed", exc), 2.5) - return None - if key == ord("x") and machines: - machine = machines[state.selected_machine] - try: - state.capture_state = _open_capture(machine["label"], state.selected_role, machine_session, runtime_height) - if state.capture_state.get("returncode"): - state.set_flash(f"capture error for {machine['label']}:{state.selected_role}", 2.5) - else: - state.set_flash(f"capture loaded for {machine['label']}:{state.selected_role}", 2.5) - except Exception as exc: # noqa: BLE001 - state.set_flash(_error_line("capture failed", exc), 2.5) - return None - if key == ord("z"): - return {"action": "cockpit", "workspace": snapshot["workspace"]} - if key == ord("e"): - try: - rebuild_workspace_memory(snapshot["workspace"], enroll=True) - state.set_flash(f"memory rebuilt for {snapshot['workspace']}", 2.5) - except Exception as exc: # noqa: BLE001 - state.set_flash(_error_line("memory rebuild failed", exc), 2.5) - return None - if key == ord("s") and snapshot["selected_mission"]: - try: - summarize_mission(snapshot["selected_mission"]["mission_id"]) - state.set_flash(f"mission summarized: {snapshot['selected_mission']['mission_id']}", 2.5) - except Exception as exc: # noqa: BLE001 - state.set_flash(_error_line("summary failed", exc), 2.5) - return None - return None - - -def _run(stdscr: Any, workspace: str, local_session: str, machine_session: str) -> dict[str, Any] | None: - try: - curses.curs_set(1) - except curses.error: - pass - stdscr.nodelay(False) - stdscr.timeout(250) - colors = _init_colors() - state = TuiState() - - while True: - snapshot = _refresh_snapshot(state, workspace, local_session, machine_session) - layout = _draw_frame(stdscr, snapshot, state, colors) - - key = stdscr.getch() - if key in (-1, curses.KEY_RESIZE): - continue - - if state.input_mode: - next_action = _handle_input_mode_key( - key, - state, - snapshot, - local_session, - machine_session, - layout.runtime_height, - ) - if next_action: - return next_action - continue - - if _handle_capture_key(key, state, machine_session, layout.runtime_height): - continue - - action = _handle_normal_key( - key, - state, - snapshot, - local_session, - machine_session, - layout.runtime_height, - ) - if action and action.get("action") == "quit": - return None - if action: - return action - - -def run_tui(workspace: str, local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any] | None: - try: - prime_workspace_memory(workspace, enroll=True) - except Exception: - pass - return curses.wrapper(lambda stdscr: _run(stdscr, str(Path(workspace).expanduser().resolve()), local_session, machine_session)) diff --git a/scripts/Constant b/scripts/Constant index 4495792..d2af2a5 100755 --- a/scripts/Constant +++ b/scripts/Constant @@ -9,149 +9,39 @@ while [[ -L "$script_source" ]]; do done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" repo_dir="$(cd "$script_dir/.." && pwd -P)" -default_python="$HOME/.local/share/constant/venv/bin/python3" rust_bin="$repo_dir/target/debug/constant" cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/constant/wrapper" -probe_timeout_sec="${CONSTANT_RUST_PROBE_TIMEOUT_SEC:-2}" -probe_fail_ttl_sec="${CONSTANT_RUST_PROBE_FAIL_TTL_SEC:-300}" -force_rust_recheck="${CONSTANT_RUST_RECHECK:-0}" - -ancestor_comm_contains() { - local needle="$1" - local pid="${PPID:-}" - local depth=0 - while [[ -n "$pid" && "$pid" =~ ^[0-9]+$ && "$pid" -gt 1 && $depth -lt 12 ]]; do - local comm="" - comm="$(ps -o comm= -p "$pid" 2>/dev/null | tr -d '\n')" || break - [[ -z "$comm" ]] && break - if [[ "$comm" == *"$needle"* ]]; then - return 0 - fi - pid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d '[:space:]')" || break - (( depth += 1 )) - done - return 1 -} - -stat_signature() { - local target="$1" - /usr/bin/stat -f '%m:%z' "$target" 2>/dev/null || printf 'missing\n' -} record_wrapper_mode() { - local mode="$1" - local reason="$2" + local reason="$1" mkdir -p "$cache_root" - printf '%s %s %s\n' "$mode" "$reason" "$(date +%s)" > "$cache_root/last-mode" + printf '%s %s %s\n' "rust" "$reason" "$(date +%s)" > "$cache_root/last-mode" export CONSTANT_WRAPPER_LAST_REASON="$reason" } -cache_matches() { - local cache_file="$1" - local expected_signature="$2" - [[ -f "$cache_file" ]] || return 1 - local cached_signature="" - local cached_epoch="" - IFS=' ' read -r cached_signature cached_epoch < "$cache_file" || return 1 - [[ "$cached_signature" == "$expected_signature" ]] || return 1 - printf '%s\n' "${cached_epoch:-0}" +sign_binary_if_possible() { + command -v codesign >/dev/null 2>&1 || return 0 + codesign --force -s - "$rust_bin" >/dev/null 2>&1 || true } -run_rust_probe() { - local probe_target="$1" - local timeout_sec="$2" - local probe_log="$cache_root/rust-probe.log" - mkdir -p "$cache_root" - - ("$probe_target" --version >"$probe_log" 2>&1) & - local probe_pid=$! - local waited=0 - local deadline=$(( timeout_sec * 10 )) - - while (( waited < deadline )); do - if ! kill -0 "$probe_pid" 2>/dev/null; then - local status=0 - wait "$probe_pid" || status=$? - [[ $status -eq 0 ]] - return - fi - sleep 0.1 - (( waited += 1 )) - done - - kill "$probe_pid" 2>/dev/null || true - wait "$probe_pid" 2>/dev/null || true +needs_build() { + [[ -x "$rust_bin" ]] || return 0 + [[ "$repo_dir/Cargo.toml" -nt "$rust_bin" ]] && return 0 + [[ -f "$repo_dir/Cargo.lock" && "$repo_dir/Cargo.lock" -nt "$rust_bin" ]] && return 0 + if find "$repo_dir/src" "$repo_dir/tests" -type f -newer "$rust_bin" -print -quit 2>/dev/null | grep -q .; then + return 0 + fi return 1 } -warn_rust_fallback() { - [[ -t 2 ]] || return 0 - printf '%s\n' "constant: Rust startup probe failed in this macOS session, falling back to Python." >&2 -} - -warn_codex_rust_bypass() { - [[ -t 2 ]] || return 0 - printf '%s\n' "constant: running under Codex runner, skipping Rust startup path and using Python fallback." >&2 -} - -if [[ "${CONSTANT_USE_RUST:-0}" == "1" && "${CONSTANT_USE_PYTHON:-0}" != "1" ]]; then +if needs_build; then cargo build --manifest-path "$repo_dir/Cargo.toml" --quiet - record_wrapper_mode "rust" "forced-rust" - export CONSTANT_REPO_DIR="$repo_dir" - export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}" - exec "$rust_bin" "$@" + sign_binary_if_possible + record_wrapper_mode "rebuilt" +else + record_wrapper_mode "reused-binary" fi -if [[ "${CONSTANT_USE_PYTHON:-0}" != "1" ]]; then - if ancestor_comm_contains "codex-aarch64-apple-darwin"; then - record_wrapper_mode "python" "codex-runner" - warn_codex_rust_bypass - else - cargo build --manifest-path "$repo_dir/Cargo.toml" --quiet - rust_signature="$(stat_signature "$rust_bin")" - mkdir -p "$cache_root" - - ok_epoch="$(cache_matches "$cache_root/rust-ok" "$rust_signature" || true)" - if [[ "$force_rust_recheck" != "1" && -n "$ok_epoch" ]]; then - record_wrapper_mode "rust" "cached-rust-ok" - export CONSTANT_REPO_DIR="$repo_dir" - export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}" - exec "$rust_bin" "$@" - fi - - fail_epoch="$(cache_matches "$cache_root/rust-fail" "$rust_signature" || true)" - now_epoch="$(date +%s)" - if [[ "$force_rust_recheck" != "1" && -n "$fail_epoch" ]] && (( now_epoch - fail_epoch < probe_fail_ttl_sec )); then - record_wrapper_mode "python" "cached-rust-fail" - : - elif run_rust_probe "$rust_bin" "$probe_timeout_sec"; then - printf '%s %s\n' "$rust_signature" "$now_epoch" > "$cache_root/rust-ok" - rm -f "$cache_root/rust-fail" - record_wrapper_mode "rust" "probe-ok" - export CONSTANT_REPO_DIR="$repo_dir" - export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}" - exec "$rust_bin" "$@" - else - printf '%s %s\n' "$rust_signature" "$now_epoch" > "$cache_root/rust-fail" - rm -f "$cache_root/rust-ok" - record_wrapper_mode "python" "probe-failed" - warn_rust_fallback - fi - fi -fi - -python_bin="${CONSTANT_PYTHON:-}" -if [[ -z "$python_bin" ]]; then - if [[ -x "$default_python" ]]; then - python_bin="$default_python" - else - python_bin="python3" - fi -fi - -export PYTHONPATH="$repo_dir${PYTHONPATH+:$PYTHONPATH}" export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}" -if [[ "${CONSTANT_USE_PYTHON:-0}" == "1" ]]; then - record_wrapper_mode "python" "forced-python" -fi -exec "$python_bin" -m constant "$@" +export CONSTANT_REPO_DIR="$repo_dir" +exec "$rust_bin" "$@" diff --git a/scripts/constant-chat-dock.sh b/scripts/constant-chat-dock.sh new file mode 100755 index 0000000..4e5860e --- /dev/null +++ b/scripts/constant-chat-dock.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_source="${BASH_SOURCE[0]:-$0}" +while [[ -L "$script_source" ]]; do + script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" + script_source="$(readlink "$script_source")" + [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" +done +script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" +repo_dir="$(cd "$script_dir/.." && pwd -P)" +rust_bin="$repo_dir/target/debug/constant" + +if [[ ! -x "$rust_bin" ]]; then + cargo build --manifest-path "$repo_dir/Cargo.toml" --quiet + if command -v codesign >/dev/null 2>&1; then + codesign --force -s - "$rust_bin" >/dev/null 2>&1 || true + fi +fi + +exec "$rust_bin" cockpit status-line "$@" diff --git a/scripts/constant-tmux-fleet.sh b/scripts/constant-tmux-fleet.sh index b76aba0..2777542 100755 --- a/scripts/constant-tmux-fleet.sh +++ b/scripts/constant-tmux-fleet.sh @@ -44,11 +44,30 @@ require_arg() { fi } +window_pane_id() { + local session_name="$1" + local window_name="$2" + tmux list-panes -t "${session_name}:${window_name}" -F '#{pane_id}' 2>/dev/null | head -n 1 +} + +mark_window_managed() { + local session_name="$1" + local window_name="$2" + local role_label="$3" + local command_string="$4" + local pane_id + + pane_id="$(window_pane_id "$session_name" "$window_name")" + if [[ -z "$pane_id" ]]; then + return 1 + fi + constant_tmux_set_managed_pane "$pane_id" "$role_label" "$command_string" +} + build_constant_window_command() { local cmd=( env "PATH=$(zellij_ai_agent_path)" - "CONSTANT_USE_PYTHON=1" "$repo_dir/scripts/Constant" tui --workspace "$workspace" @@ -111,23 +130,9 @@ link_local_window() { configure_fleet_session() { local session_name="$1" tmux rename-window -t "${session_name}:0" Constant >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g set-clipboard on >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g remain-on-exit on >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g mouse on >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g status on >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g status-position top >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g status-justify left >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g status-left-length 24 >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g status-right-length 48 >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g status-left '#[fg=black,bg=green,bold] Constant #[default] ' >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g status-right '#[fg=colour250]#S #[fg=colour244]| #[fg=colour252]%H:%M #[fg=colour244]%d-%b' >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g status-style 'bg=colour234,fg=colour252' >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g window-status-separator ' ' >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g window-status-format '#[fg=colour245,bg=colour236] #I:#W ' >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g window-status-current-format '#[fg=colour16,bg=colour46,bold] #I:#W ' >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g window-status-current-style 'fg=colour16,bg=colour46,bold' >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g allow-rename off >/dev/null 2>&1 || true - tmux set-option -t "$session_name" -g automatic-rename off >/dev/null 2>&1 || true + constant_tmux_configure_autorestart_hook "$session_name" + constant_tmux_configure_status_chrome "$session_name" "Constant" "green" + constant_tmux_configure_chat_dock "$session_name" "$repo_dir" "$workspace" "fleet" "" } attach_fleet_session() { @@ -154,8 +159,10 @@ ensure_constant_window() { if window_pane_dead "$local_session" "Constant"; then tmux respawn-pane -k -t "${local_session}:Constant" "$command_string" fi + mark_window_managed "$local_session" "Constant" "constant" "$command_string" >/dev/null 2>&1 || true else tmux new-window -d -t "${local_session}:" -n Constant "$command_string" + mark_window_managed "$local_session" "Constant" "constant" "$command_string" >/dev/null 2>&1 || true fi } @@ -168,8 +175,10 @@ ensure_remote_window() { if window_pane_dead "$local_session" "$label"; then tmux respawn-pane -k -t "${local_session}:${label}" "$command_string" fi + mark_window_managed "$local_session" "$label" "$label" "$command_string" >/dev/null 2>&1 || true else tmux new-window -d -t "${local_session}:" -n "$label" "$command_string" + mark_window_managed "$local_session" "$label" "$label" "$command_string" >/dev/null 2>&1 || true fi } diff --git a/scripts/constant-tmux-machine.sh b/scripts/constant-tmux-machine.sh index 8649180..a8e8f65 100755 --- a/scripts/constant-tmux-machine.sh +++ b/scripts/constant-tmux-machine.sh @@ -72,6 +72,12 @@ pane_command() { set_pane_meta() { local pane_id="$1" local role="$2" + local command_string="${3:-}" + local reset_state="${4:-0}" + if [[ -n "$command_string" ]]; then + constant_tmux_set_managed_pane "$pane_id" "$role" "$command_string" "$reset_state" + return + fi tmux select-pane -t "$pane_id" -T "$role" >/dev/null 2>&1 || true tmux set-option -p -t "$pane_id" @constant_role "$role" >/dev/null 2>&1 || true } @@ -87,10 +93,9 @@ create_session() { vibe_cmd="$(pane_command vibe)" tmux new-session -d -s "$session" -n "$machine_name" "$claude_cmd" - tmux set-option -t "$session" -g set-clipboard on >/dev/null 2>&1 || true - tmux set-option -t "$session" -g remain-on-exit on >/dev/null 2>&1 || true - tmux set-option -t "$session" -g mouse on >/dev/null 2>&1 || true - tmux set-option -t "$session" -g status-position top >/dev/null 2>&1 || true + constant_tmux_configure_autorestart_hook "$session" + constant_tmux_configure_status_chrome "$session" "$machine_name" "colour81" + constant_tmux_configure_chat_dock "$session" "$repo_dir" "$workspace" "$machine_name" "$machine_name" tmux set-window-option -t "$target_window" -g pane-base-index 0 >/dev/null 2>&1 || true claude_pane="$(tmux list-panes -t "$target_window" -F '#{pane_id}' | head -n 1)" @@ -100,14 +105,15 @@ create_session() { tmux select-layout -t "$target_window" main-vertical >/dev/null tmux resize-pane -t "$claude_pane" -x 120 >/dev/null 2>&1 || true - set_pane_meta "$claude_pane" claude - set_pane_meta "$codex_pane" codex - set_pane_meta "$copilot_pane" copilot - set_pane_meta "$vibe_pane" vibe + set_pane_meta "$claude_pane" claude "$claude_cmd" 1 + set_pane_meta "$codex_pane" codex "$codex_cmd" 1 + set_pane_meta "$copilot_pane" copilot "$copilot_cmd" 1 + set_pane_meta "$vibe_pane" vibe "$vibe_cmd" 1 tmux select-pane -t "$claude_pane" >/dev/null } ensure_session() { + local role pane_id command_string mkdir -p "$state_dir" "$bus_dir/messages" if $recreate && constant_tmux_session_exists "$session"; then @@ -119,11 +125,25 @@ ensure_session() { return 0 fi + constant_tmux_configure_autorestart_hook "$session" + constant_tmux_configure_status_chrome "$session" "$machine_name" "colour81" + constant_tmux_configure_chat_dock "$session" "$repo_dir" "$workspace" "$machine_name" "$machine_name" + if ! constant_tmux_window_exists "$session" "$machine_name"; then recreate=true tmux kill-session -t "$session" >/dev/null 2>&1 || true create_session + return 0 fi + + for role in claude codex copilot vibe; do + pane_id="$(role_pane_id "$role")" + if [[ -z "$pane_id" ]]; then + continue + fi + command_string="$(pane_command "$role")" + set_pane_meta "$pane_id" "$role" "$command_string" + done } focus_role() { @@ -154,7 +174,7 @@ restart_role() { command_string="$(pane_command "$role")" tmux respawn-pane -k -t "$pane_id" "$command_string" - set_pane_meta "$pane_id" "$role" + set_pane_meta "$pane_id" "$role" "$command_string" 1 tmux select-layout -t "$(tmux_target_window)" main-vertical >/dev/null } @@ -190,7 +210,7 @@ send_role() { session="$(zellij_ai_default_session)" workspace="$PWD" -repo_dir="$(zellij_ai_expand_home_path "$(zellij_ai_default_repo_dir)")" +repo_dir="$(cd "$script_dir/.." && pwd -P)" codex_home="$(zellij_ai_expand_home_path "$(zellij_ai_default_codex_home)")" claude_config_dir="" machine_name="${ZELLIJ_AI_MACHINE_NAME:-$(zellij_ai_local_machine_label)}" diff --git a/scripts/zellij-ai-common.sh b/scripts/zellij-ai-common.sh index fc23072..94ef8f6 100755 --- a/scripts/zellij-ai-common.sh +++ b/scripts/zellij-ai-common.sh @@ -28,56 +28,16 @@ zellij_ai_fleet_config_path() { zellij_ai_fleet_config_query() { local expr="$1" local config_path + local common_source common_dir wrapper config_path="$(zellij_ai_fleet_config_path 2>/dev/null || true)" [[ -n "$config_path" ]] || return 1 - command -v python3 >/dev/null 2>&1 || return 1 + common_source="${BASH_SOURCE[0]:-$0}" + common_dir="$(cd "$(dirname "$common_source")" && pwd -P)" + wrapper="$common_dir/Constant" + [[ -x "$wrapper" ]] || return 1 -python3 - "$config_path" "$expr" <<'PY' -import json -import sys -from pathlib import Path - -try: - import tomllib -except ModuleNotFoundError: # pragma: no cover - tomllib = None - -path = Path(sys.argv[1]) -expr = sys.argv[2] - -try: - if path.suffix == ".toml": - if tomllib is None: - raise SystemExit(1) - payload = tomllib.loads(path.read_text(encoding="utf-8")) - else: - payload = json.loads(path.read_text(encoding="utf-8")) -except Exception: - raise SystemExit(1) - -if expr == "repo_dir": - value = payload.get("repo_dir") - if isinstance(value, str) and value: - print(value) - raise SystemExit(0) - -if expr == "local_machine": - value = payload.get("local_machine") - if isinstance(value, str) and value: - print(value) - raise SystemExit(0) - -if expr == "machine_specs": - for machine in payload.get("machines", []): - label = machine.get("label") - target = machine.get("target") - if isinstance(label, str) and label and isinstance(target, str) and target: - print(f"{label}={target}") - raise SystemExit(0) - -raise SystemExit(1) -PY + "$wrapper" fleet config-get "$expr" } zellij_ai_default_session() { @@ -297,6 +257,100 @@ constant_tmux_window_exists() { tmux list-windows -t "$session_name" -F '#{window_name}' 2>/dev/null | grep -Fxq "$window_name" } +constant_tmux_set_managed_pane() { + local pane_id="$1" + local role="$2" + local command_string="$3" + local reset_state="${4:-0}" + + tmux select-pane -t "$pane_id" -T "$role" >/dev/null 2>&1 || true + tmux set-option -p -t "$pane_id" @constant_role "$role" >/dev/null 2>&1 || true + tmux set-option -p -t "$pane_id" @constant_command "$command_string" >/dev/null 2>&1 || true + tmux set-option -p -t "$pane_id" @constant_autorestart 1 >/dev/null 2>&1 || true + if [[ "$reset_state" == "1" ]]; then + tmux set-option -p -t "$pane_id" @constant_restart_failures 0 >/dev/null 2>&1 || true + tmux set-option -p -t "$pane_id" @constant_autorestart_disabled 0 >/dev/null 2>&1 || true + else + if [[ -z "$(tmux show-options -p -t "$pane_id" -v @constant_restart_failures 2>/dev/null || true)" ]]; then + tmux set-option -p -t "$pane_id" @constant_restart_failures 0 >/dev/null 2>&1 || true + fi + if [[ -z "$(tmux show-options -p -t "$pane_id" -v @constant_autorestart_disabled 2>/dev/null || true)" ]]; then + tmux set-option -p -t "$pane_id" @constant_autorestart_disabled 0 >/dev/null 2>&1 || true + fi + fi +} + +constant_tmux_autorestart_hook_command() { + cat <<'EOF' +run-shell -b 'sleep 0.8; pane="#{hook_pane}"; dead=$(tmux display-message -p -t "$pane" "#{pane_dead}" 2>/dev/null || true); managed=$(tmux show-options -p -t "$pane" -v @constant_autorestart 2>/dev/null || true); disabled=$(tmux show-options -p -t "$pane" -v @constant_autorestart_disabled 2>/dev/null || true); cmd=$(tmux show-options -p -t "$pane" -v @constant_command 2>/dev/null || true); failures=$(tmux show-options -p -t "$pane" -v @constant_restart_failures 2>/dev/null || printf 0); if [ "$dead" = "1" ] && [ "$managed" = "1" ] && [ "$disabled" != "1" ] && [ -n "$cmd" ]; then failures=$((failures + 1)); tmux set-option -p -t "$pane" @constant_restart_failures "$failures" >/dev/null 2>&1 || true; if [ "$failures" -ge 3 ]; then tmux set-option -p -t "$pane" @constant_autorestart_disabled 1 >/dev/null 2>&1 || true; else tmux respawn-pane -k -t "$pane" "$cmd"; fi; fi' +EOF +} + +constant_tmux_configure_autorestart_hook() { + local session_name="$1" + tmux set-hook -t "$session_name" pane-died "$(constant_tmux_autorestart_hook_command)" >/dev/null 2>&1 || true +} + +constant_tmux_configure_status_chrome() { + local session_name="$1" + local left_label="$2" + local left_bg="${3:-green}" + + tmux set-option -t "$session_name" set-clipboard on >/dev/null 2>&1 || true + tmux set-option -t "$session_name" remain-on-exit on >/dev/null 2>&1 || true + tmux set-option -t "$session_name" mouse on >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status on >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status-position top >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status-justify left >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status-left-length 32 >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status-right-length 64 >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status-left "#[fg=colour16,bg=${left_bg},bold] ${left_label} #[default] " >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status-right '#[fg=colour250]#S #[fg=colour244]| #[fg=colour252]%H:%M #[fg=colour244]%d-%b' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status-style 'bg=colour234,fg=colour252' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" window-status-separator ' ' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" window-status-format '#[fg=colour245,bg=colour236] #I:#W ' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" window-status-current-format '#[fg=colour16,bg=colour46,bold] #I:#W ' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" window-status-current-style 'fg=colour16,bg=colour46,bold' >/dev/null 2>&1 || true + tmux set-option -t "$session_name" allow-rename off >/dev/null 2>&1 || true + tmux set-option -t "$session_name" automatic-rename off >/dev/null 2>&1 || true +} + +constant_tmux_chat_dock_command() { + local repo_dir="$1" + local workspace="$2" + local scope_label="${3:-}" + local machine_label="${4:-}" + local cmd=( + "$repo_dir/scripts/constant-chat-dock.sh" + --workspace "$workspace" + ) + + if [[ -n "$scope_label" ]]; then + cmd+=(--scope-label "$scope_label") + fi + if [[ -n "$machine_label" ]]; then + cmd+=(--machine-label "$machine_label") + fi + + printf '%q ' "${cmd[@]}" +} + +constant_tmux_configure_chat_dock() { + local session_name="$1" + local repo_dir="$2" + local workspace="$3" + local scope_label="${4:-}" + local machine_label="${5:-}" + local dock_cmd dock_line + + dock_cmd="$(constant_tmux_chat_dock_command "$repo_dir" "$workspace" "$scope_label" "$machine_label")" + dock_line="#[fg=colour16,bg=colour153,bold] Chat #[fg=colour231,bg=colour238] #($dock_cmd)" + + tmux set-option -t "$session_name" status 2 >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status-interval 4 >/dev/null 2>&1 || true + tmux set-option -t "$session_name" status-format[1] "$dock_line" >/dev/null 2>&1 || true +} + constant_tmux_base_command() { local role="$1" local launcher="$2" diff --git a/scripts/zellij-ai-fleet-deploy.sh b/scripts/zellij-ai-fleet-deploy.sh index e6361aa..b7c25e7 100755 --- a/scripts/zellij-ai-fleet-deploy.sh +++ b/scripts/zellij-ai-fleet-deploy.sh @@ -196,32 +196,7 @@ probe_seed() { render_scan_json() { local candidates_file="$1" - python3 - "$candidates_file" <<'PY' -import json -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -candidates = [] -for raw in path.read_text(encoding="utf-8").splitlines(): - if not raw.strip(): - continue - seed, user, host, port, reachable, remote_name, remote_os, remote_home, error = raw.split("\t", 8) - candidates.append( - { - "seed": seed, - "user": user, - "host": host, - "port": int(port), - "reachable": reachable == "yes", - "remote_name": remote_name, - "remote_os": remote_os, - "remote_home": remote_home, - "error": error, - } - ) -print(json.dumps({"candidates": candidates}, indent=2)) -PY + "$script_dir/Constant" fleet render-scan-json "$candidates_file" } print_scan_table() { @@ -426,55 +401,7 @@ write_fleet_config() { local finalized_file="$1" local output_path="$2" local repo_dir="$3" - - python3 - "$finalized_file" "$output_path" "$repo_dir" <<'PY' -import json -import sys -from pathlib import Path - -finalized = Path(sys.argv[1]) -output = Path(sys.argv[2]).expanduser() -repo_dir = sys.argv[3] - -machines = [] -local_machine = None -for raw in finalized.read_text(encoding="utf-8").splitlines(): - if not raw.strip(): - continue - label, role, user, seed = raw.split("\t", 3) - if role == "local": - local_machine = label - machines.append( - { - "label": label, - "target": "local", - "auto_clis": ["codex", "vibe", "claude"], - "manual_clis": ["copilot"], - "backends": ["omc", "cli-local", "cockpit"], - } - ) - else: - machines.append( - { - "label": label, - "target": f"{user}@{seed}", - "auto_clis": ["codex", "vibe", "claude"], - "manual_clis": ["copilot"], - "backends": ["cli-ssh", "cockpit"], - } - ) - -payload = { - "version": 1, - "local_machine": local_machine or "command-center", - "repo_dir": repo_dir, - "machines": machines, -} - -output.parent.mkdir(parents=True, exist_ok=True) -output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") -print(str(output)) -PY + "$script_dir/Constant" fleet write-config "$finalized_file" --output "$output_path" --repo-dir "$repo_dir" } run_install() { @@ -583,7 +510,6 @@ if [[ -z "$default_user" ]]; then fi zellij_ai_require_command ssh -zellij_ai_require_command python3 candidates_file="$(discover_candidates "$explicit_hosts_file" "$default_user" "$use_ssh_config" "$use_known_hosts" "$use_arp")" rm -f "$explicit_hosts_file" diff --git a/src/chat.rs b/src/chat.rs new file mode 100644 index 0000000..007833e --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,558 @@ +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use serde::{Deserialize, Serialize}; + +use crate::paths; +use crate::state::{list_missions, load_mission, now_utc}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChatEntry { + pub timestamp: String, + pub role: String, + pub content: String, + pub intent: String, + pub workspace: String, + pub mission_id: Option, + pub machine: Option, + pub pane: Option, + #[serde(default)] + pub meta: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ThreadIndex { + pub thread_key: String, + pub workspace: String, + pub mission_id: Option, + pub kind: String, + pub title: String, + pub mission_status: Option, + pub message_count: u64, + pub last_role: String, + pub last_preview: String, + pub last_timestamp: String, + pub chat_mtime: u64, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct ThreadViewState { + #[serde(default)] + pub seen_counts: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ThreadSummary { + pub thread_key: String, + pub workspace: String, + pub mission_id: Option, + pub kind: String, + pub title: String, + pub mission_status: Option, + pub message_count: u64, + pub unread_count: u64, + pub last_role: String, + pub last_preview: String, + pub last_timestamp: String, +} + +pub fn resolve_workspace(workspace: &str) -> Result { + let expanded = paths::expand_home_string(workspace); + match expanded.canonicalize() { + Ok(path) => Ok(path.display().to_string()), + Err(_) => Ok(expanded.display().to_string()), + } +} + +pub fn workspace_chat_slug(workspace: &str) -> Result { + let resolved = resolve_workspace(workspace)?; + let name = Path::new(&resolved) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("workspace"); + Ok(format!("{name}-{}", stable_hash_hex(&resolved)[..10].to_string())) +} + +pub fn thread_key(workspace: &str, mission_id: Option<&str>) -> Result { + if let Some(mission_id) = mission_id { + return Ok(format!("mission:{mission_id}")); + } + Ok(format!("workspace:{}", resolve_workspace(workspace)?)) +} + +pub fn chat_file(workspace: &str, mission_id: Option<&str>) -> Result { + paths::ensure_runtime_dirs()?; + if let Some(mission_id) = mission_id { + return Ok(paths::chat_root().join("missions").join(format!("{mission_id}.ndjson"))); + } + Ok(paths::chat_root().join("workspaces").join(format!( + "{}.ndjson", + workspace_chat_slug(workspace)? + ))) +} + +pub fn append_chat_message( + role: &str, + content: &str, + workspace: &str, + mission_id: Option<&str>, + intent: Option<&str>, + machine: Option<&str>, + pane: Option<&str>, + meta: Option>, +) -> Result { + let resolved_workspace = resolve_workspace(workspace)?; + let path = chat_file(&resolved_workspace, mission_id)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + + let entry = ChatEntry { + timestamp: now_utc(), + role: role.to_string(), + content: content.to_string(), + intent: intent.unwrap_or("plain_chat").to_string(), + workspace: resolved_workspace.clone(), + mission_id: mission_id.map(ToString::to_string), + machine: machine.map(ToString::to_string), + pane: pane.map(ToString::to_string), + meta: meta.unwrap_or_default(), + }; + + let line = serde_json::to_string(&entry).map_err(|err| format!("cannot encode chat entry: {err}"))?; + let mut handle = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|err| format!("cannot open {}: {err}", path.display()))?; + handle + .write_all(format!("{line}\n").as_bytes()) + .map_err(|err| format!("cannot append {}: {err}", path.display()))?; + + let mission = mission_id.map(load_mission).transpose()?; + let thread_index = refresh_thread_index( + &resolved_workspace, + mission_id, + mission + .as_ref() + .map(|value| value.title.as_str()) + .unwrap_or("Workspace chat"), + mission.as_ref().map(|value| value.status.as_str()), + )?; + if thread_index.is_none() { + return Err(format!("failed to update thread index for {}", path.display())); + } + + Ok(entry) +} + +pub fn read_chat_history( + workspace: &str, + mission_id: Option<&str>, + limit: usize, +) -> Result, String> { + let path = chat_file(workspace, mission_id)?; + if !path.exists() { + return Ok(Vec::new()); + } + + let raw_lines = tail_lines(&path, limit, 256 * 1024)?; + let mut entries = Vec::new(); + for raw in raw_lines { + if raw.trim().is_empty() { + continue; + } + if let Ok(entry) = serde_json::from_str::(&raw) { + entries.push(entry); + } + } + Ok(entries) +} + +pub fn refresh_thread_index( + workspace: &str, + mission_id: Option<&str>, + title: &str, + mission_status: Option<&str>, +) -> Result, String> { + let resolved_workspace = resolve_workspace(workspace)?; + let path = chat_file(&resolved_workspace, mission_id)?; + if !path.exists() { + remove_thread_index(&resolved_workspace, mission_id)?; + return Ok(None); + } + + let mtime = file_mtime_secs(&path)?; + if let Some(index) = load_thread_index(&resolved_workspace, mission_id)? { + if index.chat_mtime >= mtime { + let mut refreshed = index; + refreshed.title = title.to_string(); + refreshed.mission_status = mission_status.map(ToString::to_string); + save_thread_index(&refreshed)?; + return Ok(Some(refreshed)); + } + } + + let mut line_count = 0_u64; + let mut last_entry: Option = None; + let file = File::open(&path).map_err(|err| format!("cannot open {}: {err}", path.display()))?; + for raw in BufReader::new(file).lines() { + let raw = raw.map_err(|err| format!("cannot read {}: {err}", path.display()))?; + if raw.trim().is_empty() { + continue; + } + line_count += 1; + if let Ok(entry) = serde_json::from_str::(&raw) { + last_entry = Some(entry); + } + } + + let Some(last_entry) = last_entry else { + return Ok(None); + }; + let index = ThreadIndex { + thread_key: thread_key(&resolved_workspace, mission_id)?, + workspace: resolved_workspace.clone(), + mission_id: mission_id.map(ToString::to_string), + kind: if mission_id.is_some() { + "mission".to_string() + } else { + "workspace".to_string() + }, + title: title.to_string(), + mission_status: mission_status.map(ToString::to_string), + message_count: line_count, + last_role: last_entry.role.clone(), + last_preview: content_preview(&last_entry.content, 96), + last_timestamp: last_entry.timestamp.clone(), + chat_mtime: mtime, + updated_at: now_utc(), + }; + save_thread_index(&index)?; + Ok(Some(index)) +} + +pub fn list_thread_summaries(workspace: &str) -> Result, String> { + let resolved_workspace = resolve_workspace(workspace)?; + let view_state = load_thread_view_state(&resolved_workspace)?; + let workspace_index = refresh_thread_index(&resolved_workspace, None, "Workspace chat", None)?; + let workspace_summary = workspace_index.map(|index| to_summary(index, &view_state)); + + let mut missions = list_missions()? + .into_iter() + .filter(|mission| mission.workspace == resolved_workspace) + .collect::>(); + missions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + + let mut summaries = Vec::new(); + if let Some(summary) = workspace_summary { + summaries.push(summary); + } else { + summaries.push(ThreadSummary { + thread_key: thread_key(&resolved_workspace, None)?, + workspace: resolved_workspace.clone(), + mission_id: None, + kind: "workspace".to_string(), + title: "Workspace chat".to_string(), + mission_status: None, + message_count: 0, + unread_count: 0, + last_role: String::new(), + last_preview: "Start a conversation".to_string(), + last_timestamp: String::new(), + }); + } + + for mission in missions { + if let Some(index) = refresh_thread_index( + &resolved_workspace, + Some(&mission.mission_id), + &mission.title, + Some(&mission.status), + )? { + summaries.push(to_summary(index, &view_state)); + } + } + + Ok(summaries) +} + +pub fn latest_chat_summary(workspace: &str) -> Result, String> { + let mut summaries = list_thread_summaries(workspace)?; + summaries.retain(|summary| summary.message_count > 0); + summaries.sort_by(|left, right| { + right + .last_timestamp + .cmp(&left.last_timestamp) + .then_with(|| right.message_count.cmp(&left.message_count)) + }); + Ok(summaries.into_iter().next()) +} + +pub fn render_chat_dock_line( + workspace: &str, + scope_label: Option<&str>, + machine_label: Option<&str>, + max_length: usize, +) -> Result { + let workspace_name = Path::new(&resolve_workspace(workspace)?) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("workspace") + .to_string(); + let mut context = vec!["workspace".to_string(), workspace_name]; + if let Some(scope_label) = scope_label.filter(|value| !value.is_empty()) { + context.insert(0, scope_label.to_string()); + } else if let Some(machine_label) = machine_label.filter(|value| !value.is_empty()) { + context.insert(0, machine_label.to_string()); + } + + let line = if let Some(summary) = latest_chat_summary(workspace)? { + let thread_prefix = if summary.kind == "mission" { + "latest mission" + } else { + "latest chat" + }; + format!( + "{} | {}: {} | {}: {}", + context.join(" | "), + thread_prefix, + summary.title, + role_label(&summary.last_role), + summary.last_preview + ) + } else { + format!( + "{} | No chats yet. Open Constant to start.", + context.join(" | ") + ) + }; + Ok(clip_line(&line, max_length)) +} + +pub fn load_thread_view_state(workspace: &str) -> Result { + let path = thread_view_state_path(workspace)?; + if !path.exists() { + return Ok(ThreadViewState::default()); + } + let text = fs::read_to_string(&path).map_err(|err| format!("cannot read {}: {err}", path.display()))?; + serde_json::from_str(&text).map_err(|err| format!("cannot parse {}: {err}", path.display())) +} + +pub fn mark_thread_seen(workspace: &str, thread_key: &str, seen_count: u64) -> Result<(), String> { + let path = thread_view_state_path(workspace)?; + let mut view_state = load_thread_view_state(workspace)?; + view_state + .seen_counts + .insert(thread_key.to_string(), seen_count); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + let payload = serde_json::to_string_pretty(&view_state) + .map_err(|err| format!("cannot encode thread view state: {err}"))?; + fs::write(&path, format!("{payload}\n")) + .map_err(|err| format!("cannot write {}: {err}", path.display())) +} + +pub fn delete_mission_thread(mission_id: &str) -> Result<(), String> { + paths::ensure_runtime_dirs()?; + let mission = load_mission(mission_id)?; + if matches!(mission.status.as_str(), "draft" | "planned" | "running") { + return Err(format!( + "Mission {mission_id} is {} and cannot be deleted yet.", + mission.status + )); + } + + let workspace = resolve_workspace(&mission.workspace)?; + let mission_chat = chat_file(&workspace, Some(mission_id))?; + if mission_chat.exists() { + fs::remove_file(&mission_chat) + .map_err(|err| format!("cannot remove {}: {err}", mission_chat.display()))?; + } + let mission_dir = paths::mission_dir(mission_id); + if mission_dir.exists() { + fs::remove_dir_all(&mission_dir) + .map_err(|err| format!("cannot remove {}: {err}", mission_dir.display()))?; + } + remove_thread_index(&workspace, Some(mission_id))?; + + let thread_key = thread_key(&workspace, Some(mission_id))?; + let mut view_state = load_thread_view_state(&workspace)?; + if view_state.seen_counts.remove(&thread_key).is_some() { + let path = thread_view_state_path(&workspace)?; + let payload = serde_json::to_string_pretty(&view_state) + .map_err(|err| format!("cannot encode thread view state: {err}"))?; + fs::write(&path, format!("{payload}\n")) + .map_err(|err| format!("cannot write {}: {err}", path.display()))?; + } + + Ok(()) +} + +fn to_summary(index: ThreadIndex, view_state: &ThreadViewState) -> ThreadSummary { + let seen_count = view_state + .seen_counts + .get(&index.thread_key) + .copied() + .unwrap_or(0); + ThreadSummary { + unread_count: index.message_count.saturating_sub(seen_count), + thread_key: index.thread_key, + workspace: index.workspace, + mission_id: index.mission_id, + kind: index.kind, + title: index.title, + mission_status: index.mission_status, + message_count: index.message_count, + last_role: index.last_role, + last_preview: if index.last_preview.is_empty() { + "Start a conversation".to_string() + } else { + index.last_preview + }, + last_timestamp: index.last_timestamp, + } +} + +fn thread_index_path(workspace: &str, mission_id: Option<&str>) -> Result { + if let Some(mission_id) = mission_id { + return Ok(paths::chat_threads_index_dir().join(format!("mission-{mission_id}.json"))); + } + Ok(paths::chat_threads_index_dir().join(format!( + "workspace-{}.json", + workspace_chat_slug(workspace)? + ))) +} + +fn remove_thread_index(workspace: &str, mission_id: Option<&str>) -> Result<(), String> { + let path = thread_index_path(workspace, mission_id)?; + if path.exists() { + fs::remove_file(&path).map_err(|err| format!("cannot remove {}: {err}", path.display()))?; + } + Ok(()) +} + +fn load_thread_index(workspace: &str, mission_id: Option<&str>) -> Result, String> { + let path = thread_index_path(workspace, mission_id)?; + if !path.exists() { + return Ok(None); + } + let text = fs::read_to_string(&path).map_err(|err| format!("cannot read {}: {err}", path.display()))?; + let payload = serde_json::from_str(&text).map_err(|err| format!("cannot parse {}: {err}", path.display()))?; + Ok(Some(payload)) +} + +fn save_thread_index(index: &ThreadIndex) -> Result<(), String> { + let path = thread_index_path(&index.workspace, index.mission_id.as_deref())?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + let payload = serde_json::to_string_pretty(index) + .map_err(|err| format!("cannot encode thread index: {err}"))?; + fs::write(&path, format!("{payload}\n")) + .map_err(|err| format!("cannot write {}: {err}", path.display())) +} + +fn thread_view_state_path(workspace: &str) -> Result { + Ok(paths::chat_views_dir().join(format!( + "workspace-{}.json", + workspace_chat_slug(workspace)? + ))) +} + +fn role_label(role: &str) -> &'static str { + match role { + "user" => "YOU", + "constant" => "CONSTANT", + "buddy" => "BUDDY", + "system" => "SYSTEM", + _ => "CHAT", + } +} + +fn content_preview(text: &str, limit: usize) -> String { + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.len() <= limit { + return normalized; + } + format!("{}...", normalized.chars().take(limit.saturating_sub(3)).collect::().trim_end()) +} + +fn clip_line(line: &str, max_length: usize) -> String { + if line.len() <= max_length { + return line.to_string(); + } + format!( + "{}...", + line.chars() + .take(max_length.saturating_sub(3)) + .collect::() + .trim_end() + ) +} + +fn tail_lines(path: &Path, limit: usize, max_bytes: usize) -> Result, String> { + let mut file = File::open(path).map_err(|err| format!("cannot open {}: {err}", path.display()))?; + let file_len = file + .metadata() + .map_err(|err| format!("cannot stat {}: {err}", path.display()))? + .len() as usize; + let mut chunk = 8192_usize; + let mut consumed = 0_usize; + let mut buffer = Vec::new(); + + while consumed < file_len && consumed < max_bytes { + let next = chunk.min(file_len - consumed).min(max_bytes - consumed); + consumed += next; + let offset = (file_len - consumed) as u64; + file.seek(SeekFrom::Start(offset)) + .map_err(|err| format!("cannot seek {}: {err}", path.display()))?; + let mut bytes = vec![0_u8; next]; + file.read_exact(&mut bytes) + .map_err(|err| format!("cannot read {}: {err}", path.display()))?; + bytes.extend(buffer); + buffer = bytes; + let newline_count = buffer.iter().filter(|byte| **byte == b'\n').count(); + if newline_count > limit { + break; + } + chunk = (chunk * 2).min(max_bytes.max(1)); + } + + let text = String::from_utf8_lossy(&buffer); + let mut lines = text.lines().map(ToString::to_string).collect::>(); + if lines.len() > limit { + lines = lines.split_off(lines.len() - limit); + } + Ok(lines) +} + +fn file_mtime_secs(path: &Path) -> Result { + let modified = path + .metadata() + .and_then(|meta| meta.modified()) + .map_err(|err| format!("cannot stat {}: {err}", path.display()))?; + Ok(modified + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0)) +} + +fn stable_hash_hex(value: &str) -> String { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in value.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} diff --git a/src/cockpit.rs b/src/cockpit.rs index b48ca70..11a5068 100644 --- a/src/cockpit.rs +++ b/src/cockpit.rs @@ -21,6 +21,8 @@ pub struct PaneStatus { pub active: bool, pub dead: bool, pub dead_status: i32, + pub autorestart_failures: u32, + pub autorestart_disabled: bool, } #[derive(Debug, Clone, Serialize)] @@ -141,6 +143,23 @@ pub fn cockpit_doctor(local_session: &str, machine_session: &str) -> Result, + machine_label: Option<&str>, + max_length: usize, + local_session: &str, + machine_session: &str, +) -> Result { + let chat_line = + crate::chat::render_chat_dock_line(workspace, scope_label, machine_label, max_length)?; + let runtime = runtime_status(local_session, machine_session)?; + if let Some(warning) = autorestart_warning(&runtime) { + return Ok(clip_line(&format!("{chat_line} | {warning}"), max_length)); + } + Ok(chat_line) +} + pub fn cockpit_open( workspace: &str, local_session: &str, @@ -325,7 +344,7 @@ fn parse_panes(stdout: &str) -> Vec { .lines() .filter_map(|raw| { let parts = raw.split('\t').collect::>(); - if parts.len() != 10 { + if parts.len() != 12 { return None; } let role = if !parts[4].is_empty() { @@ -347,6 +366,8 @@ fn parse_panes(stdout: &str) -> Vec { active: parts[7] == "1", dead: parts[8] == "1", dead_status: parts[9].parse().unwrap_or(0), + autorestart_failures: parts[10].parse().unwrap_or(0), + autorestart_disabled: parts[11] == "1", }) }) .collect::>(); @@ -361,10 +382,38 @@ fn tmux_list_command(session_name: &str) -> Vec { "-t".to_string(), session_name.to_string(), "-F".to_string(), - "#{session_name}\t#{window_name}\t#{pane_id}\t#{pane_index}\t#{@constant_role}\t#{pane_title}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}\t#{pane_dead_status}".to_string(), + "#{session_name}\t#{window_name}\t#{pane_id}\t#{pane_index}\t#{@constant_role}\t#{pane_title}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}\t#{pane_dead_status}\t#{@constant_restart_failures}\t#{@constant_autorestart_disabled}".to_string(), ] } +fn autorestart_warning(runtime: &FleetRuntimeStatus) -> Option { + for machine in &runtime.machines { + for pane in &machine.panes { + if pane.autorestart_disabled { + return Some(format!( + "respawn disabled after {} failures: {}", + pane.autorestart_failures.max(1), + pane.role + )); + } + } + } + None +} + +fn clip_line(line: &str, max_length: usize) -> String { + if line.len() <= max_length { + return line.to_string(); + } + format!( + "{}...", + line.chars() + .take(max_length.saturating_sub(3)) + .collect::() + .trim_end() + ) +} + fn ssh_command(target: &str, inner: &str) -> Vec { vec![ "ssh".to_string(), diff --git a/src/config.rs b/src/config.rs index cc2f9b3..1fc2a4c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -173,6 +173,23 @@ pub fn load_fleet_config() -> Result { Ok(config) } +pub fn read_fleet_config_if_present() -> Result, String> { + paths::ensure_runtime_dirs()?; + + let mut config = if paths::fleet_toml_path().exists() { + parse_toml_file(&paths::fleet_toml_path())? + } else if paths::fleet_json_path().exists() { + parse_json_file(&paths::fleet_json_path())? + } else if paths::fleet_yaml_path().exists() { + parse_json_file(&paths::fleet_yaml_path())? + } else { + return Ok(None); + }; + + normalize_fleet(&mut config); + Ok(Some(config)) +} + pub fn load_models_config() -> Result { let config = load_with_legacy( &paths::models_toml_path(), @@ -212,6 +229,24 @@ pub fn save_memory_config(config: &MemoryConfig) -> Result<(), String> { ) } +pub fn write_fleet_config_file(path: &Path, config: &FleetConfig) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + + let ext = path.extension().and_then(|value| value.to_str()).unwrap_or(""); + let text = if ext.eq_ignore_ascii_case("toml") { + toml::to_string_pretty(config).map_err(|err| format!("cannot encode TOML: {err}"))? + } else { + serde_json::to_string_pretty(config) + .map_err(|err| format!("cannot encode JSON: {err}"))? + }; + + fs::write(path, format!("{text}\n")) + .map_err(|err| format!("cannot write {}: {err}", path.display())) +} + pub fn fleet_machine<'a>( fleet: &'a FleetConfig, needle: &str, diff --git a/src/fleet.rs b/src/fleet.rs index 196e565..d256390 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -1,5 +1,9 @@ +use std::fs; +use std::path::{Path, PathBuf}; + use serde_json::{Value, json}; +use crate::config::{FleetConfig, MachineConfig, read_fleet_config_if_present, write_fleet_config_file}; use crate::paths; pub fn fleet_check() -> Result { @@ -54,3 +58,122 @@ pub fn bridge_sync() -> Result { "stderr": String::from_utf8_lossy(&output.stderr), })) } + +pub fn fleet_config_query(expr: &str) -> Result>, String> { + let Some(config) = read_fleet_config_if_present()? else { + return Ok(None); + }; + + let values = match expr { + "repo_dir" => vec![config.repo_dir], + "local_machine" => vec![config.local_machine], + "machine_specs" => config + .machines + .into_iter() + .map(|machine| format!("{}={}", machine.label, machine.target)) + .collect(), + other => return Err(format!("unknown fleet config query: {other}")), + }; + + Ok(Some(values)) +} + +pub fn render_scan_json(path: &Path) -> Result { + let text = + fs::read_to_string(path).map_err(|err| format!("cannot read {}: {err}", path.display()))?; + let mut candidates = Vec::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() { + continue; + } + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() != 9 { + return Err(format!( + "cannot parse {}: expected 9 tab-separated fields, got {}", + path.display(), + fields.len() + )); + } + candidates.push(json!({ + "seed": fields[0], + "user": fields[1], + "host": fields[2], + "port": fields[3].parse::().map_err(|_| { + format!("cannot parse port '{}' in {}", fields[3], path.display()) + })?, + "reachable": fields[4] == "yes", + "remote_name": fields[5], + "remote_os": fields[6], + "remote_home": fields[7], + "error": fields[8], + })); + } + Ok(json!({ "candidates": candidates })) +} + +pub fn write_fleet_config( + finalized_file: &Path, + output_path: &Path, + repo_dir: &str, +) -> Result { + let text = fs::read_to_string(finalized_file) + .map_err(|err| format!("cannot read {}: {err}", finalized_file.display()))?; + let mut machines = Vec::new(); + let mut local_machine: Option = None; + + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() { + continue; + } + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() != 4 { + return Err(format!( + "cannot parse {}: expected 4 tab-separated fields, got {}", + finalized_file.display(), + fields.len() + )); + } + let label = fields[0].to_string(); + let role = fields[1]; + let user = fields[2]; + let seed = fields[3]; + + if role == "local" { + local_machine = Some(label.clone()); + machines.push(MachineConfig { + label, + target: "local".to_string(), + auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()], + manual_clis: vec!["copilot".into()], + backends: vec!["omc".into(), "cli-local".into(), "cockpit".into()], + }); + } else { + machines.push(MachineConfig { + label, + target: format!("{user}@{seed}"), + auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()], + manual_clis: vec!["copilot".into()], + backends: vec!["cli-ssh".into(), "cockpit".into()], + }); + } + } + + let config = FleetConfig { + version: 1, + local_machine: local_machine.unwrap_or_else(|| "command-center".to_string()), + repo_dir: repo_dir.to_string(), + machines, + }; + + let resolved_output = if output_path.is_absolute() { + output_path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|err| format!("cannot resolve current directory: {err}"))? + .join(output_path) + }; + write_fleet_config_file(&resolved_output, &config)?; + Ok(resolved_output) +} diff --git a/src/main.rs b/src/main.rs index eaea9ea..814abe3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,18 @@ mod buddy; mod capabilities; +mod chat; mod cockpit; mod config; mod executor; mod fleet; mod memory; mod mission; +mod operator; mod paths; mod state; mod tui; use std::env; -use std::ffi::OsString; use std::fs; use std::io::IsTerminal; use std::path::{Path, PathBuf}; @@ -23,15 +24,22 @@ use serde_json::{Value, json}; use buddy::buddy_ask; use capabilities::{AGENTS, MINIMAL_STACK, SKILLS, VERSION, WORKFLOW_STACK}; +use chat::delete_mission_thread; use cockpit::{ - capture_pane, cockpit_attach, cockpit_doctor, cockpit_open, focus_machine, restart_pane, - runtime_status, send_to_pane, + capture_pane, cockpit_attach, cockpit_doctor, cockpit_open, cockpit_status_line, + focus_machine, restart_pane, runtime_status, send_to_pane, }; use config::{fleet_machine, load_fleet_config, load_memory_config, load_models_config}; use executor::{run_mission, summarize_mission_command, verify_mission}; -use fleet::{bridge_sync, fleet_check}; -use memory::summarize_mission_to_memory; +use fleet::{ + bridge_sync, fleet_check, fleet_config_query, render_scan_json, write_fleet_config, +}; +use memory::{ + enroll_workspace, list_decisions, memory_status, persona_markdown, rebuild_workspace_memory, + search_memory, summarize_mission_to_memory, sync_qdrant, +}; use mission::{delegate_step, plan_mission, retry_mission}; +use operator::{health_value, models_status_value}; use state::{ append_event, list_missions, load_mission, mission_events_text, mission_summary_value, new_mission, save_mission, @@ -65,15 +73,16 @@ fn run() -> Result { } "skills" => handle_skills(&args[1..]), "agents" => handle_agents(&args[1..]), + "models" => handle_models(&args[1..]), "doctor" => handle_doctor(&args[1..]), "tui" => handle_tui(&args[1..]), "cockpit" => handle_cockpit(&args[1..]), "mission" => handle_mission(&args[1..]), "delegate" => handle_delegate(&args[1..]), "buddy" => handle_buddy(&args[1..]), - "memory" => handoff_to_python(&args), + "memory" => handle_memory(&args[1..]), "fleet" => handle_fleet(&args[1..]), - _ => handoff_to_python(&args), + other => Err(format!("unknown command: {other}")), } } @@ -104,21 +113,24 @@ Rust front-controller for Constant. Commands handled in Rust: doctor [--json] + models status [--json] agents [--json] skills [--json] [--public-only] tui [--workspace DIR] [--local-session NAME] [--session NAME] - cockpit open|attach|status|doctor|focus|send|capture|restart + cockpit open|attach|status|status-line|doctor|focus|send|capture|restart mission create [--workspace DIR] [--json] mission plan [--json] mission run [--json] + mission delete [--json] mission status [mission_id] [--verbose] [--json] mission tail [--follow] mission verify [--step-id ID] [--json] mission retry [--step-id ID] [--json] mission summarize [--json] delegate [--step-id ID] [--machine LABEL] [--backend NAME] [--cli NAME] [--agent ID] [--skill ID] [--json] + buddy ask [--mission-id ID] [--json] + memory status|rebuild|enroll|search|persona show|decisions|sync-qdrant -Other commands still hand off to the existing Python runtime during migration. Running `{prog}` with no arguments opens or attaches the full fleet cockpit. Use `{prog} tui --workspace DIR` for the standalone TUI." ); @@ -135,19 +147,29 @@ fn handle_skills(args: &[String]) -> Result { match arg.as_str() { "--json" => as_json = true, "--public-only" => public_only = true, - _ => return handoff_to_python_with_prefix("skills", args), + other => return Err(format!("unknown skills option: {other}")), } } print_skills(as_json, public_only)?; Ok(ExitCode::SUCCESS) } +fn handle_models(args: &[String]) -> Result { + if args.is_empty() { + return Err("models requires a subcommand".to_string()); + } + match args[0].as_str() { + "status" => models_status_cmd(&args[1..]), + other => Err(format!("unknown models subcommand: {other}")), + } +} + fn handle_agents(args: &[String]) -> Result { let mut as_json = false; for arg in args { match arg.as_str() { "--json" => as_json = true, - _ => return handoff_to_python_with_prefix("agents", args), + other => return Err(format!("unknown agents option: {other}")), } } print_agents(as_json)?; @@ -159,13 +181,25 @@ fn handle_doctor(args: &[String]) -> Result { for arg in args { match arg.as_str() { "--json" => as_json = true, - _ => return handoff_to_python_with_prefix("doctor", args), + other => return Err(format!("unknown doctor option: {other}")), } } print_doctor(as_json)?; Ok(ExitCode::SUCCESS) } +fn models_status_cmd(args: &[String]) -> Result { + let mut as_json = false; + for arg in args { + match arg.as_str() { + "--json" => as_json = true, + other => return Err(format!("unknown models status option: {other}")), + } + } + print_value(&models_status_value()?, as_json)?; + Ok(ExitCode::SUCCESS) +} + fn handle_tui(args: &[String]) -> Result { let mut workspace = env::current_dir() .map_err(|err| format!("cannot resolve current directory: {err}"))? @@ -198,17 +232,13 @@ fn handle_tui(args: &[String]) -> Result { .cloned() .ok_or_else(|| "--session requires a value".to_string())?; } - _ => return handoff_to_python_with_prefix("tui", args), + other => return Err(format!("unknown tui option: {other}")), } index += 1; } let workspace = canonical_workspace(&workspace)?; - match run_tui( - workspace.clone(), - local_session.clone(), - machine_session.clone(), - )? { + match run_tui(workspace.clone(), local_session.clone(), machine_session.clone())? { TuiAction::Exit => Ok(ExitCode::SUCCESS), TuiAction::OpenCockpit => Ok(exit_code_from_status(Some(cockpit_open( &workspace, @@ -228,16 +258,13 @@ fn handle_cockpit(args: &[String]) -> Result { "open" => cockpit_open_cmd(&args[1..]), "attach" => cockpit_attach_cmd(&args[1..]), "status" => cockpit_status_cmd(&args[1..]), + "status-line" => cockpit_status_line_cmd(&args[1..]), "doctor" => cockpit_doctor_cmd(&args[1..]), "focus" => cockpit_focus_cmd(&args[1..]), "send" => cockpit_send_cmd(&args[1..]), "capture" => cockpit_capture_cmd(&args[1..]), "restart" => cockpit_restart_cmd(&args[1..]), - _ => { - let mut forwarded = vec!["cockpit".to_string()]; - forwarded.extend(args.iter().cloned()); - handoff_to_python(&forwarded) - } + other => Err(format!("unknown cockpit subcommand: {other}")), } } @@ -250,16 +277,13 @@ fn handle_mission(args: &[String]) -> Result { "create" => mission_create(&args[1..]), "plan" => mission_plan(&args[1..]), "run" => mission_run_cmd(&args[1..]), + "delete" => mission_delete_cmd(&args[1..]), "status" => mission_status(&args[1..]), "tail" => mission_tail(&args[1..]), "verify" => mission_verify_cmd(&args[1..]), "retry" => mission_retry_cmd(&args[1..]), "summarize" => mission_summarize_cmd(&args[1..]), - _ => { - let mut forwarded = vec!["mission".to_string()]; - forwarded.extend(args.iter().cloned()); - handoff_to_python(&forwarded) - } + other => Err(format!("unknown mission subcommand: {other}")), } } @@ -269,11 +293,23 @@ fn handle_buddy(args: &[String]) -> Result { } match args[0].as_str() { "ask" => buddy_ask_cmd(&args[1..]), - _ => { - let mut forwarded = vec!["buddy".to_string()]; - forwarded.extend(args.iter().cloned()); - handoff_to_python(&forwarded) - } + other => Err(format!("unknown buddy subcommand: {other}")), + } +} + +fn handle_memory(args: &[String]) -> Result { + if args.is_empty() { + return Err("memory requires a subcommand".to_string()); + } + match args[0].as_str() { + "status" => memory_status_cmd(&args[1..]), + "rebuild" => memory_rebuild_cmd(&args[1..]), + "enroll" => memory_enroll_cmd(&args[1..]), + "search" => memory_search_cmd(&args[1..]), + "persona" => memory_persona_cmd(&args[1..]), + "decisions" => memory_decisions_cmd(&args[1..]), + "sync-qdrant" => memory_sync_qdrant_cmd(&args[1..]), + other => Err(format!("unknown memory subcommand: {other}")), } } @@ -284,11 +320,10 @@ fn handle_fleet(args: &[String]) -> Result { match args[0].as_str() { "status" => fleet_status_cmd(&args[1..]), "sync" => fleet_sync_cmd(&args[1..]), - _ => { - let mut forwarded = vec!["fleet".to_string()]; - forwarded.extend(args.iter().cloned()); - handoff_to_python(&forwarded) - } + "config-get" => fleet_config_get_cmd(&args[1..]), + "render-scan-json" => fleet_render_scan_json_cmd(&args[1..]), + "write-config" => fleet_write_config_cmd(&args[1..]), + other => Err(format!("unknown fleet subcommand: {other}")), } } @@ -311,12 +346,10 @@ fn mission_create(args: &[String]) -> Result { .ok_or_else(|| "--workspace requires a value".to_string())?; } "--json" => as_json = true, - value if value.starts_with("--") => { - return handoff_mission_subcommand("create", args); - } + value if value.starts_with("--") => return Err(format!("unknown mission create option: {value}")), value => { if prompt.is_some() { - return handoff_mission_subcommand("create", args); + return Err("mission create accepts a single prompt".to_string()); } prompt = Some(value.to_string()); } @@ -360,10 +393,10 @@ fn mission_plan(args: &[String]) -> Result { for arg in args { match arg.as_str() { "--json" => as_json = true, - value if value.starts_with("--") => return handoff_mission_subcommand("plan", args), + value if value.starts_with("--") => return Err(format!("unknown mission plan option: {value}")), value => { if mission_id.is_some() { - return handoff_mission_subcommand("plan", args); + return Err("mission plan accepts a single mission_id".to_string()); } mission_id = Some(value.to_string()); } @@ -402,10 +435,10 @@ fn mission_run_cmd(args: &[String]) -> Result { for arg in args { match arg.as_str() { "--json" => as_json = true, - value if value.starts_with("--") => return handoff_mission_subcommand("run", args), + value if value.starts_with("--") => return Err(format!("unknown mission run option: {value}")), value => { if mission_id.is_some() { - return handoff_mission_subcommand("run", args); + return Err("mission run accepts a single mission_id".to_string()); } mission_id = Some(value.to_string()); } @@ -427,10 +460,10 @@ fn mission_status(args: &[String]) -> Result { match arg.as_str() { "--verbose" => verbose = true, "--json" => as_json = true, - value if value.starts_with("--") => return handoff_mission_subcommand("status", args), + value if value.starts_with("--") => return Err(format!("unknown mission status option: {value}")), value => { if mission_id.is_some() { - return handoff_mission_subcommand("status", args); + return Err("mission status accepts at most one mission_id".to_string()); } mission_id = Some(value.to_string()); } @@ -464,10 +497,10 @@ fn mission_tail(args: &[String]) -> Result { for arg in args { match arg.as_str() { "--follow" => follow = true, - value if value.starts_with("--") => return handoff_mission_subcommand("tail", args), + value if value.starts_with("--") => return Err(format!("unknown mission tail option: {value}")), value => { if mission_id.is_some() { - return handoff_mission_subcommand("tail", args); + return Err("mission tail accepts a single mission_id".to_string()); } mission_id = Some(value.to_string()); } @@ -513,10 +546,10 @@ fn mission_verify_cmd(args: &[String]) -> Result { ); } "--json" => as_json = true, - value if value.starts_with("--") => return handoff_mission_subcommand("verify", args), + value if value.starts_with("--") => return Err(format!("unknown mission verify option: {value}")), value => { if mission_id.is_some() { - return handoff_mission_subcommand("verify", args); + return Err("mission verify accepts a single mission_id".to_string()); } mission_id = Some(value.to_string()); } @@ -549,10 +582,10 @@ fn mission_retry_cmd(args: &[String]) -> Result { ); } "--json" => as_json = true, - value if value.starts_with("--") => return handoff_mission_subcommand("retry", args), + value if value.starts_with("--") => return Err(format!("unknown mission retry option: {value}")), value => { if mission_id.is_some() { - return handoff_mission_subcommand("retry", args); + return Err("mission retry accepts a single mission_id".to_string()); } mission_id = Some(value.to_string()); } @@ -575,12 +608,10 @@ fn mission_summarize_cmd(args: &[String]) -> Result { for arg in args { match arg.as_str() { "--json" => as_json = true, - value if value.starts_with("--") => { - return handoff_mission_subcommand("summarize", args); - } + value if value.starts_with("--") => return Err(format!("unknown mission summarize option: {value}")), value => { if mission_id.is_some() { - return handoff_mission_subcommand("summarize", args); + return Err("mission summarize accepts a single mission_id".to_string()); } mission_id = Some(value.to_string()); } @@ -598,6 +629,27 @@ fn mission_summarize_cmd(args: &[String]) -> Result { Ok(ExitCode::SUCCESS) } +fn mission_delete_cmd(args: &[String]) -> Result { + let mut mission_id: Option = None; + let mut as_json = false; + for arg in args { + match arg.as_str() { + "--json" => as_json = true, + value if value.starts_with("--") => return Err(format!("unknown mission delete option: {value}")), + value => { + if mission_id.is_some() { + return Err("mission delete accepts a single mission_id".to_string()); + } + mission_id = Some(value.to_string()); + } + } + } + let mission_id = mission_id.ok_or_else(|| "mission delete requires a mission_id".to_string())?; + delete_mission_thread(&mission_id)?; + print_value(&json!({ "deleted": true, "mission_id": mission_id }), as_json)?; + Ok(ExitCode::SUCCESS) +} + fn handle_delegate(args: &[String]) -> Result { if args.is_empty() { return Err("delegate requires a mission_id".to_string()); @@ -663,10 +715,8 @@ fn handle_delegate(args: &[String]) -> Result { ); } "--json" => as_json = true, - value if value.starts_with("--") => { - return handoff_to_python_with_prefix("delegate", args); - } - _ => return handoff_to_python_with_prefix("delegate", args), + value if value.starts_with("--") => return Err(format!("unknown delegate option: {value}")), + value => return Err(format!("unexpected delegate argument: {value}")), } index += 1; } @@ -707,16 +757,10 @@ fn buddy_ask_cmd(args: &[String]) -> Result { ); } "--json" => as_json = true, - value if value.starts_with("--") => { - let mut forwarded = vec!["buddy".to_string(), "ask".to_string()]; - forwarded.extend(args.iter().cloned()); - return handoff_to_python(&forwarded); - } + value if value.starts_with("--") => return Err(format!("unknown buddy ask option: {value}")), value => { if prompt.is_some() { - let mut forwarded = vec!["buddy".to_string(), "ask".to_string()]; - forwarded.extend(args.iter().cloned()); - return handoff_to_python(&forwarded); + return Err("buddy ask accepts a single prompt".to_string()); } prompt = Some(value.to_string()); } @@ -731,6 +775,197 @@ fn buddy_ask_cmd(args: &[String]) -> Result { Ok(ExitCode::SUCCESS) } +fn memory_status_cmd(args: &[String]) -> Result { + let mut workspace: Option = None; + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + index += 1; + workspace = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--workspace requires a value".to_string())?, + ); + } + "--json" => as_json = true, + other => return Err(format!("unknown memory status option: {other}")), + } + index += 1; + } + print_value(&memory_status(workspace.as_deref())?, as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn memory_rebuild_cmd(args: &[String]) -> Result { + let mut workspace = env::current_dir() + .map_err(|err| format!("cannot resolve current directory: {err}"))? + .to_string_lossy() + .into_owned(); + let mut enroll = true; + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + index += 1; + workspace = args + .get(index) + .cloned() + .ok_or_else(|| "--workspace requires a value".to_string())?; + } + "--no-enroll" => enroll = false, + "--json" => as_json = true, + other => return Err(format!("unknown memory rebuild option: {other}")), + } + index += 1; + } + print_value(&rebuild_workspace_memory(&workspace, enroll)?, as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn memory_enroll_cmd(args: &[String]) -> Result { + let mut path: Option = None; + let mut as_json = false; + for arg in args { + match arg.as_str() { + "--json" => as_json = true, + value if value.starts_with("--") => return Err(format!("unknown memory enroll option: {value}")), + value => { + if path.is_some() { + return Err("memory enroll accepts a single path".to_string()); + } + path = Some(value.to_string()); + } + } + } + let path = path.ok_or_else(|| "memory enroll requires a path".to_string())?; + print_value(&enroll_workspace(&path)?, as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn memory_search_cmd(args: &[String]) -> Result { + let mut query: Option = None; + let mut workspace: Option = None; + let mut limit: Option = None; + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + index += 1; + workspace = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--workspace requires a value".to_string())?, + ); + } + "--limit" => { + index += 1; + limit = Some( + args.get(index) + .ok_or_else(|| "--limit requires a value".to_string())? + .parse::() + .map_err(|_| "--limit must be an integer".to_string())?, + ); + } + "--json" => as_json = true, + value if value.starts_with("--") => return Err(format!("unknown memory search option: {value}")), + value => { + if query.is_some() { + return Err("memory search accepts a single query".to_string()); + } + query = Some(value.to_string()); + } + } + index += 1; + } + let query = query.ok_or_else(|| "memory search requires a query".to_string())?; + print_value(&search_memory(&query, workspace.as_deref(), limit)?, as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn memory_persona_cmd(args: &[String]) -> Result { + if args.is_empty() { + return Err("memory persona requires a subcommand".to_string()); + } + match args[0].as_str() { + "show" => { + let as_json = args.iter().skip(1).any(|arg| arg == "--json"); + if args.iter().skip(1).any(|arg| arg != "--json") { + return Err("unknown memory persona show option".to_string()); + } + let persona = persona_markdown()?; + if as_json { + print_value(&json!({ "persona": persona }), true)?; + } else { + println!("{persona}"); + } + Ok(ExitCode::SUCCESS) + } + other => Err(format!("unknown memory persona subcommand: {other}")), + } +} + +fn memory_decisions_cmd(args: &[String]) -> Result { + let mut workspace: Option = None; + let mut mission_id: Option = None; + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + index += 1; + workspace = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--workspace requires a value".to_string())?, + ); + } + "--mission-id" => { + index += 1; + mission_id = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--mission-id requires a value".to_string())?, + ); + } + "--json" => as_json = true, + other => return Err(format!("unknown memory decisions option: {other}")), + } + index += 1; + } + print_value(&list_decisions(workspace.as_deref(), mission_id.as_deref())?, as_json)?; + Ok(ExitCode::SUCCESS) +} + +fn memory_sync_qdrant_cmd(args: &[String]) -> Result { + let mut workspace: Option = None; + let mut as_json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + index += 1; + workspace = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--workspace requires a value".to_string())?, + ); + } + "--json" => as_json = true, + other => return Err(format!("unknown memory sync-qdrant option: {other}")), + } + index += 1; + } + let payload = sync_qdrant(workspace.as_deref())?; + let ok = payload.get("ok").and_then(Value::as_bool).unwrap_or(false) + || payload.get("skipped").and_then(Value::as_bool).unwrap_or(false); + print_value(&payload, as_json)?; + Ok(exit_code_from_status(Some(if ok { 0 } else { 1 }))) +} + fn fleet_status_cmd(args: &[String]) -> Result { let as_json = args.iter().any(|arg| arg == "--json"); let payload = fleet_check()?; @@ -755,16 +990,63 @@ fn fleet_sync_cmd(args: &[String]) -> Result { Ok(exit_code_from_status(Some(if ok { 0 } else { 1 }))) } -fn handoff_mission_subcommand(prefix: &str, tail: &[String]) -> Result { - let mut args = vec!["mission".to_string(), prefix.to_string()]; - args.extend(tail.iter().cloned()); - handoff_to_python(&args) +fn fleet_config_get_cmd(args: &[String]) -> Result { + if args.len() != 1 { + return Err("fleet config-get requires exactly one query name".to_string()); + } + if let Some(lines) = fleet_config_query(&args[0])? { + for line in lines { + println!("{line}"); + } + } + Ok(ExitCode::SUCCESS) } -fn handoff_cockpit_subcommand(prefix: &str, tail: &[String]) -> Result { - let mut args = vec!["cockpit".to_string(), prefix.to_string()]; - args.extend(tail.iter().cloned()); - handoff_to_python(&args) +fn fleet_render_scan_json_cmd(args: &[String]) -> Result { + if args.len() != 1 { + return Err("fleet render-scan-json requires a candidates file".to_string()); + } + let path = Path::new(&args[0]); + print_value(&render_scan_json(path)?, true)?; + Ok(ExitCode::SUCCESS) +} + +fn fleet_write_config_cmd(args: &[String]) -> Result { + if args.is_empty() { + return Err("fleet write-config requires a finalized selection file".to_string()); + } + let finalized_file = Path::new(&args[0]); + let mut output_path: Option = None; + let mut repo_dir: Option = None; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--output" => { + index += 1; + output_path = Some(PathBuf::from( + args.get(index) + .ok_or_else(|| "--output requires a value".to_string())?, + )); + } + "--repo-dir" => { + index += 1; + repo_dir = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--repo-dir requires a value".to_string())?, + ); + } + other => return Err(format!("unknown fleet write-config option: {other}")), + } + index += 1; + } + + let output_path = output_path.ok_or_else(|| "--output is required".to_string())?; + let repo_dir = repo_dir.ok_or_else(|| "--repo-dir is required".to_string())?; + let written = write_fleet_config(finalized_file, &output_path, &repo_dir)?; + println!("{}", written.display()); + Ok(ExitCode::SUCCESS) } fn cockpit_open_cmd(args: &[String]) -> Result { @@ -802,7 +1084,7 @@ fn cockpit_open_cmd(args: &[String]) -> Result { } "--recreate" => recreate = true, "--remote-recreate" => remote_recreate = true, - _ => return handoff_cockpit_subcommand("open", args), + other => return Err(format!("unknown cockpit open option: {other}")), } index += 1; } @@ -828,7 +1110,7 @@ fn cockpit_attach_cmd(args: &[String]) -> Result { .cloned() .ok_or_else(|| "--local-session requires a value".to_string())?; } - _ => return handoff_cockpit_subcommand("attach", args), + other => return Err(format!("unknown cockpit attach option: {other}")), } index += 1; } @@ -858,7 +1140,7 @@ fn cockpit_status_cmd(args: &[String]) -> Result { .ok_or_else(|| "--session requires a value".to_string())?; } "--json" => as_json = true, - _ => return handoff_cockpit_subcommand("status", args), + other => return Err(format!("unknown cockpit status option: {other}")), } index += 1; } @@ -889,7 +1171,7 @@ fn cockpit_doctor_cmd(args: &[String]) -> Result { .ok_or_else(|| "--session requires a value".to_string())?; } "--json" => as_json = true, - _ => return handoff_cockpit_subcommand("doctor", args), + other => return Err(format!("unknown cockpit doctor option: {other}")), } index += 1; } @@ -938,7 +1220,7 @@ fn cockpit_focus_cmd(args: &[String]) -> Result { .ok_or_else(|| "--session requires a value".to_string())?; } "--json" => as_json = true, - _ => return handoff_cockpit_subcommand("focus", args), + other => return Err(format!("unknown cockpit focus option: {other}")), } index += 1; } @@ -989,7 +1271,7 @@ fn cockpit_send_cmd(args: &[String]) -> Result { .ok_or_else(|| "--session requires a value".to_string())?; } "--json" => as_json = true, - _ => return handoff_cockpit_subcommand("send", args), + other => return Err(format!("unknown cockpit send option: {other}")), } index += 1; } @@ -1044,7 +1326,7 @@ fn cockpit_capture_cmd(args: &[String]) -> Result { .ok_or_else(|| "--session requires a value".to_string())?; } "--json" => as_json = true, - _ => return handoff_cockpit_subcommand("capture", args), + other => return Err(format!("unknown cockpit capture option: {other}")), } index += 1; } @@ -1094,7 +1376,7 @@ fn cockpit_restart_cmd(args: &[String]) -> Result { .ok_or_else(|| "--session requires a value".to_string())?; } "--json" => as_json = true, - _ => return handoff_cockpit_subcommand("restart", args), + other => return Err(format!("unknown cockpit restart option: {other}")), } index += 1; } @@ -1107,41 +1389,80 @@ fn cockpit_restart_cmd(args: &[String]) -> Result { Ok(exit_code_from_status(Some(payload.returncode))) } -fn handoff_to_python_with_prefix(prefix: &str, tail: &[String]) -> Result { - let mut args = vec![prefix.to_string()]; - args.extend(tail.iter().cloned()); - handoff_to_python(&args) -} - -fn handoff_to_python(args: &[String]) -> Result { - let repo_root = paths::repo_root(); - let default_python = paths::home_dir() - .map(|home| home.join(".local/share/constant/venv/bin/python3")) - .filter(|path| path.exists()); - let python_bin = env::var_os("CONSTANT_PYTHON") - .or_else(|| default_python.map(Into::into)) - .unwrap_or_else(|| OsString::from("python3")); - - let mut command = Command::new(python_bin); - command.arg("-m").arg("constant"); - command.args(args); - command.env("CONSTANT_PROG_NAME", program_name()); - command.env("CONSTANT_RUST_HANDOFF", "1"); - command.env("PYTHONPATH", python_path_with_repo(&repo_root)); - let status = command - .status() - .map_err(|err| format!("python handoff failed: {err}"))?; - - Ok(exit_code_from_status(status.code())) -} - -fn python_path_with_repo(repo_root: &Path) -> OsString { - let mut value = OsString::from(repo_root.as_os_str()); - if let Some(existing) = env::var_os("PYTHONPATH") { - value.push(":"); - value.push(existing); +fn cockpit_status_line_cmd(args: &[String]) -> Result { + let mut workspace = env::current_dir() + .map_err(|err| format!("cannot resolve current directory: {err}"))? + .to_string_lossy() + .into_owned(); + let mut scope_label: Option = None; + let mut machine_label: Option = None; + let mut max_length = 180_usize; + let mut local_session = "constant-fleet".to_string(); + let mut machine_session = "constant".to_string(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + index += 1; + workspace = args + .get(index) + .cloned() + .ok_or_else(|| "--workspace requires a value".to_string())?; + } + "--scope-label" => { + index += 1; + scope_label = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--scope-label requires a value".to_string())?, + ); + } + "--machine-label" => { + index += 1; + machine_label = Some( + args.get(index) + .cloned() + .ok_or_else(|| "--machine-label requires a value".to_string())?, + ); + } + "--max-length" => { + index += 1; + max_length = args + .get(index) + .ok_or_else(|| "--max-length requires a value".to_string())? + .parse::() + .map_err(|_| "--max-length must be an integer".to_string())?; + } + "--local-session" => { + index += 1; + local_session = args + .get(index) + .cloned() + .ok_or_else(|| "--local-session requires a value".to_string())?; + } + "--session" => { + index += 1; + machine_session = args + .get(index) + .cloned() + .ok_or_else(|| "--session requires a value".to_string())?; + } + other => return Err(format!("unknown cockpit status-line option: {other}")), + } + index += 1; } - value + println!( + "{}", + cockpit_status_line( + &canonical_workspace(&workspace)?, + scope_label.as_deref(), + machine_label.as_deref(), + max_length, + &local_session, + &machine_session, + )? + ); + Ok(ExitCode::SUCCESS) } fn canonical_workspace(input: &str) -> Result { @@ -1216,6 +1537,7 @@ fn doctor_value() -> Result { let fleet = load_fleet_config()?; let models = load_models_config()?; let memory = load_memory_config()?; + let health = health_value()?; Ok(json!({ "version": VERSION, @@ -1225,10 +1547,9 @@ fn doctor_value() -> Result { "data_root": paths::data_root().display().to_string(), "rust": { "front_controller": true, - "python_handoff": command_exists("python3"), + "python_handoff": false, }, "commands": { - "python3": command_exists("python3"), "tmux": command_exists("tmux"), "cargo": command_exists("cargo"), "claude": command_exists("claude"), @@ -1247,6 +1568,7 @@ fn doctor_value() -> Result { "machines": fleet.machines, }, "models": models, + "health": health, "memory": memory, "wrapper": wrapper_status_value(), })) @@ -1259,45 +1581,15 @@ fn wrapper_status_value() -> Value { "wrapper_dir": wrapper_dir.display().to_string(), "rust_bin": rust_bin.display().to_string(), "rust_bin_exists": rust_bin.exists(), - "forced_python": env::var("CONSTANT_USE_PYTHON").ok().as_deref() == Some("1"), - "forced_rust": env::var("CONSTANT_USE_RUST").ok().as_deref() == Some("1"), - "force_recheck": env::var("CONSTANT_RUST_RECHECK").ok().as_deref() == Some("1"), + "mode": "rust-only", "last_mode": read_wrapper_mode(&wrapper_dir.join("last-mode")), - "cache": { - "rust_ok": read_wrapper_stamp(&wrapper_dir.join("rust-ok")), - "rust_fail": read_wrapper_stamp(&wrapper_dir.join("rust-fail")), - }, "codesign_verify": if rust_bin.exists() { Some(run_quick_command(&["codesign", "--verify", "--verbose=2", &rust_bin.display().to_string()])) } else { None }, "spctl_assess": if rust_bin.exists() { Some(run_quick_command(&["spctl", "--assess", "-vv", &rust_bin.display().to_string()])) } else { None }, "xattr_provenance": if rust_bin.exists() { Some(run_quick_command(&["xattr", "-p", "com.apple.provenance", &rust_bin.display().to_string()])) } else { None }, - "hint": "If spctl rejects the Rust binary from a normal terminal, allow that terminal in System Settings -> Privacy & Security -> Developer Tools, then rerun with CONSTANT_RUST_RECHECK=1 to force a fresh startup probe.", + "hint": "The public wrapper is Rust-only. If the binary is missing or stale, rerun ./scripts/Constant and it will rebuild and ad-hoc sign the binary before launch.", }) } -fn read_wrapper_stamp(path: &Path) -> Value { - let Ok(raw) = fs::read_to_string(path) else { - return Value::Null; - }; - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Value::Null; - } - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - if parts.len() < 2 { - return json!({ "raw": trimmed }); - } - let mut payload = json!({ - "signature": parts[0], - "epoch": parts[1], - }); - if let Ok(epoch) = parts[1].parse::() { - if let Some(object) = payload.as_object_mut() { - object.insert("timestamp".to_string(), json!(epoch)); - } - } - payload -} - fn read_wrapper_mode(path: &Path) -> Value { let Ok(raw) = fs::read_to_string(path) else { return Value::Null; diff --git a/src/memory.rs b/src/memory.rs index 3360c98..12944e5 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -707,6 +707,83 @@ pub fn summarize_mission_to_memory(mission: &Mission) -> Result { })) } +pub fn sync_qdrant(workspace: Option<&str>) -> Result { + ensure_schema()?; + let config = load_memory_config()?; + let url = config.qdrant_url.trim().to_string(); + if url.is_empty() { + return Ok(json!({ + "ok": false, + "skipped": true, + "reason": "qdrant_url is not configured", + })); + } + + let hits = search_memory("*", workspace, Some(32))? + .get("hits") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let points = hits + .iter() + .map(|hit| { + let kind = hit.get("kind").and_then(Value::as_str).unwrap_or("memory"); + let path = hit.get("path").and_then(Value::as_str).unwrap_or("-"); + let snippet = hit.get("snippet").and_then(Value::as_str).unwrap_or(""); + json!({ + "id": stable_point_id(kind, path), + "vector": embed_text(snippet, config.vector_dimensions as usize), + "payload": hit, + }) + }) + .collect::>(); + + let endpoint = format!( + "{}/collections/{}/points?wait=true", + url.trim_end_matches('/'), + config.qdrant_collection + ); + let body = json!({ "points": points }); + let output = Command::new("curl") + .args([ + "-fsS", + "-X", + "PUT", + "-H", + "Content-Type: application/json", + &endpoint, + "--data-binary", + &body.to_string(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(); + + match output { + Ok(output) if output.status.success() => { + let payload: Value = serde_json::from_slice(&output.stdout) + .map_err(|err| format!("cannot decode qdrant response: {err}"))?; + Ok(json!({ + "ok": true, + "response": payload, + "points": body["points"].as_array().map(|items| items.len()).unwrap_or(0), + })) + } + Ok(output) => Ok(json!({ + "ok": false, + "skipped": false, + "reason": String::from_utf8_lossy(&output.stderr).trim(), + "points": body["points"].as_array().map(|items| items.len()).unwrap_or(0), + })), + Err(err) => Ok(json!({ + "ok": false, + "skipped": false, + "reason": err.to_string(), + "points": body["points"].as_array().map(|items| items.len()).unwrap_or(0), + })), + } +} + fn ensure_schema() -> Result<(), String> { let db = paths::memory_store_path(); if let Some(parent) = db.parent() { @@ -1185,6 +1262,50 @@ fn tokenize(text: &str) -> Vec { tokens } +fn embed_text(text: &str, dims: usize) -> Vec { + let mut vector = vec![0.0_f64; dims]; + if dims == 0 { + return vector; + } + let mut counts = BTreeMap::new(); + for token in tokenize(text) { + *counts.entry(token).or_insert(0_u32) += 1; + } + for (token, count) in counts { + let digest = sha256_text(&token); + let bytes = digest.as_bytes(); + let mut index_seed = 0_u32; + for byte in bytes.iter().take(4) { + index_seed = (index_seed << 8) | *byte as u32; + } + let index = index_seed as usize % dims; + let sign = if bytes.get(4).copied().unwrap_or(b'0') % 2 == 0 { + 1.0 + } else { + -1.0 + }; + vector[index] += sign * (1.0 + f64::from(count).ln_1p()); + } + let norm = vector.iter().map(|value| value * value).sum::().sqrt(); + if norm == 0.0 { + return vector; + } + vector + .into_iter() + .map(|value| ((value / norm) * 1_000_000.0).round() / 1_000_000.0) + .collect() +} + +fn stable_point_id(kind: &str, path: &str) -> i64 { + let raw = format!("{kind}:{path}"); + let digest = sha256_text(&raw); + let mut value = 0_i64; + for byte in digest.as_bytes().iter().take(8) { + value = (value << 8) | i64::from(*byte); + } + value.abs() +} + fn lexical_score(text: &str, query: &str) -> f64 { let haystack = text.to_lowercase(); tokenize(query) diff --git a/src/operator.rs b/src/operator.rs new file mode 100644 index 0000000..8a170b9 --- /dev/null +++ b/src/operator.rs @@ -0,0 +1,485 @@ +use serde_json::{Value, json}; + +use crate::buddy::buddy_ask; +use crate::capabilities::{ + AGENTS, MINIMAL_STACK, SKILLS, WORKFLOW_STACK, match_skill, resolve_skill_and_agent, + skill_by_id, +}; +use crate::config::{load_fleet_config, load_models_config}; +use crate::memory::{instruction_skill_sources, search_memory}; +use crate::state::Mission; + +pub const CHAT_ROLES: [&str; 4] = ["claude", "codex", "copilot", "vibe"]; + +pub fn health_value() -> Result { + let models = load_models_config()?; + let model_rows = [ + ("planner", &models.planner.model_id), + ("buddy", &models.buddy.model_id), + ("verify", &models.verify.model_id), + ] + .into_iter() + .map(|(role, model_id)| { + ( + role.to_string(), + json!({ + "role": role, + "model_id": model_id, + "available": false, + "loaded": false, + "backend": "heuristic", + "cached": false, + "cache_path": "", + }), + ) + }) + .collect::>(); + + Ok(json!({ + "mlx_python": false, + "mlx_probe": { + "requested": false, + "package_present": false, + "available": false, + "reason": "disabled in Rust runtime", + }, + "models": Value::Object(model_rows), + "local_model_warmup": { + "enabled": false, + "download_required": false, + "missing_model_count": 0, + "missing_models": [], + "startup_message": "", + }, + "fallback_mode": models.fallback_mode, + "agents": AGENTS, + "skills": SKILLS, + "recommended_skill_stack": recommended_skill_stack_value(), + })) +} + +pub fn models_status_value() -> Result { + Ok(json!({ + "config": load_models_config()?, + "runtime": { + "backend": "heuristic", + "python": false, + "service": false, + }, + "health": health_value()?, + })) +} + +pub fn chat( + message: &str, + mission: Option<&Mission>, + workspace: &str, + selected_machine: Option<&str>, + selected_role: Option<&str>, + _chat_history: &[Value], +) -> Result { + let (prompt, explicit_skill) = extract_explicit_skill(message); + let prompt = prompt.trim().to_string(); + let prompt_l = prompt.to_lowercase(); + + let memory_payload = search_memory(&prompt, Some(workspace), Some(4))?; + let memory_hits = memory_payload + .get("hits") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let skill_sources = instruction_skill_sources(workspace, Some(&prompt), 4)? + .as_array() + .cloned() + .unwrap_or_default(); + let memory_lines = memory_hits + .iter() + .take(3) + .map(|hit| { + format!( + "{} {} :: {}", + hit.get("kind").and_then(Value::as_str).unwrap_or("memory"), + hit.get("path").and_then(Value::as_str).unwrap_or("-"), + hit.get("snippet").and_then(Value::as_str).unwrap_or("") + ) + }) + .collect::>(); + + let target_machine = match_machine_label(&prompt)? + .or_else(|| selected_machine.map(ToString::to_string)) + .unwrap_or_else(|| fleet_labels().local); + let target_role = match_role(&prompt) + .or_else(|| selected_role.map(ToString::to_string)) + .unwrap_or_else(|| "codex".to_string()); + + let matched_skill = explicit_skill.or_else(|| { + if prompt.is_empty() { + None + } else { + Some(match_skill(&prompt, false)) + } + }); + + let mut intent = "plain_chat".to_string(); + let mut cockpit_action = Value::Null; + let mut buddy_note = Value::Null; + let mut reply: String; + let mut mission_goal = Value::Null; + let mut routing_overrides = serde_json::Map::new(); + + if contains_any(&prompt_l, &["open cockpit", "attach cockpit", "show cockpit"]) { + intent = "cockpit_open".to_string(); + cockpit_action = json!({ "type": "open" }); + reply = "I can hand off to the full cockpit now.".to_string(); + } else if contains_any(&prompt_l, &["restart", "relance", "respawn"]) { + intent = "cockpit_restart".to_string(); + cockpit_action = json!({ + "type": "restart", + "machine": target_machine, + "pane": target_role, + }); + reply = format!("I'll restart {target_machine}:{target_role}."); + } else if contains_any(&prompt_l, &["capture", "log", "logs", "show pane", "see pane"]) { + intent = "cockpit_capture".to_string(); + cockpit_action = json!({ + "type": "capture", + "machine": target_machine, + "pane": target_role, + }); + reply = format!("I'll capture {target_machine}:{target_role}."); + } else if contains_any(&prompt_l, &["focus", "jump", "go to", "ouvre", "open machine"]) { + intent = "cockpit_focus".to_string(); + cockpit_action = json!({ + "type": "focus", + "machine": target_machine, + "pane": target_role, + }); + reply = format!("I'll focus {target_machine}:{target_role}."); + } else if contains_any( + &prompt_l, + &[ + "memory", + "remember", + "decision", + "persona", + "what do we know", + "qu'est-ce qu", + "souviens", + ], + ) { + intent = "memory_lookup".to_string(); + reply = if memory_lines.is_empty() { + "No strong memory hits for that query yet.".to_string() + } else { + format!("Memory echoes:\n- {}", memory_lines.join("\n- ")) + }; + } else if explicit_skill.is_none() + && prompt.ends_with('?') + && !contains_any( + &prompt_l, + &[ + "fix", + "build", + "implement", + "write", + "create", + "deploy", + "restart", + "capture", + "focus", + ], + ) + { + let title = mission + .map(|entry| entry.title.as_str()) + .unwrap_or("global cockpit"); + reply = format!( + "Constant view for {title}. selected={}:{}", + selected_machine.unwrap_or("-"), + selected_role.unwrap_or("-") + ); + if !memory_lines.is_empty() { + reply.push_str(&format!("\nMemory echoes:\n- {}", memory_lines[..memory_lines.len().min(2)].join("\n- "))); + } + } else { + intent = "mission_create".to_string(); + if let Some(skill) = matched_skill { + routing_overrides.insert("skill".to_string(), json!(skill.id)); + routing_overrides.insert("agent".to_string(), json!(skill.preferred_agent)); + routing_overrides.insert("cli".to_string(), json!(skill.preferred_cli)); + } + let preview = heuristic_plan_preview(&prompt, workspace, &routing_overrides)?; + let step = preview + .get("steps") + .and_then(Value::as_array) + .and_then(|steps| steps.first()) + .cloned() + .unwrap_or_else(|| json!({})); + buddy_note = json!({ + "answer": format!( + "Route preview agrees with {}/{}/{}.", + step.get("machine").and_then(Value::as_str).unwrap_or("-"), + step.get("cli").and_then(Value::as_str).unwrap_or("-"), + step.get("backend").and_then(Value::as_str).unwrap_or("-"), + ), + "mode": "rust-heuristic", + }); + reply = format!( + "I turned that into a mission. Route preview: {}/{}/{} skill={} agent={}.", + step.get("machine").and_then(Value::as_str).unwrap_or("-"), + step.get("cli").and_then(Value::as_str).unwrap_or("-"), + step.get("backend").and_then(Value::as_str).unwrap_or("-"), + step.get("skill").and_then(Value::as_str).unwrap_or("-"), + step.get("agent").and_then(Value::as_str).unwrap_or("-"), + ); + mission_goal = json!(prompt); + } + + if matches!(intent.as_str(), "plain_chat" | "memory_lookup") && buddy_note.is_null() { + if contains_any( + &prompt_l, + &["route", "reroute", "machine", "cli", "pane", "codex", "claude", "vibe", "copilot"], + ) { + buddy_note = buddy_ask(mission, &prompt, Some(workspace))?; + } + } + + Ok(json!({ + "intent": intent, + "reply": reply, + "message": prompt, + "mode": "rust-heuristic", + "cockpit_action": cockpit_action, + "buddy_note": buddy_note, + "memory_hits": memory_hits, + "skill_sources": skill_sources, + "workspace": workspace, + "mission_goal": mission_goal, + "skill": matched_skill.map(skill_value).unwrap_or(Value::Null), + "routing_overrides": Value::Object(routing_overrides), + })) +} + +pub fn recommended_skill_stack_value() -> Value { + json!({ + "minimal": MINIMAL_STACK, + "workflow": WORKFLOW_STACK, + "layers": { + "reflection": ["spec-planner", "architecture-brainstorm"], + "execution": ["repo-onboarding", "task-decomposer", "pr-review-prep"], + } + }) +} + +fn skill_value(skill: &crate::capabilities::Skill) -> Value { + json!({ + "id": skill.id, + "label": skill.label, + "layer": skill.layer, + "visibility": skill.visibility, + "summary": skill.summary, + "preferred_cli": skill.preferred_cli, + "preferred_agent": skill.preferred_agent, + }) +} + +fn extract_explicit_skill(message: &str) -> (String, Option<&'static crate::capabilities::Skill>) { + let raw = message.trim(); + let lowered = raw.to_lowercase(); + + if let Some(payload) = lowered.strip_prefix("skill:") { + let offset = raw.len() - payload.len(); + if let Some((skill, remainder)) = parse_skill_payload(&raw[offset..]) { + return (remainder, Some(skill)); + } + } + if raw.starts_with("/skill ") { + if let Some((skill, remainder)) = parse_skill_payload(&raw["/skill ".len()..]) { + return (remainder, Some(skill)); + } + } + if raw.starts_with('/') { + let payload = &raw[1..]; + let mut parts = payload.splitn(2, char::is_whitespace); + if let Some(skill_id) = parts.next() { + if let Some(skill) = skill_by_id(skill_id, false) { + let remainder = parts.next().unwrap_or_default().trim().to_string(); + return (if remainder.is_empty() { raw.to_string() } else { remainder }, Some(skill)); + } + } + } + for skill in SKILLS.iter().filter(|skill| skill.visibility == "public") { + if lowered.contains(skill.id) { + return (raw.to_string(), Some(skill)); + } + } + (raw.to_string(), None) +} + +fn parse_skill_payload(payload: &str) -> Option<(&'static crate::capabilities::Skill, String)> { + let trimmed = payload.trim(); + if trimmed.is_empty() { + return None; + } + let mut parts = trimmed.splitn(2, char::is_whitespace); + let skill_id = parts.next()?; + let skill = skill_by_id(skill_id, false)?; + let remainder = parts.next().unwrap_or_default().trim().to_string(); + Some(( + skill, + if remainder.is_empty() { + trimmed.to_string() + } else { + remainder + }, + )) +} + +fn heuristic_plan_preview( + goal: &str, + _workspace: &str, + overrides: &serde_json::Map, +) -> Result { + let resolution = resolve_skill_and_agent( + Some(goal), + overrides.get("skill").and_then(Value::as_str), + overrides.get("agent").and_then(Value::as_str), + overrides.get("cli").and_then(Value::as_str), + ); + let skill = resolution.skill; + let cli = resolution.cli; + let machine = overrides + .get("machine") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| route_machine(goal, skill.id)); + let backend = overrides + .get("backend") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| route_backend(&machine, cli, goal)); + Ok(json!({ + "title": goal.lines().next().unwrap_or_default().trim().chars().take(80).collect::(), + "summary": format!("Route the mission to {machine} using {cli} via {backend} for skill {}.", skill.id), + "steps": [{ + "step_id": "step-1", + "kind": "task", + "title": format!("Execute mission on {machine}"), + "prompt": goal, + "machine": machine, + "backend": backend, + "cli": cli, + "agent": resolution.agent.id, + "agent_role": resolution.agent.role, + "skill": skill.id, + "skill_summary": skill.summary, + "status": "pending", + "attempt": 0, + "depends_on": [], + "artifact_refs": [], + "result_summary": "", + }] + })) +} + +fn match_machine_label(message: &str) -> Result, String> { + let message_l = message.to_lowercase(); + let fleet = load_fleet_config()?; + Ok(fleet + .machines + .iter() + .find(|machine| message_l.contains(&machine.label.to_lowercase())) + .map(|machine| machine.label.clone())) +} + +fn match_role(message: &str) -> Option { + let message_l = message.to_lowercase(); + CHAT_ROLES + .iter() + .find(|role| message_l.contains(**role)) + .map(|role| (*role).to_string()) +} + +struct FleetLabels { + local: String, + builder_a: String, + builder_b: String, + edge_a: String, + lab_a: String, +} + +fn fleet_labels() -> FleetLabels { + let fleet = load_fleet_config().unwrap_or_default(); + let labels = fleet + .machines + .iter() + .map(|machine| machine.label.clone()) + .collect::>(); + FleetLabels { + local: fleet + .machines + .iter() + .find(|machine| machine.label == fleet.local_machine) + .map(|machine| machine.label.clone()) + .unwrap_or_else(|| labels.first().cloned().unwrap_or_else(|| "command-center".to_string())), + builder_a: labels.get(1).cloned().unwrap_or_else(|| "builder-a".to_string()), + builder_b: labels.get(2).cloned().unwrap_or_else(|| "builder-b".to_string()), + edge_a: labels.get(3).cloned().unwrap_or_else(|| "edge-a".to_string()), + lab_a: labels.get(4).cloned().unwrap_or_else(|| "lab-a".to_string()), + } +} + +fn route_machine(goal: &str, skill_id: &str) -> String { + let labels = fleet_labels(); + let goal_l = goal.to_lowercase(); + if matches!(skill_id, "spec-planner" | "repo-onboarding" | "task-decomposer") { + return labels.local; + } + if skill_id == "architecture-brainstorm" { + return labels.lab_a; + } + if skill_id == "pr-review-prep" { + return labels.builder_a; + } + if skill_id == "ops-deployment" { + return labels.edge_a; + } + if skill_id == "debug-restoration" + && contains_any(&goal_l, &["performance", "deep", "benchmark", "compiler", "cuda"]) + { + return labels.builder_b; + } + if contains_any(&goal_l, &["ssh", "shell", "fleet", "ops", "network", "infra"]) { + return labels.edge_a; + } + if contains_any(&goal_l, &["refactor", "performance", "deep", "cuda", "compiler", "benchmark"]) { + return labels.builder_b; + } + if contains_any(&goal_l, &["review", "audit", "test", "qa", "docs"]) { + return labels.builder_a; + } + if contains_any(&goal_l, &["experiment", "prototype", "sandbox", "branch"]) { + return labels.lab_a; + } + labels.local +} + +fn route_backend(machine: &str, cli: &str, goal: &str) -> String { + let local_machine = fleet_labels().local; + let goal_l = goal.to_lowercase(); + if machine == local_machine + && matches!(cli, "claude" | "codex") + && contains_any(&goal_l, &["parallel", "team", "multi-agent", "compare"]) + { + return "omc".to_string(); + } + if machine == local_machine { + "cli-local".to_string() + } else { + "cli-ssh".to_string() + } +} + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} diff --git a/src/paths.rs b/src/paths.rs index 42d247c..b48f345 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -31,6 +31,26 @@ pub fn data_root() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".local/share/constant")) } +pub fn expand_home_string(value: &str) -> PathBuf { + if value == "~" { + return home_dir().unwrap_or_else(|| PathBuf::from(value)); + } + if let Some(rest) = value.strip_prefix("~/") { + return home_dir() + .map(|home| home.join(rest)) + .unwrap_or_else(|| PathBuf::from(value)); + } + if value == "$HOME" { + return home_dir().unwrap_or_else(|| PathBuf::from(value)); + } + if let Some(rest) = value.strip_prefix("$HOME/") { + return home_dir() + .map(|home| home.join(rest)) + .unwrap_or_else(|| PathBuf::from(value)); + } + PathBuf::from(value) +} + pub fn planner_dir() -> PathBuf { cache_root().join("planner") } @@ -92,6 +112,18 @@ pub fn indexes_dir() -> PathBuf { data_root().join("indexes") } +pub fn chat_indexes_dir() -> PathBuf { + indexes_dir().join("chat") +} + +pub fn chat_threads_index_dir() -> PathBuf { + chat_indexes_dir().join("threads") +} + +pub fn chat_views_dir() -> PathBuf { + chat_indexes_dir().join("views") +} + pub fn memory_sources_dir() -> PathBuf { data_root().join("sources") } @@ -121,6 +153,9 @@ pub fn ensure_runtime_dirs() -> Result<(), String> { data_root(), missions_dir(), indexes_dir(), + chat_indexes_dir(), + chat_threads_index_dir(), + chat_views_dir(), memory_sources_dir(), planner_dir(), ] { diff --git a/src/tui.rs b/src/tui.rs index 06a6916..953e2bd 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,6 +1,6 @@ -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, @@ -10,106 +10,134 @@ use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; -use serde_json::Value; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; +use serde_json::{Map, Value, json}; -use crate::cockpit::{ROLES, capture_pane, focus_machine, restart_pane, runtime_status}; -use crate::state::{list_missions, mission_summary_value}; +use crate::chat::{ + ChatEntry, ThreadSummary, append_chat_message, delete_mission_thread, list_thread_summaries, + mark_thread_seen, read_chat_history, +}; +use crate::cockpit::{FleetRuntimeStatus, ROLES, capture_pane, focus_machine, restart_pane, runtime_status}; +use crate::config::{load_fleet_config, load_models_config}; +use crate::mission::plan_mission; +use crate::operator::chat as operator_chat; +use crate::state::{append_event, load_mission, new_mission, save_mission}; pub enum TuiAction { Exit, OpenCockpit, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum FocusZone { + Composer, + Threads, + Cockpit, + Capture, +} + struct App { workspace: String, local_session: String, machine_session: String, - selected_mission: usize, + focus: FocusZone, + input: String, + selected_thread: usize, selected_machine: usize, selected_role: usize, - missions: Vec, - runtime: Option, + threads: Vec, + chat_history: Vec, + runtime: Option, + capture_title: String, + capture_lines: Vec, + capture_scroll: usize, flash: String, flash_until: Instant, - capture: Option, - capture_title: String, - last_refresh: Instant, + last_thread_refresh: Instant, + last_runtime_refresh: Instant, } -const HEXAPUS_FRAMES: &[&[&str]] = &[ - &[ - " .-====-.", - " .-' .--. `-.", - " / ( oo ) \\", - " | \\/\\/ |", - " | .-____-. |", - " \\__/_/ || \\_\\__/", - " /_ /||\\ _\\", - " <_____/ || \\_____>", - ], - &[ - " .-====-.", - " .-' .--. `-.", - " / ( -- ) \\", - " | \\/\\/ |", - " | .-____-. |", - " _/\\_/ / || \\ \\_/\\_", - "/_ _\\/ || \\/_ _\\", - " <_____/||\\_____>", - ], - &[ - " .-====-.", - " .-' .--. `-.", - " / ( xx ) \\", - " | \\/\\/ |", - " | .-____-. |", - " \\__/__/||\\__\\__/", - " _\\_ /||\\ _/_", - " <_____/ || \\_____>", - ], -]; - impl App { fn new(workspace: String, local_session: String, machine_session: String) -> Self { Self { workspace, local_session, machine_session, - selected_mission: 0, + focus: FocusZone::Composer, + input: String::new(), + selected_thread: 0, selected_machine: 0, selected_role: 1, - missions: Vec::new(), + threads: Vec::new(), + chat_history: Vec::new(), runtime: None, + capture_title: String::new(), + capture_lines: Vec::new(), + capture_scroll: 0, flash: String::new(), flash_until: Instant::now(), - capture: None, - capture_title: String::new(), - last_refresh: Instant::now() - Duration::from_secs(10), + last_thread_refresh: Instant::now() - Duration::from_secs(10), + last_runtime_refresh: Instant::now() - Duration::from_secs(10), } } fn refresh(&mut self) { - if self.last_refresh.elapsed() < Duration::from_millis(700) { - return; - } - self.missions = list_missions() - .unwrap_or_default() - .into_iter() - .map(|mission| mission_summary_value(&mission)) - .collect(); - self.runtime = runtime_status(&self.local_session, &self.machine_session).ok(); - if self.selected_mission >= self.missions.len() { - self.selected_mission = self.missions.len().saturating_sub(1); - } - if let Some(runtime) = &self.runtime { - if self.selected_machine >= runtime.machines.len() { - self.selected_machine = runtime.machines.len().saturating_sub(1); + if self.last_thread_refresh.elapsed() >= Duration::from_millis(400) { + self.threads = list_thread_summaries(&self.workspace).unwrap_or_else(|_| { + vec![ThreadSummary { + thread_key: format!("workspace:{}", self.workspace), + workspace: self.workspace.clone(), + mission_id: None, + kind: "workspace".to_string(), + title: "Workspace chat".to_string(), + mission_status: None, + message_count: 0, + unread_count: 0, + last_role: String::new(), + last_preview: "Start a conversation".to_string(), + last_timestamp: String::new(), + }] + }); + if self.selected_thread >= self.threads.len() { + self.selected_thread = self.threads.len().saturating_sub(1); } - } else { - self.selected_machine = 0; + if let Some(summary) = self.current_thread().cloned() { + let _ = mark_thread_seen(&self.workspace, &summary.thread_key, summary.message_count); + self.chat_history = + read_chat_history(&self.workspace, summary.mission_id.as_deref(), 80) + .unwrap_or_default(); + } else { + self.chat_history.clear(); + } + self.last_thread_refresh = Instant::now(); } - self.last_refresh = Instant::now(); + + if self.last_runtime_refresh.elapsed() >= Duration::from_secs(2) { + self.runtime = runtime_status(&self.local_session, &self.machine_session).ok(); + if let Some(runtime) = &self.runtime { + if self.selected_machine >= runtime.machines.len() { + self.selected_machine = runtime.machines.len().saturating_sub(1); + } + } else { + self.selected_machine = 0; + } + self.last_runtime_refresh = Instant::now(); + } + } + + fn current_thread(&self) -> Option<&ThreadSummary> { + self.threads.get(self.selected_thread) + } + + fn current_mission_id(&self) -> Option<&str> { + self.current_thread().and_then(|summary| summary.mission_id.as_deref()) + } + + fn selected_machine_label(&self) -> Option<&str> { + self.runtime + .as_ref() + .and_then(|runtime| runtime.machines.get(self.selected_machine)) + .map(|machine| machine.label.as_str()) } fn flash(&mut self, message: impl Into) { @@ -143,7 +171,6 @@ pub fn run_tui( disable_raw_mode().ok(); execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); terminal.show_cursor().ok(); - result } @@ -158,333 +185,707 @@ fn run_loop( .draw(|frame| draw(frame, app)) .map_err(|err| format!("cannot draw TUI: {err}"))?; - if event::poll(Duration::from_millis(120)) + if !event::poll(Duration::from_millis(120)) .map_err(|err| format!("event poll failed: {err}"))? { - let Event::Key(key) = - event::read().map_err(|err| format!("event read failed: {err}"))? - else { - continue; - }; - if key.kind != KeyEventKind::Press { - continue; + continue; + } + + let Event::Key(key) = + event::read().map_err(|err| format!("event read failed: {err}"))? + else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + + if matches!( + (key.code, key.modifiers), + (KeyCode::Char('q'), _) + | (KeyCode::Char('c'), KeyModifiers::CONTROL) + ) { + return Ok(TuiAction::Exit); + } + + match app.focus { + FocusZone::Composer => { + if let Some(action) = handle_composer_key(app, key)? { + return Ok(action); + } } - match key.code { - KeyCode::Char('q') => return Ok(TuiAction::Exit), - KeyCode::Char('j') => { - if app.selected_mission + 1 < app.missions.len() { - app.selected_mission += 1; - } + FocusZone::Threads => handle_threads_key(app, key)?, + FocusZone::Cockpit => { + if let Some(action) = handle_cockpit_key(app, key)? { + return Ok(action); } - KeyCode::Char('k') => { - app.selected_mission = app.selected_mission.saturating_sub(1); - } - KeyCode::Char(']') => { - if let Some(runtime) = &app.runtime { - if app.selected_machine + 1 < runtime.machines.len() { - app.selected_machine += 1; - } - } - } - KeyCode::Char('[') => { - app.selected_machine = app.selected_machine.saturating_sub(1); - } - KeyCode::Char('1') => app.selected_role = 0, - KeyCode::Char('2') => app.selected_role = 1, - KeyCode::Char('3') => app.selected_role = 2, - KeyCode::Char('4') => app.selected_role = 3, - KeyCode::Char('o') => { - if let Some(machine) = selected_machine(app) { - let role = ROLES[app.selected_role]; - match focus_machine( - &machine.label, - Some(role), - &app.local_session, - &app.machine_session, - ) { - Ok(_) => app.flash(format!("focused {}:{}", machine.label, role)), - Err(err) => app.flash(format!("focus failed: {err}")), - } - } - } - KeyCode::Char('r') => { - if let Some(machine) = selected_machine(app) { - let role = ROLES[app.selected_role]; - match restart_pane(&machine.label, role, &app.machine_session) { - Ok(result) if result.returncode == 0 => { - app.flash(format!("restart sent to {}:{}", machine.label, role)) - } - Ok(result) => app - .flash(format!("restart failed: {}", compact_err(&result.stderr))), - Err(err) => app.flash(format!("restart failed: {err}")), - } - } - } - KeyCode::Char('x') => { - if app.capture.is_some() { - app.capture = None; - app.capture_title.clear(); - } else if let Some(machine) = selected_machine(app) { - let machine_label = machine.label.clone(); - let role = ROLES[app.selected_role]; - match capture_pane(&machine_label, role, 80, &app.machine_session) { - Ok(result) if result.returncode == 0 => { - app.capture = Some(result.stdout); - app.capture_title = format!("{}:{}", machine_label, role); - } - Ok(result) => app - .flash(format!("capture failed: {}", compact_err(&result.stderr))), - Err(err) => app.flash(format!("capture failed: {err}")), - } - } - } - KeyCode::Esc => { - app.capture = None; - app.capture_title.clear(); - } - KeyCode::Char('z') => return Ok(TuiAction::OpenCockpit), - _ => {} } + FocusZone::Capture => handle_capture_key(app, key), } } } -fn selected_machine(app: &App) -> Option<&crate::cockpit::MachineRuntimeStatus> { - app.runtime.as_ref()?.machines.get(app.selected_machine) +fn handle_composer_key(app: &mut App, key: KeyEvent) -> Result, String> { + match key.code { + KeyCode::Enter => { + submit_message(app)?; + Ok(None) + } + KeyCode::Esc => { + app.focus = FocusZone::Cockpit; + app.input.clear(); + Ok(None) + } + KeyCode::Left | KeyCode::Tab => { + app.focus = FocusZone::Threads; + Ok(None) + } + KeyCode::Backspace => { + app.input.pop(); + Ok(None) + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.input.clear(); + Ok(None) + } + KeyCode::Char(ch) => { + app.input.push(ch); + Ok(None) + } + _ => Ok(None), + } +} + +fn handle_threads_key(app: &mut App, key: KeyEvent) -> Result<(), String> { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if app.selected_thread + 1 < app.threads.len() { + app.selected_thread += 1; + app.last_thread_refresh = Instant::now() - Duration::from_secs(10); + } + } + KeyCode::Char('k') | KeyCode::Up => { + app.selected_thread = app.selected_thread.saturating_sub(1); + app.last_thread_refresh = Instant::now() - Duration::from_secs(10); + } + KeyCode::Char('d') => { + if let Some(summary) = app.current_thread().cloned() { + if let Some(mission_id) = summary.mission_id { + delete_mission_thread(&mission_id)?; + app.selected_thread = 0; + app.focus = FocusZone::Composer; + app.flash(format!("Deleted thread '{}'.", summary.title)); + app.last_thread_refresh = Instant::now() - Duration::from_secs(10); + } else { + app.flash("Workspace chat cannot be deleted."); + } + } + } + KeyCode::Right | KeyCode::Enter => app.focus = FocusZone::Composer, + KeyCode::Left | KeyCode::Esc | KeyCode::Tab => app.focus = FocusZone::Cockpit, + KeyCode::Char(ch) => { + app.focus = FocusZone::Composer; + app.input.clear(); + app.input.push(ch); + } + _ => {} + } + Ok(()) +} + +fn handle_cockpit_key(app: &mut App, key: KeyEvent) -> Result, String> { + match key.code { + KeyCode::Char(']') => { + if let Some(runtime) = &app.runtime { + if app.selected_machine + 1 < runtime.machines.len() { + app.selected_machine += 1; + } + } + } + KeyCode::Char('[') => { + app.selected_machine = app.selected_machine.saturating_sub(1); + } + KeyCode::Char('1') => app.selected_role = 0, + KeyCode::Char('2') => app.selected_role = 1, + KeyCode::Char('3') => app.selected_role = 2, + KeyCode::Char('4') => app.selected_role = 3, + KeyCode::Char('o') => { + if let Some(machine) = app.selected_machine_label().map(ToString::to_string) { + let role = ROLES[app.selected_role]; + match focus_machine(&machine, Some(role), &app.local_session, &app.machine_session) { + Ok(_) => app.flash(format!("focused {machine}:{role}")), + Err(err) => app.flash(format!("focus failed: {err}")), + } + } + } + KeyCode::Char('r') => { + if let Some(machine) = app.selected_machine_label().map(ToString::to_string) { + let role = ROLES[app.selected_role]; + match restart_pane(&machine, role, &app.machine_session) { + Ok(result) if result.returncode == 0 => { + app.flash(format!("restart sent to {machine}:{role}")) + } + Ok(result) => app.flash(format!("restart failed: {}", compact_err(&result.stderr))), + Err(err) => app.flash(format!("restart failed: {err}")), + } + } + } + KeyCode::Char('x') => { + if let Some(machine) = app.selected_machine_label().map(ToString::to_string) { + let role = ROLES[app.selected_role]; + match capture_pane(&machine, role, 120, &app.machine_session) { + Ok(result) if result.returncode == 0 => { + app.capture_title = format!("{machine}:{role}"); + app.capture_lines = result.stdout.lines().map(ToString::to_string).collect(); + app.capture_scroll = app.capture_lines.len().saturating_sub(20); + app.focus = FocusZone::Capture; + } + Ok(result) => app.flash(format!("capture failed: {}", compact_err(&result.stderr))), + Err(err) => app.flash(format!("capture failed: {err}")), + } + } + } + KeyCode::Char('z') => return Ok(Some(TuiAction::OpenCockpit)), + KeyCode::Left | KeyCode::Tab => app.focus = FocusZone::Threads, + KeyCode::Right => app.focus = FocusZone::Composer, + KeyCode::Char(ch) => { + app.focus = FocusZone::Composer; + app.input.clear(); + app.input.push(ch); + } + _ => {} + } + Ok(None) +} + +fn handle_capture_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if app.capture_scroll + 1 < app.capture_lines.len() { + app.capture_scroll += 1; + } + } + KeyCode::Char('k') | KeyCode::Up => { + app.capture_scroll = app.capture_scroll.saturating_sub(1); + } + KeyCode::Esc | KeyCode::Char('x') => { + app.capture_lines.clear(); + app.capture_title.clear(); + app.capture_scroll = 0; + app.focus = FocusZone::Cockpit; + } + _ => {} + } +} + +fn submit_message(app: &mut App) -> Result<(), String> { + let message = app.input.trim().to_string(); + if message.is_empty() { + app.flash("empty prompt"); + return Ok(()); + } + app.input.clear(); + + let mission = app.current_mission_id().map(load_mission).transpose()?; + let mission_id = mission.as_ref().map(|entry| entry.mission_id.as_str()); + let machine = app.selected_machine_label().map(ToString::to_string); + let role = ROLES[app.selected_role]; + + append_chat_message( + "user", + &message, + &app.workspace, + mission_id, + Some("plain_chat"), + machine.as_deref(), + Some(role), + None, + )?; + if let Some(mission_id) = mission_id { + append_event( + mission_id, + "chat.user", + json!({ "content": message, "machine": machine, "pane": role }), + )?; + } + + let history_values = app + .chat_history + .iter() + .rev() + .take(12) + .rev() + .map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null)) + .collect::>(); + let payload = operator_chat( + &message, + mission.as_ref(), + &app.workspace, + app.selected_machine_label(), + Some(role), + &history_values, + )?; + + if payload.get("intent").and_then(Value::as_str) == Some("mission_create") { + let models = load_models_config()?; + let fleet = load_fleet_config()?; + let routing_overrides = payload + .get("routing_overrides") + .and_then(Value::as_object) + .cloned() + .unwrap_or_else(Map::new); + let mission_goal = payload + .get("mission_goal") + .and_then(Value::as_str) + .unwrap_or(&message); + let mut created = new_mission(mission_goal, &app.workspace, Some(routing_overrides), &models); + save_mission(&mut created)?; + append_event( + &created.mission_id, + "mission.created", + json!({ "goal": mission_goal, "workspace": app.workspace }), + )?; + let buddy_review = plan_mission(&mut created, &fleet)?; + save_mission(&mut created)?; + append_event( + &created.mission_id, + "mission.planned", + json!({ + "plan": { + "title": created.title, + "summary": created.planner_summary, + "steps": created.steps, + }, + "buddy_review": buddy_review, + }), + )?; + + append_chat_message( + "user", + &message, + &app.workspace, + Some(&created.mission_id), + Some("mission_create"), + machine.as_deref(), + Some(role), + None, + )?; + append_chat_message( + "constant", + payload.get("reply").and_then(Value::as_str).unwrap_or("No reply."), + &app.workspace, + Some(&created.mission_id), + Some("mission_create"), + machine.as_deref(), + Some(role), + None, + )?; + if let Some(answer) = payload + .get("buddy_note") + .and_then(Value::as_object) + .and_then(|note| note.get("answer")) + .and_then(Value::as_str) + { + append_chat_message( + "buddy", + answer, + &app.workspace, + Some(&created.mission_id), + Some("buddy_answer"), + None, + None, + None, + )?; + } + app.flash(format!("mission created {}", created.mission_id)); + app.last_thread_refresh = Instant::now() - Duration::from_secs(10); + app.refresh(); + if let Some(index) = app + .threads + .iter() + .position(|summary| summary.mission_id.as_deref() == Some(created.mission_id.as_str())) + { + app.selected_thread = index; + } + return Ok(()); + } + + let reply = payload + .get("reply") + .and_then(Value::as_str) + .unwrap_or("No reply."); + append_chat_message( + "constant", + reply, + &app.workspace, + mission_id, + payload.get("intent").and_then(Value::as_str), + machine.as_deref(), + Some(role), + None, + )?; + if let Some(mission_id) = mission_id { + append_event( + mission_id, + "chat.constant", + json!({ "intent": payload.get("intent"), "reply": reply }), + )?; + } + if let Some(answer) = payload + .get("buddy_note") + .and_then(Value::as_object) + .and_then(|note| note.get("answer")) + .and_then(Value::as_str) + { + append_chat_message( + "buddy", + answer, + &app.workspace, + mission_id, + Some("buddy_answer"), + None, + None, + None, + )?; + } + + if let Some(action) = payload.get("cockpit_action").and_then(Value::as_object) { + match action.get("type").and_then(Value::as_str) { + Some("focus") => { + if let Some(machine_name) = action.get("machine").and_then(Value::as_str) { + let pane = action.get("pane").and_then(Value::as_str); + match focus_machine(machine_name, pane, &app.local_session, &app.machine_session) + { + Ok(_) => app.flash(format!( + "focused {}:{}", + machine_name, + pane.unwrap_or(role) + )), + Err(err) => app.flash(format!("focus failed: {err}")), + } + } + } + Some("restart") => { + if let Some(machine_name) = action.get("machine").and_then(Value::as_str) { + let pane = action.get("pane").and_then(Value::as_str).unwrap_or(role); + match restart_pane(machine_name, pane, &app.machine_session) { + Ok(result) if result.returncode == 0 => { + app.flash(format!("restart sent to {machine_name}:{pane}")) + } + Ok(result) => app.flash(format!("restart failed: {}", compact_err(&result.stderr))), + Err(err) => app.flash(format!("restart failed: {err}")), + } + } + } + Some("capture") => { + if let Some(machine_name) = action.get("machine").and_then(Value::as_str) { + let pane = action.get("pane").and_then(Value::as_str).unwrap_or(role); + match capture_pane(machine_name, pane, 120, &app.machine_session) { + Ok(result) if result.returncode == 0 => { + app.capture_title = format!("{machine_name}:{pane}"); + app.capture_lines = + result.stdout.lines().map(ToString::to_string).collect(); + app.capture_scroll = app.capture_lines.len().saturating_sub(20); + app.focus = FocusZone::Capture; + } + Ok(result) => app.flash(format!("capture failed: {}", compact_err(&result.stderr))), + Err(err) => app.flash(format!("capture failed: {err}")), + } + } + } + Some("open") => { + app.flash("Use z to open the full cockpit view."); + } + _ => {} + } + } else { + app.flash("message routed through Constant"); + } + + app.last_thread_refresh = Instant::now() - Duration::from_secs(10); + Ok(()) } fn draw(frame: &mut ratatui::Frame, app: &App) { - let chunks = Layout::default() + let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Min(10), - Constraint::Length(2), + Constraint::Min(12), + Constraint::Length(4), ]) .split(frame.area()); + draw_header(frame, layout[0], app); - draw_header(frame, chunks[0], app); - - let main = Layout::default() + let body = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(28), - Constraint::Percentage(48), - Constraint::Percentage(24), + Constraint::Length(32), + Constraint::Min(40), + Constraint::Length(36), ]) - .split(chunks[1]); - - draw_missions(frame, main[0], app); - if app.capture.is_some() { - draw_capture(frame, main[1], app); - } else { - draw_runtime(frame, main[1], app); - } - draw_hexapus(frame, main[2], app); - draw_footer(frame, chunks[2], app); + .split(layout[1]); + draw_threads(frame, body[0], app); + draw_conversation(frame, body[1], app); + draw_side_panel(frame, body[2], app); + draw_footer(frame, layout[2], app); } fn draw_header(frame: &mut ratatui::Frame, area: Rect, app: &App) { - let machine_text = if let Some(machine) = selected_machine(app) { - format!("{} / {}", machine.label, ROLES[app.selected_role]) - } else { - "no machine".to_string() - }; - let text = Paragraph::new(vec![ - Line::from(vec![ - Span::styled( - " CONSTANT ", - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(app.workspace.as_str(), Style::default().fg(Color::Yellow)), - ]), - Line::from(vec![ - Span::raw("fleet="), - Span::styled( - app.local_session.as_str(), - Style::default().fg(Color::Green), - ), - Span::raw(" machine="), - Span::styled(machine_text, Style::default().fg(Color::Magenta)), - ]), - ]) - .block(Block::default().borders(Borders::ALL).title("Rust Cockpit")); - frame.render_widget(text, area); + let workspace_name = std::path::Path::new(&app.workspace) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("workspace"); + let summary = format!( + "{} | {} threads | focus={} | selected={}:{}", + workspace_name, + app.threads.len().saturating_sub(1), + focus_label(app.focus), + app.selected_machine_label().unwrap_or("-"), + ROLES[app.selected_role] + ); + let block = Block::default() + .title("Constant") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let paragraph = Paragraph::new(summary).block(block); + frame.render_widget(paragraph, area); } -fn draw_missions(frame: &mut ratatui::Frame, area: Rect, app: &App) { +fn draw_threads(frame: &mut ratatui::Frame, area: Rect, app: &App) { let items = app - .missions + .threads .iter() .enumerate() - .map(|(index, mission)| { - let selected = index == app.selected_mission; - let title = mission - .get("title") - .and_then(Value::as_str) - .unwrap_or("mission"); - let status = mission - .get("status") - .and_then(Value::as_str) - .unwrap_or("unknown"); - let style = if selected { - Style::default().fg(Color::Black).bg(Color::Yellow) + .map(|(index, thread)| { + let mut style = Style::default().fg(Color::White); + if index == app.selected_thread { + style = style.fg(Color::Yellow).add_modifier(Modifier::BOLD); + } else if thread.unread_count > 0 { + style = style.fg(Color::Green); + } + let badge = if index == app.selected_thread { + "ACTIVE".to_string() + } else if thread.unread_count > 0 { + format!("NEW {}", thread.unread_count) } else { - Style::default() + format!("{} msgs", thread.message_count) }; - ListItem::new(Line::from(vec![ - Span::styled(format!("{title} "), style), - Span::styled(format!("[{status}]"), style.fg(Color::Cyan)), - ])) + ListItem::new(vec![ + Line::from(vec![Span::styled( + format!("{} [{}]", thread.title, badge), + style, + )]), + Line::from(Span::styled( + if thread.last_preview.is_empty() { + "Start a conversation".to_string() + } else { + format!("{}: {}", role_label(&thread.last_role), thread.last_preview) + }, + Style::default().fg(Color::Gray), + )), + ]) }) .collect::>(); - let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Missions")); - frame.render_widget(list, area); + let title = if app.focus == FocusZone::Threads { + "Recent Threads *" + } else { + "Recent Threads" + }; + frame.render_widget( + List::new(items).block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(if app.focus == FocusZone::Threads { + Color::Yellow + } else { + Color::Cyan + })), + ), + area, + ); } -fn draw_runtime(frame: &mut ratatui::Frame, area: Rect, app: &App) { - let mut lines = Vec::new(); - if let Some(runtime) = &app.runtime { +fn draw_conversation(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let title = app + .current_thread() + .map(|thread| thread.title.clone()) + .unwrap_or_else(|| "Conversation".to_string()); + let lines = if app.chat_history.is_empty() { + vec![ + Line::from("No conversation yet."), + Line::from(""), + Line::from("Type directly to talk to Constant."), + Line::from("Use /spec-planner or /architecture-brainstorm to route faster."), + ] + } else { + app.chat_history + .iter() + .map(|entry| { + Line::from(vec![ + Span::styled( + format!("{} ", role_label(&entry.role)), + Style::default() + .fg(role_color(&entry.role)) + .add_modifier(Modifier::BOLD), + ), + Span::raw(entry.content.clone()), + ]) + }) + .collect::>() + }; + frame.render_widget( + Paragraph::new(lines) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn draw_side_panel(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let block = Block::default() + .title(if app.focus == FocusZone::Capture && !app.capture_title.is_empty() { + format!("Capture {}", app.capture_title) + } else { + "Cockpit".to_string() + }) + .borders(Borders::ALL) + .border_style(Style::default().fg(if app.focus == FocusZone::Cockpit { + Color::Yellow + } else { + Color::Cyan + })); + + if app.focus == FocusZone::Capture && !app.capture_title.is_empty() { + let height = area.height.saturating_sub(2) as usize; + let start = app.capture_scroll.min(app.capture_lines.len().saturating_sub(height)); + let visible = app + .capture_lines + .iter() + .skip(start) + .take(height) + .cloned() + .collect::>() + .join("\n"); + frame.render_widget(Paragraph::new(visible).block(block).wrap(Wrap { trim: false }), area); + return; + } + + let lines = if let Some(runtime) = &app.runtime { + let mut lines = vec![ + Line::from(format!( + "session={} focused={}:{}", + runtime.local_session, + runtime.focused_machine.as_deref().unwrap_or("-"), + runtime.focused_role.as_deref().unwrap_or("-") + )), + Line::from(""), + ]; for (index, machine) in runtime.machines.iter().enumerate() { - let selected = index == app.selected_machine; - let prefix = if selected { ">" } else { " " }; + let marker = if index == app.selected_machine { ">" } else { " " }; lines.push(Line::from(Span::styled( format!( - "{prefix} {} [{}]", + "{marker} {} [{}]", machine.label, if machine.session_exists { "up" } else { "down" } ), - if selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) + Style::default().fg(if index == app.selected_machine { + Color::Yellow } else { - Style::default() - }, + Color::White + }), ))); - for (role_index, role) in ROLES.iter().enumerate() { - let marker = if selected && role_index == app.selected_role { - "*" - } else { - " " - }; - let pane = machine.roles.get(*role).and_then(|pane| pane.as_ref()); - let status = match pane { - Some(pane) if pane.dead => "dead", - Some(_) => "live", - None => "missing", - }; - lines.push(Line::from(format!(" {marker} {} {}", role, status))); - } } + lines.push(Line::from("")); + lines.push(Line::from("Controls: [ ] machine | 1..4 pane | o focus | r restart | x capture | z open cockpit")); + lines } else { - lines.push(Line::from("No runtime status.")); - } - let paragraph = Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL).title("Runtime")) - .wrap(Wrap { trim: true }); - frame.render_widget(paragraph, area); -} + vec![ + Line::from("No runtime snapshot yet."), + Line::from(""), + Line::from("Controls: [ ] machine | 1..4 pane | o focus | r restart | x capture | z open cockpit"), + ] + }; -fn draw_capture(frame: &mut ratatui::Frame, area: Rect, app: &App) { - let capture = app.capture.as_deref().unwrap_or(""); - let paragraph = Paragraph::new(capture) - .block( - Block::default() - .borders(Borders::ALL) - .title(format!("Capture {}", app.capture_title)), - ) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, area); -} - -fn draw_hexapus(frame: &mut ratatui::Frame, area: Rect, app: &App) { - let tick = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|value| value.as_millis()) - .unwrap_or(0); - let frame_index = (tick / 220 % HEXAPUS_FRAMES.len() as u128) as usize; - let frame_lines = HEXAPUS_FRAMES[frame_index]; - let mut lines = frame_lines - .iter() - .map(|line| Line::from(Span::styled(*line, Style::default().fg(Color::Cyan)))) - .collect::>(); - lines.push(Line::from("")); - if let Some(machine) = selected_machine(app) { - lines.push(Line::from(format!("selected {}", machine.label))); - lines.push(Line::from(format!( - "pane {}", - ROLES[app.selected_role] - ))); - lines.push(Line::from(format!( - "focused {}", - app.runtime - .as_ref() - .and_then(|runtime| runtime.focused_machine.clone()) - .unwrap_or_else(|| "-".to_string()) - ))); - } - let paragraph = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title("Hexapus Buddy"), - ) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, area); + frame.render_widget(Paragraph::new(lines).block(block).wrap(Wrap { trim: false }), area); } fn draw_footer(frame: &mut ratatui::Frame, area: Rect, app: &App) { - let message = if app.flash.is_empty() { - "j/k mission | [/] machine | 1..4 pane | o focus | x capture | r restart | z cockpit | q quit".to_string() - } else { + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(3)]) + .split(area); + let status = if !app.flash.is_empty() { app.flash.clone() + } else { + match app.focus { + FocusZone::Composer => "chat | Enter send | Left threads | Esc cockpit | Ctrl-Q quit".to_string(), + FocusZone::Threads => "threads | j/k navigate | Enter reply | d delete | Esc cockpit".to_string(), + FocusZone::Cockpit => "cockpit | [ ] machine | 1..4 pane | o focus | r restart | x capture | Right chat".to_string(), + FocusZone::Capture => "capture | j/k scroll | x close".to_string(), + } }; - let paragraph = - Paragraph::new(message).block(Block::default().borders(Borders::ALL).title("Keys")); - frame.render_widget(paragraph, area); - - if let Some(capture) = &app.capture { - let popup = centered_rect(80, 70, area); - frame.render_widget(Clear, popup); - let block = Paragraph::new(capture.as_str()) + frame.render_widget(Paragraph::new(status), sections[0]); + frame.render_widget( + Paragraph::new(format!("› {}", app.input)) .block( Block::default() + .title(if app.focus == FocusZone::Composer { + "Ask Constant *" + } else { + "Ask Constant" + }) .borders(Borders::ALL) - .title("Capture view (Esc/x close)"), + .border_style(Style::default().fg(if app.focus == FocusZone::Composer { + Color::Yellow + } else { + Color::Cyan + })), ) - .wrap(Wrap { trim: false }); - frame.render_widget(block, popup); + .wrap(Wrap { trim: false }), + sections[1], + ); +} + +fn compact_err(stderr: &str) -> String { + stderr + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or("command failed") + .to_string() +} + +fn role_label(role: &str) -> &'static str { + match role { + "user" => "YOU", + "constant" => "CONSTANT", + "buddy" => "BUDDY", + "system" => "SYSTEM", + _ => "CHAT", } } -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] +fn role_color(role: &str) -> Color { + match role { + "user" => Color::Green, + "constant" => Color::Cyan, + "buddy" => Color::Magenta, + "system" => Color::Yellow, + _ => Color::White, + } } -fn compact_err(text: &str) -> String { - text.lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .unwrap_or("no details") - .to_string() +fn focus_label(focus: FocusZone) -> &'static str { + match focus { + FocusZone::Composer => "composer", + FocusZone::Threads => "threads", + FocusZone::Cockpit => "cockpit", + FocusZone::Capture => "capture", + } } diff --git a/tests/cli_cutover.rs b/tests/cli_cutover.rs new file mode 100644 index 0000000..ca5833e --- /dev/null +++ b/tests/cli_cutover.rs @@ -0,0 +1,297 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +fn unique_temp_dir(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!("constant-cli-test-{label}-{unique}")); + fs::create_dir_all(&path).expect("create temp dir"); + path +} + +fn constant_output(home: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_constant")) + .args(args) + .env("HOME", home) + .output() + .expect("run constant") +} + +fn write_json(path: &Path, payload: Value) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dir"); + } + fs::write(path, format!("{}\n", serde_json::to_string_pretty(&payload).unwrap())) + .expect("write json"); +} + +fn write_mission(home: &Path, mission_id: &str, status: &str, workspace: &Path) { + let path = home + .join(".cache/constant/missions") + .join(mission_id) + .join("mission.json"); + write_json( + &path, + json!({ + "mission_id": mission_id, + "title": format!("Mission {mission_id}"), + "goal": "Ship the migration", + "workspace": workspace.display().to_string(), + "status": status, + "priority": "normal", + "created_at": "2026-04-06T08:00:00Z", + "updated_at": "2026-04-06T08:00:00Z", + "planner_model": "heuristic", + "buddy_model": "heuristic", + "verify_model": "heuristic", + "owner": "Constant", + "routing_overrides": {}, + "steps": [], + "artifacts": [], + "meta": {}, + }), + ); +} + +fn stable_hash_hex(value: &str) -> String { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in value.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +fn workspace_slug(workspace: &Path) -> String { + let resolved = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); + let name = resolved + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("workspace"); + format!("{name}-{}", &stable_hash_hex(&resolved.display().to_string())[..10]) +} + +#[test] +fn fleet_config_get_reads_existing_config() { + let home = unique_temp_dir("fleet-config-get"); + let config_path = home.join(".config/constant/fleet.json"); + write_json( + &config_path, + json!({ + "version": 1, + "local_machine": "command-center", + "repo_dir": "$HOME/constant", + "machines": [ + { "label": "command-center", "target": "local", "auto_clis": [], "manual_clis": [], "backends": ["cockpit"] }, + { "label": "builder-a", "target": "dev@builder-a", "auto_clis": [], "manual_clis": [], "backends": ["cli-ssh"] } + ], + }), + ); + + let repo_dir = constant_output(&home, &["fleet", "config-get", "repo_dir"]); + assert!(repo_dir.status.success()); + assert_eq!(String::from_utf8_lossy(&repo_dir.stdout).trim(), "$HOME/constant"); + + let machine_specs = constant_output(&home, &["fleet", "config-get", "machine_specs"]); + assert!(machine_specs.status.success()); + let lines = String::from_utf8_lossy(&machine_specs.stdout); + assert!(lines.contains("command-center=local")); + assert!(lines.contains("builder-a=dev@builder-a")); +} + +#[test] +fn fleet_render_scan_json_emits_candidate_payload() { + let home = unique_temp_dir("fleet-render-scan-json"); + let scan_file = home.join("scan.tsv"); + fs::write( + &scan_file, + "builder-a\tdev\tbuilder-a\t22\tyes\tbuilder-a\tDarwin\t/Users/dev\t\n", + ) + .unwrap(); + + let output = constant_output( + &home, + &[ + "fleet", + "render-scan-json", + scan_file.to_str().expect("scan path"), + ], + ); + assert!(output.status.success()); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["candidates"][0]["host"], "builder-a"); + assert_eq!(payload["candidates"][0]["port"], 22); + assert_eq!(payload["candidates"][0]["reachable"], true); +} + +#[test] +fn fleet_write_config_generates_json_file() { + let home = unique_temp_dir("fleet-write-config"); + let finalized = home.join("finalized.tsv"); + let output_path = home.join("fleet.json"); + fs::write( + &finalized, + "command-center\tlocal\tlocal\tlocal\nbuilder-a\tremote\tdev\tbuilder-a\n", + ) + .unwrap(); + + let output = constant_output( + &home, + &[ + "fleet", + "write-config", + finalized.to_str().expect("finalized path"), + "--output", + output_path.to_str().expect("output path"), + "--repo-dir", + "$HOME/constant", + ], + ); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), output_path.display().to_string()); + + let payload: Value = serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); + assert_eq!(payload["local_machine"], "command-center"); + assert_eq!(payload["machines"][1]["target"], "dev@builder-a"); +} + +#[test] +fn memory_sync_qdrant_skips_when_url_is_not_configured() { + let home = unique_temp_dir("memory-sync"); + + let output = constant_output(&home, &["memory", "sync-qdrant", "--json"]); + assert!(output.status.success()); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["skipped"], true); + assert_eq!(payload["ok"], false); +} + +#[test] +fn mission_delete_blocks_active_missions() { + let home = unique_temp_dir("mission-delete-blocked"); + let workspace = home.join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + write_mission(&home, "mission-running", "running", &workspace); + + let output = constant_output( + &home, + &["mission", "delete", "mission-running", "--json"], + ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("cannot be deleted yet")); +} + +#[test] +fn mission_delete_removes_terminal_thread_files() { + let home = unique_temp_dir("mission-delete-terminal"); + let workspace = home.join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + write_mission(&home, "mission-done", "done", &workspace); + + let mission_chat = home.join(".cache/constant/chat/missions/mission-done.ndjson"); + if let Some(parent) = mission_chat.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write( + &mission_chat, + "{\"timestamp\":\"2026-04-06T08:01:00Z\",\"role\":\"constant\",\"content\":\"Done\"}\n", + ) + .unwrap(); + + let mission_index = home + .join(".local/share/constant/indexes/chat/threads/mission-mission-done.json"); + write_json( + &mission_index, + json!({ + "thread_key": "mission:mission-done", + "workspace": workspace.display().to_string(), + "mission_id": "mission-done", + "kind": "mission", + "title": "Mission mission-done", + "mission_status": "done", + "message_count": 1, + "last_role": "constant", + "last_preview": "Done", + "last_timestamp": "2026-04-06T08:01:00Z", + "chat_mtime": 1, + "updated_at": "2026-04-06T08:01:00Z", + }), + ); + + let view_state = home.join(format!( + ".local/share/constant/indexes/chat/views/workspace-{}.json", + workspace_slug(&workspace) + )); + write_json( + &view_state, + json!({ + "seen_counts": { + "mission:mission-done": 1 + } + }), + ); + + let output = constant_output(&home, &["mission", "delete", "mission-done", "--json"]); + assert!(output.status.success()); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["deleted"], true); + + assert!(!home.join(".cache/constant/missions/mission-done").exists()); + assert!(!mission_chat.exists()); + assert!(!mission_index.exists()); + + let view_payload: Value = serde_json::from_str(&fs::read_to_string(&view_state).unwrap()).unwrap(); + assert!(view_payload["seen_counts"]["mission:mission-done"].is_null()); +} + +#[test] +fn cockpit_status_line_rebuilds_workspace_sidecar_from_chat() { + let home = unique_temp_dir("status-line-sidecar"); + let workspace = home.join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + + let slug = workspace_slug(&workspace); + let workspace_chat = home.join(format!( + ".cache/constant/chat/workspaces/{slug}.ndjson" + )); + if let Some(parent) = workspace_chat.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write( + &workspace_chat, + "{\"timestamp\":\"2026-04-06T08:00:00Z\",\"role\":\"user\",\"content\":\"hello from workspace\"}\n", + ) + .unwrap(); + + let output = constant_output( + &home, + &[ + "cockpit", + "status-line", + "--workspace", + workspace.to_str().expect("workspace path"), + "--scope-label", + "fleet", + "--max-length", + "120", + ], + ); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("fleet | workspace")); + assert!(stdout.contains("YOU: hello from workspace")); + + let sidecar = home.join(format!( + ".local/share/constant/indexes/chat/threads/workspace-{slug}.json" + )); + assert!(sidecar.exists()); +}