Migrate Constant to a Rust-only runtime
This commit is contained in:
+11
-4
@@ -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:
|
||||
|
||||
@@ -15,3 +15,4 @@ ratatui = "0.29"
|
||||
[[bin]]
|
||||
name = "constant"
|
||||
path = "src/main.rs"
|
||||
test = false
|
||||
|
||||
@@ -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 <mission_id>` 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 <mission_id> --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 <mission_id> --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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,5 +0,0 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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,
|
||||
}
|
||||
-943
@@ -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())
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
-1199
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
-1725
File diff suppressed because it is too large
Load Diff
+18
-128
@@ -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
|
||||
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
|
||||
sleep 0.1
|
||||
(( waited += 1 ))
|
||||
done
|
||||
|
||||
kill "$probe_pid" 2>/dev/null || true
|
||||
wait "$probe_pid" 2>/dev/null || true
|
||||
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" "$@"
|
||||
|
||||
Executable
+21
@@ -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 "$@"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
+100
-46
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+558
@@ -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<String>,
|
||||
pub machine: Option<String>,
|
||||
pub pane: Option<String>,
|
||||
#[serde(default)]
|
||||
pub meta: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ThreadIndex {
|
||||
pub thread_key: String,
|
||||
pub workspace: String,
|
||||
pub mission_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub mission_status: Option<String>,
|
||||
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<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ThreadSummary {
|
||||
pub thread_key: String,
|
||||
pub workspace: String,
|
||||
pub mission_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub mission_status: Option<String>,
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<PathBuf, String> {
|
||||
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<serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Result<ChatEntry, String> {
|
||||
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<Vec<ChatEntry>, 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::<ChatEntry>(&raw) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn refresh_thread_index(
|
||||
workspace: &str,
|
||||
mission_id: Option<&str>,
|
||||
title: &str,
|
||||
mission_status: Option<&str>,
|
||||
) -> Result<Option<ThreadIndex>, 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<ChatEntry> = 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::<ChatEntry>(&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<Vec<ThreadSummary>, 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::<Vec<_>>();
|
||||
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<Option<ThreadSummary>, 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<String, String> {
|
||||
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<ThreadViewState, String> {
|
||||
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<PathBuf, String> {
|
||||
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<Option<ThreadIndex>, 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<PathBuf, String> {
|
||||
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::<Vec<_>>().join(" ");
|
||||
if normalized.len() <= limit {
|
||||
return normalized;
|
||||
}
|
||||
format!("{}...", normalized.chars().take(limit.saturating_sub(3)).collect::<String>().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::<String>()
|
||||
.trim_end()
|
||||
)
|
||||
}
|
||||
|
||||
fn tail_lines(path: &Path, limit: usize, max_bytes: usize) -> Result<Vec<String>, 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::<Vec<_>>();
|
||||
if lines.len() > limit {
|
||||
lines = lines.split_off(lines.len() - limit);
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn file_mtime_secs(path: &Path) -> Result<u64, String> {
|
||||
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}")
|
||||
}
|
||||
+51
-2
@@ -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<Valu
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn cockpit_status_line(
|
||||
workspace: &str,
|
||||
scope_label: Option<&str>,
|
||||
machine_label: Option<&str>,
|
||||
max_length: usize,
|
||||
local_session: &str,
|
||||
machine_session: &str,
|
||||
) -> Result<String, String> {
|
||||
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<PaneStatus> {
|
||||
.lines()
|
||||
.filter_map(|raw| {
|
||||
let parts = raw.split('\t').collect::<Vec<_>>();
|
||||
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<PaneStatus> {
|
||||
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::<Vec<_>>();
|
||||
@@ -361,10 +382,38 @@ fn tmux_list_command(session_name: &str) -> Vec<String> {
|
||||
"-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<String> {
|
||||
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::<String>()
|
||||
.trim_end()
|
||||
)
|
||||
}
|
||||
|
||||
fn ssh_command(target: &str, inner: &str) -> Vec<String> {
|
||||
vec![
|
||||
"ssh".to_string(),
|
||||
|
||||
@@ -173,6 +173,23 @@ pub fn load_fleet_config() -> Result<FleetConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn read_fleet_config_if_present() -> Result<Option<FleetConfig>, 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<ModelsConfig, String> {
|
||||
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,
|
||||
|
||||
+123
@@ -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<Value, String> {
|
||||
@@ -54,3 +58,122 @@ pub fn bridge_sync() -> Result<Value, String> {
|
||||
"stderr": String::from_utf8_lossy(&output.stderr),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn fleet_config_query(expr: &str) -> Result<Option<Vec<String>>, 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<Value, String> {
|
||||
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::<u16>().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<PathBuf, String> {
|
||||
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<String> = 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)
|
||||
}
|
||||
|
||||
+445
-153
@@ -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<ExitCode, String> {
|
||||
}
|
||||
"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 <prompt> [--workspace DIR] [--json]
|
||||
mission plan <mission_id> [--json]
|
||||
mission run <mission_id> [--json]
|
||||
mission delete <mission_id> [--json]
|
||||
mission status [mission_id] [--verbose] [--json]
|
||||
mission tail <mission_id> [--follow]
|
||||
mission verify <mission_id> [--step-id ID] [--json]
|
||||
mission retry <mission_id> [--step-id ID] [--json]
|
||||
mission summarize <mission_id> [--json]
|
||||
delegate <mission_id> [--step-id ID] [--machine LABEL] [--backend NAME] [--cli NAME] [--agent ID] [--skill ID] [--json]
|
||||
buddy ask <prompt> [--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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
.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<ExitCode, String> {
|
||||
"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<ExitCode, String> {
|
||||
"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<ExitCode, String> {
|
||||
}
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
.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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
);
|
||||
}
|
||||
"--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<ExitCode, String> {
|
||||
);
|
||||
}
|
||||
"--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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn mission_delete_cmd(args: &[String]) -> Result<ExitCode, String> {
|
||||
let mut mission_id: Option<String> = 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<ExitCode, String> {
|
||||
if args.is_empty() {
|
||||
return Err("delegate requires a mission_id".to_string());
|
||||
@@ -663,10 +715,8 @@ fn handle_delegate(args: &[String]) -> Result<ExitCode, String> {
|
||||
);
|
||||
}
|
||||
"--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<ExitCode, String> {
|
||||
);
|
||||
}
|
||||
"--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<ExitCode, String> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn memory_status_cmd(args: &[String]) -> Result<ExitCode, String> {
|
||||
let mut workspace: Option<String> = 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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
let mut path: Option<String> = 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<ExitCode, String> {
|
||||
let mut query: Option<String> = None;
|
||||
let mut workspace: Option<String> = None;
|
||||
let mut limit: Option<usize> = 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::<usize>()
|
||||
.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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
let mut workspace: Option<String> = None;
|
||||
let mut mission_id: Option<String> = 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<ExitCode, String> {
|
||||
let mut workspace: Option<String> = 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<ExitCode, String> {
|
||||
let as_json = args.iter().any(|arg| arg == "--json");
|
||||
let payload = fleet_check()?;
|
||||
@@ -755,16 +990,63 @@ fn fleet_sync_cmd(args: &[String]) -> Result<ExitCode, String> {
|
||||
Ok(exit_code_from_status(Some(if ok { 0 } else { 1 })))
|
||||
}
|
||||
|
||||
fn handoff_mission_subcommand(prefix: &str, tail: &[String]) -> Result<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<PathBuf> = None;
|
||||
let mut repo_dir: Option<String> = 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<ExitCode, String> {
|
||||
@@ -802,7 +1084,7 @@ fn cockpit_open_cmd(args: &[String]) -> Result<ExitCode, String> {
|
||||
}
|
||||
"--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<ExitCode, String> {
|
||||
.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<ExitCode, String> {
|
||||
.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<ExitCode, String> {
|
||||
.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<ExitCode, String> {
|
||||
.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<ExitCode, String> {
|
||||
.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<ExitCode, String> {
|
||||
.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<ExitCode, String> {
|
||||
.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<ExitCode, String> {
|
||||
Ok(exit_code_from_status(Some(payload.returncode)))
|
||||
}
|
||||
|
||||
fn handoff_to_python_with_prefix(prefix: &str, tail: &[String]) -> Result<ExitCode, String> {
|
||||
let mut args = vec![prefix.to_string()];
|
||||
args.extend(tail.iter().cloned());
|
||||
handoff_to_python(&args)
|
||||
}
|
||||
|
||||
fn handoff_to_python(args: &[String]) -> Result<ExitCode, String> {
|
||||
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<ExitCode, String> {
|
||||
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<String> = None;
|
||||
let mut machine_label: Option<String> = 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())?;
|
||||
}
|
||||
value
|
||||
"--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::<usize>()
|
||||
.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;
|
||||
}
|
||||
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<String, String> {
|
||||
@@ -1216,6 +1537,7 @@ fn doctor_value() -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
"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<Value, String> {
|
||||
"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::<u64>() {
|
||||
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;
|
||||
|
||||
+121
@@ -707,6 +707,83 @@ pub fn summarize_mission_to_memory(mission: &Mission) -> Result<Value, String> {
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn sync_qdrant(workspace: Option<&str>) -> Result<Value, String> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<String> {
|
||||
tokens
|
||||
}
|
||||
|
||||
fn embed_text(text: &str, dims: usize) -> Vec<f64> {
|
||||
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::<f64>().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)
|
||||
|
||||
+485
@@ -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<Value, String> {
|
||||
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::<serde_json::Map<String, Value>>();
|
||||
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<String, Value>,
|
||||
) -> Result<Value, String> {
|
||||
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::<String>(),
|
||||
"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<Option<String>, 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<String> {
|
||||
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::<Vec<_>>();
|
||||
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))
|
||||
}
|
||||
@@ -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(),
|
||||
] {
|
||||
|
||||
+685
-284
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user