Add Constant TUI and durable memory commands
This commit is contained in:
@@ -39,11 +39,13 @@ Current build status in this repo:
|
||||
- a 4-pane host-local Zellij session per machine
|
||||
- a fleet cockpit with one tab per machine
|
||||
- `Constant`, the orchestration CLI on top of the cockpit
|
||||
- a terminal TUI with a central `hexapus` buddy rail
|
||||
- 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
|
||||
- workspace-first durable memory with lexical + local vector search
|
||||
|
||||
The historical repo name may still say `zellij-ai-triple`.
|
||||
The product name is now `Constant`.
|
||||
@@ -178,9 +180,12 @@ codex login --device-auth
|
||||
|
||||
```bash
|
||||
./scripts/Constant
|
||||
./scripts/Constant tui
|
||||
./scripts/Constant doctor
|
||||
./scripts/Constant mission create "audit the repo" --workspace "$PWD"
|
||||
./scripts/Constant mission status
|
||||
./scripts/Constant memory rebuild --workspace "$PWD"
|
||||
./scripts/Constant memory search "buddy rail" --workspace "$PWD"
|
||||
./scripts/Constant cockpit open --workspace "$PWD"
|
||||
```
|
||||
|
||||
@@ -190,6 +195,25 @@ If `Constant` is on your `PATH`, you can also just run:
|
||||
Constant
|
||||
```
|
||||
|
||||
In interactive mode, `Constant` with no arguments now opens the TUI by default.
|
||||
|
||||
### TUI keys
|
||||
|
||||
`Constant tui` gives you:
|
||||
|
||||
- a mission deck
|
||||
- a mission board
|
||||
- a `hexapus` buddy rail
|
||||
- a bottom timeline mixed with memory echoes
|
||||
|
||||
Useful keys:
|
||||
|
||||
- `j` / `k`: move between missions
|
||||
- `e`: rebuild memory for the current workspace
|
||||
- `s`: summarize the selected mission into durable memory
|
||||
- `z`: open the full fleet cockpit
|
||||
- `q`: quit the TUI
|
||||
|
||||
## Fleet Configuration
|
||||
|
||||
For a public setup, think in terms of roles, not personal machine names.
|
||||
@@ -213,6 +237,15 @@ The command-center machine is where you run:
|
||||
|
||||
The workers are where execution happens.
|
||||
|
||||
Public example config:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/constant
|
||||
cp examples/fleet.example.json ~/.config/constant/fleet.json
|
||||
```
|
||||
|
||||
Legacy `~/.config/constant/fleet.yaml` is still read for compatibility, but the public-facing format is now JSON.
|
||||
|
||||
## Messaging
|
||||
|
||||
Each machine exposes a local bus:
|
||||
@@ -290,13 +323,14 @@ Planned layers for the public `Constant` repo:
|
||||
## Repo Layout
|
||||
|
||||
```text
|
||||
constant/ Python orchestration core
|
||||
scripts/Constant canonical CLI entrypoint
|
||||
scripts/constant-machine.sh canonical single-machine cockpit entrypoint
|
||||
scripts/constant-fleet.sh canonical fleet cockpit entrypoint
|
||||
scripts/constant-fleet-install.sh
|
||||
scripts/ai-msg.sh local agent bus
|
||||
scripts/ai-bridge.sh inter-machine bridge
|
||||
constant/ Python orchestration core
|
||||
examples/fleet.example.json public fleet template
|
||||
scripts/Constant canonical CLI entrypoint
|
||||
scripts/constant-machine.sh canonical single-machine cockpit entrypoint
|
||||
scripts/constant-fleet.sh canonical fleet cockpit entrypoint
|
||||
scripts/constant-fleet-install.sh canonical fleet installer/checker
|
||||
scripts/ai-msg.sh local agent bus
|
||||
scripts/ai-bridge.sh inter-machine bridge
|
||||
```
|
||||
|
||||
## Current Caveats
|
||||
|
||||
+136
-1
@@ -11,6 +11,16 @@ from typing import Any
|
||||
from . import __version__
|
||||
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,
|
||||
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,
|
||||
@@ -25,6 +35,7 @@ from .state import (
|
||||
save_mission,
|
||||
write_artifact,
|
||||
)
|
||||
from .tui import run_tui
|
||||
|
||||
|
||||
def _print(data: Any, as_json: bool = False) -> None:
|
||||
@@ -44,6 +55,20 @@ def _command_exists(binary: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
@@ -103,6 +128,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
||||
"daemon": status,
|
||||
"models": models,
|
||||
"health": health,
|
||||
"memory": memory_status(),
|
||||
"fleet": [{"label": entry["label"], "target": entry["target"]} for entry in fleet["machines"]],
|
||||
}
|
||||
_print(report, args.json)
|
||||
@@ -176,7 +202,22 @@ def cmd_cockpit_open(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_tui(args: argparse.Namespace) -> int:
|
||||
workspace = str(Path(args.workspace or os.getcwd()).expanduser().resolve())
|
||||
action = run_tui(workspace)
|
||||
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,
|
||||
)
|
||||
)
|
||||
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)
|
||||
@@ -185,6 +226,7 @@ def cmd_mission_create(args: argparse.Namespace) -> int:
|
||||
|
||||
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"]
|
||||
@@ -243,6 +285,7 @@ def cmd_mission_run(args: argparse.Namespace) -> int:
|
||||
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:
|
||||
@@ -250,6 +293,7 @@ def cmd_mission_run(args: argparse.Namespace) -> int:
|
||||
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
|
||||
|
||||
@@ -260,6 +304,7 @@ def cmd_mission_run(args: argparse.Namespace) -> int:
|
||||
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
|
||||
|
||||
@@ -350,6 +395,8 @@ def cmd_delegate(args: argparse.Namespace) -> int:
|
||||
|
||||
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})
|
||||
@@ -357,6 +404,48 @@ def cmd_buddy_ask(args: argparse.Namespace) -> int:
|
||||
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="Constant")
|
||||
parser.add_argument("-V", "--version", action="version", version=__version__)
|
||||
@@ -366,6 +455,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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")
|
||||
@@ -435,6 +530,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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")
|
||||
@@ -454,6 +553,42 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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())
|
||||
|
||||
@@ -465,7 +600,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
argv = sys.argv[1:]
|
||||
if not argv:
|
||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||
argv = ["cockpit", "open", "--workspace", os.getcwd()]
|
||||
argv = ["tui", "--workspace", os.getcwd()]
|
||||
else:
|
||||
argv = ["doctor"]
|
||||
parser = build_parser()
|
||||
|
||||
+18
-2
@@ -79,15 +79,31 @@ INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md")
|
||||
INSTRUCTION_SUFFIXES = {".json", ".md", ".prompt", ".toml", ".txt", ".yaml", ".yml"}
|
||||
MAX_TEXT_BYTES = 256 * 1024
|
||||
MAX_INSTRUCTION_BYTES = 128 * 1024
|
||||
SQLITE_TIMEOUT_SECONDS = 10.0
|
||||
SQLITE_BUSY_TIMEOUT_MS = 10_000
|
||||
|
||||
|
||||
def _enable_wal(connection: sqlite3.Connection) -> None:
|
||||
try:
|
||||
row = connection.execute("pragma journal_mode").fetchone()
|
||||
current_mode = str(row[0]).lower() if row else ""
|
||||
if current_mode == "wal":
|
||||
return
|
||||
connection.execute("pragma journal_mode = wal")
|
||||
except sqlite3.OperationalError:
|
||||
# Another process may already hold the database. Keep the connection usable
|
||||
# instead of failing the whole command or TUI refresh.
|
||||
return
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
path = memory_store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(path)
|
||||
connection = sqlite3.connect(path, timeout=SQLITE_TIMEOUT_SECONDS)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("pragma journal_mode = wal")
|
||||
connection.execute(f"pragma busy_timeout = {SQLITE_BUSY_TIMEOUT_MS}")
|
||||
connection.execute("pragma foreign_keys = on")
|
||||
_enable_wal(connection)
|
||||
_ensure_schema(connection)
|
||||
return connection
|
||||
|
||||
|
||||
+3
-3
@@ -32,15 +32,15 @@ def missions_dir() -> Path:
|
||||
|
||||
|
||||
def fleet_config_path() -> Path:
|
||||
return config_root() / "fleet.yaml"
|
||||
return config_root() / "fleet.json"
|
||||
|
||||
|
||||
def models_config_path() -> Path:
|
||||
return config_root() / "models.yaml"
|
||||
return config_root() / "models.json"
|
||||
|
||||
|
||||
def memory_config_path() -> Path:
|
||||
return config_root() / "memory.yaml"
|
||||
return config_root() / "memory.json"
|
||||
|
||||
|
||||
def daemon_pid_path() -> Path:
|
||||
|
||||
@@ -112,8 +112,19 @@ def ensure_runtime_dirs() -> None:
|
||||
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)
|
||||
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import json
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .daemon import request as daemon_request
|
||||
from .memory import list_decisions, memory_status, persona_markdown, rebuild_workspace_memory, summarize_mission
|
||||
from .state import list_missions, load_fleet_config, mission_events_file
|
||||
|
||||
|
||||
def _error_line(prefix: str, exc: Exception) -> str:
|
||||
return f"{prefix}: {str(exc).strip() or exc.__class__.__name__}"
|
||||
|
||||
|
||||
def _safe_memory_status(workspace: str) -> dict[str, Any]:
|
||||
try:
|
||||
return memory_status(workspace)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"workspace": workspace,
|
||||
"counts": {},
|
||||
"error": _error_line("memory", exc),
|
||||
}
|
||||
|
||||
|
||||
def _safe_persona_lines() -> list[str]:
|
||||
try:
|
||||
return [
|
||||
line[2:]
|
||||
for line in persona_markdown().splitlines()
|
||||
if line.startswith("- ")
|
||||
]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return [_error_line("persona", exc)]
|
||||
|
||||
|
||||
def _safe_decision_lines(workspace: str) -> list[str]:
|
||||
try:
|
||||
decisions = list_decisions(workspace=workspace).get("decisions", [])[:3]
|
||||
return [f"{item['decision_id'].split(':')[-1]} {item['status']} {item['title']}" for item in decisions]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return [_error_line("decisions", exc)]
|
||||
|
||||
|
||||
def _clip(text: str, width: int) -> str:
|
||||
if width <= 0:
|
||||
return ""
|
||||
if len(text) <= width:
|
||||
return text
|
||||
if width <= 3:
|
||||
return text[:width]
|
||||
return text[: width - 3] + "..."
|
||||
|
||||
|
||||
def _safe_addstr(stdscr: Any, y: int, x: int, text: str, attr: int = 0) -> None:
|
||||
height, width = stdscr.getmaxyx()
|
||||
if y < 0 or y >= height or x >= width:
|
||||
return
|
||||
text = _clip(text, width - x)
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
stdscr.addstr(y, x, text, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
def _draw_box(stdscr: Any, y: int, x: int, height: int, width: int, title: str, title_attr: int = 0) -> None:
|
||||
if height < 3 or width < 4:
|
||||
return
|
||||
_safe_addstr(stdscr, y, x, "+" + ("-" * max(0, width - 2)) + "+")
|
||||
for row in range(y + 1, y + height - 1):
|
||||
_safe_addstr(stdscr, row, x, "|" + (" " * max(0, width - 2)) + "|")
|
||||
_safe_addstr(stdscr, y + height - 1, x, "+" + ("-" * max(0, width - 2)) + "+")
|
||||
_safe_addstr(stdscr, y, x + 2, f"[ {title} ]", title_attr)
|
||||
|
||||
|
||||
def _write_wrapped(stdscr: Any, y: int, x: int, width: int, lines: list[str], attr: int = 0) -> int:
|
||||
row = y
|
||||
for line in lines:
|
||||
wrapped = textwrap.wrap(line, max(8, width)) or [""]
|
||||
for item in wrapped:
|
||||
_safe_addstr(stdscr, row, x, item, attr)
|
||||
row += 1
|
||||
return row
|
||||
|
||||
|
||||
def _recent_events(mission_id: str | None, limit: int = 8) -> list[str]:
|
||||
if mission_id:
|
||||
path = mission_events_file(mission_id)
|
||||
if not path.exists():
|
||||
return []
|
||||
lines = path.read_text(encoding="utf-8").splitlines()[-limit:]
|
||||
events = []
|
||||
for raw in lines:
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
event_type = event.get("type", "event")
|
||||
timestamp = str(event.get("timestamp", ""))[-9:-1]
|
||||
payload = event.get("payload", {})
|
||||
detail = payload.get("summary") or payload.get("step_id") or payload.get("machine") or payload.get("mission_id") or ""
|
||||
line = f"{timestamp} {event_type}"
|
||||
if detail:
|
||||
line += f" :: {detail}"
|
||||
events.append(line)
|
||||
return events[-limit:]
|
||||
|
||||
events: list[str] = []
|
||||
for mission in sorted(list_missions(), key=lambda item: item.get("updated_at", ""), reverse=True)[:5]:
|
||||
events.extend(_recent_events(mission["mission_id"], limit=3))
|
||||
return events[-limit:]
|
||||
|
||||
|
||||
def _normalize_buddy(review: dict[str, Any] | None) -> dict[str, str]:
|
||||
if not review:
|
||||
return {
|
||||
"verdict": "idle",
|
||||
"confidence": "--",
|
||||
"why": "No buddy review yet.",
|
||||
"change": "none",
|
||||
"memory": "idle",
|
||||
}
|
||||
|
||||
if "verdict" in review:
|
||||
change = review.get("change") or {}
|
||||
target = "/".join(
|
||||
part for part in [change.get("machine"), change.get("cli"), change.get("backend")] if part
|
||||
) or "none"
|
||||
memory = review.get("memory", {})
|
||||
memory_hint = "store" if memory.get("store") else "ignore"
|
||||
return {
|
||||
"verdict": str(review.get("verdict", "warn")),
|
||||
"confidence": str(review.get("confidence", "medium")),
|
||||
"why": str(review.get("why", review.get("summary", ""))) or "No reason provided.",
|
||||
"change": target,
|
||||
"memory": memory_hint,
|
||||
}
|
||||
|
||||
agrees = bool(review.get("agrees", True))
|
||||
verdict = "agree" if agrees else ("reroute" if review.get("suggested_cli") or review.get("suggested_backend") else "warn")
|
||||
change = "/".join(part for part in [review.get("suggested_cli"), review.get("suggested_backend")] if part) or "none"
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"confidence": "medium" if agrees else "high",
|
||||
"why": str(review.get("summary", "No review summary.")),
|
||||
"change": change,
|
||||
"memory": "consider",
|
||||
}
|
||||
|
||||
|
||||
def _hexapus_lines() -> list[str]:
|
||||
return [
|
||||
" .-====-.",
|
||||
" .-' .--. `-.",
|
||||
" / ( oo ) \\",
|
||||
" | \\/\\/ |",
|
||||
" | .-____-. |",
|
||||
" \\__/_/ || \\_\\__/",
|
||||
" /_ /||\\ _\\",
|
||||
" .-' \\/ || \\/ `-.",
|
||||
" <_____/ || \\_____>",
|
||||
" /___/\\___\\",
|
||||
]
|
||||
|
||||
|
||||
def _init_colors() -> dict[str, int]:
|
||||
palette = {"base": 0, "accent": 0, "muted": 0, "warn": 0, "good": 0, "bad": 0, "hot": 0}
|
||||
if not curses.has_colors():
|
||||
return palette
|
||||
curses.start_color()
|
||||
try:
|
||||
curses.use_default_colors()
|
||||
except curses.error:
|
||||
pass
|
||||
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(2, curses.COLOR_BLUE, -1)
|
||||
curses.init_pair(3, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(4, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(5, curses.COLOR_RED, -1)
|
||||
curses.init_pair(6, curses.COLOR_MAGENTA, -1)
|
||||
palette = {
|
||||
"base": curses.color_pair(1),
|
||||
"accent": curses.color_pair(6) | curses.A_BOLD,
|
||||
"muted": curses.color_pair(2),
|
||||
"warn": curses.color_pair(3) | curses.A_BOLD,
|
||||
"good": curses.color_pair(4) | curses.A_BOLD,
|
||||
"bad": curses.color_pair(5) | curses.A_BOLD,
|
||||
"hot": curses.color_pair(6) | curses.A_BOLD,
|
||||
}
|
||||
return palette
|
||||
|
||||
|
||||
def _verdict_attr(review: dict[str, str], colors: dict[str, int]) -> int:
|
||||
verdict = review["verdict"]
|
||||
if verdict in {"agree", "done"}:
|
||||
return colors["good"]
|
||||
if verdict in {"reroute", "warn"}:
|
||||
return colors["warn"]
|
||||
if verdict in {"block", "failed"}:
|
||||
return colors["bad"]
|
||||
return colors["accent"]
|
||||
|
||||
|
||||
def _status_tag(status: str) -> str:
|
||||
return {
|
||||
"done": "DONE",
|
||||
"planned": "PLAN",
|
||||
"running": "RUN ",
|
||||
"failed": "FAIL",
|
||||
"needs_human": "HELP",
|
||||
"pending": "PEND",
|
||||
"draft": "DRAF",
|
||||
}.get(status, status[:4].upper())
|
||||
|
||||
|
||||
def _draw_header(stdscr: Any, workspace: str, mission_count: int, fleet_count: int, memory: dict[str, Any], health: dict[str, Any], colors: dict[str, int]) -> None:
|
||||
memory_counts = memory.get("counts", {})
|
||||
planner_backend = health.get("models", {}).get("planner", {}).get("backend", "heuristic")
|
||||
buddy_backend = health.get("models", {}).get("buddy", {}).get("backend", "heuristic")
|
||||
_safe_addstr(stdscr, 0, 2, "########## CONSTANT::DEMOSCENE::TUI ##########", colors["accent"])
|
||||
_safe_addstr(
|
||||
stdscr,
|
||||
1,
|
||||
2,
|
||||
_clip(
|
||||
f"workspace={workspace} fleet={fleet_count} missions={mission_count} docs={memory_counts.get('documents', 0)} chunks={memory_counts.get('chunks', 0)} decisions={memory_counts.get('decisions', 0)}",
|
||||
max(20, stdscr.getmaxyx()[1] - 4),
|
||||
),
|
||||
colors["base"],
|
||||
)
|
||||
_safe_addstr(
|
||||
stdscr,
|
||||
2,
|
||||
2,
|
||||
_clip(
|
||||
f"planner={planner_backend} buddy={buddy_backend} keys: j/k move z cockpit e index s summarize q quit",
|
||||
max(20, stdscr.getmaxyx()[1] - 4),
|
||||
),
|
||||
colors["muted"],
|
||||
)
|
||||
memory_error = memory.get("error")
|
||||
if memory_error:
|
||||
_safe_addstr(
|
||||
stdscr,
|
||||
3,
|
||||
2,
|
||||
_clip(memory_error, max(20, stdscr.getmaxyx()[1] - 4)),
|
||||
colors["bad"],
|
||||
)
|
||||
|
||||
|
||||
def _draw_missions(stdscr: Any, y: int, x: int, height: int, width: int, missions: list[dict[str, Any]], selected: int, colors: dict[str, int]) -> None:
|
||||
_draw_box(stdscr, y, x, height, width, "Mission Deck", colors["accent"])
|
||||
row = y + 1
|
||||
if not missions:
|
||||
_safe_addstr(stdscr, row, x + 2, "No missions yet. Use `Constant mission create`.", colors["muted"])
|
||||
return
|
||||
for index, mission in enumerate(missions[: max(1, height - 2)]):
|
||||
marker = ">" if index == selected else " "
|
||||
status = _status_tag(mission.get("status", "unknown"))
|
||||
line = f"{marker} {status} {mission['mission_id'][:6]} {mission['title']}"
|
||||
attr = colors["accent"] if index == selected else colors["base"]
|
||||
_safe_addstr(stdscr, row, x + 1, _clip(line, width - 3), attr)
|
||||
row += 1
|
||||
if row >= y + height - 1:
|
||||
break
|
||||
|
||||
|
||||
def _draw_board(stdscr: Any, y: int, x: int, height: int, width: int, mission: dict[str, Any] | None, colors: dict[str, int]) -> None:
|
||||
_draw_box(stdscr, y, x, height, width, "Mission Board", colors["accent"])
|
||||
row = y + 1
|
||||
if not mission:
|
||||
lines = [
|
||||
"No active mission selected.",
|
||||
"",
|
||||
"Try:",
|
||||
"Constant mission create \"audit this repo\" --workspace \"$PWD\"",
|
||||
]
|
||||
_write_wrapped(stdscr, row, x + 2, width - 4, lines, colors["muted"])
|
||||
return
|
||||
|
||||
row = _write_wrapped(stdscr, row, x + 2, width - 4, [mission["title"]], colors["hot"])
|
||||
row += 1
|
||||
row = _write_wrapped(stdscr, row, x + 2, width - 4, [mission.get("goal", "")], colors["base"])
|
||||
row += 1
|
||||
_safe_addstr(stdscr, row, x + 2, f"workspace: {mission.get('workspace', '-')}", colors["muted"])
|
||||
row += 2
|
||||
_safe_addstr(stdscr, row, x + 2, "steps:", colors["accent"])
|
||||
row += 1
|
||||
for step in mission.get("steps", [])[: max(1, height - (row - y) - 2)]:
|
||||
line = f"[{_status_tag(step['status'])}] {step['step_id']} {step['machine']}/{step['cli']}/{step['backend']}"
|
||||
_safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), colors["base"])
|
||||
row += 1
|
||||
details = step.get("result_summary") or step.get("qwen_review") or step.get("title", "")
|
||||
if details and row < y + height - 1:
|
||||
row = _write_wrapped(stdscr, row, x + 4, width - 6, [details], colors["muted"])
|
||||
if row >= y + height - 1:
|
||||
break
|
||||
|
||||
|
||||
def _draw_buddy(stdscr: Any, y: int, x: int, height: int, width: int, review: dict[str, str], memory: dict[str, Any], colors: dict[str, int]) -> None:
|
||||
_draw_box(stdscr, y, x, height, width, "Hexapus Buddy Rail", colors["accent"])
|
||||
row = y + 1
|
||||
verdict_attr = _verdict_attr(review, colors)
|
||||
for line in _hexapus_lines():
|
||||
if row >= y + height - 1:
|
||||
return
|
||||
_safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), verdict_attr)
|
||||
row += 1
|
||||
row += 1
|
||||
for line in [
|
||||
f"verdict: {review['verdict']}",
|
||||
f"confidence: {review['confidence']}",
|
||||
f"change: {review['change']}",
|
||||
f"memory: {review['memory']}",
|
||||
]:
|
||||
if row >= y + height - 1:
|
||||
return
|
||||
_safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), colors["base"])
|
||||
row += 1
|
||||
row += 1
|
||||
row = _write_wrapped(stdscr, row, x + 2, width - 4, [review["why"]], colors["muted"])
|
||||
row += 1
|
||||
counts = memory.get("counts", {})
|
||||
for line in [
|
||||
f"persona facts : {counts.get('persona_facts', 0)}",
|
||||
f"decisions : {counts.get('decisions', 0)}",
|
||||
f"mission notes : {counts.get('mission_summaries', 0)}",
|
||||
]:
|
||||
if row >= y + height - 1:
|
||||
return
|
||||
_safe_addstr(stdscr, row, x + 2, _clip(line, width - 4), colors["base"])
|
||||
row += 1
|
||||
|
||||
|
||||
def _draw_timeline(stdscr: Any, y: int, x: int, height: int, width: int, events: list[str], persona_lines: list[str], decision_lines: list[str], colors: dict[str, int]) -> None:
|
||||
_draw_box(stdscr, y, x, height, width, "Timeline + Memory", colors["accent"])
|
||||
half = max(20, width // 2)
|
||||
_safe_addstr(stdscr, y + 1, x + 2, "events", colors["hot"])
|
||||
_safe_addstr(stdscr, y + 1, x + half, "memory echoes", colors["hot"])
|
||||
row_left = y + 2
|
||||
for event in events[: max(1, height - 3)]:
|
||||
_safe_addstr(stdscr, row_left, x + 2, _clip(event, half - 4), colors["base"])
|
||||
row_left += 1
|
||||
if row_left >= y + height - 1:
|
||||
break
|
||||
row_right = y + 2
|
||||
memory_lines = [*persona_lines[:3], *decision_lines[:3]]
|
||||
for line in memory_lines[: max(1, height - 3)]:
|
||||
_safe_addstr(stdscr, row_right, x + half, _clip(line, width - half - 2), colors["muted"])
|
||||
row_right += 1
|
||||
if row_right >= y + height - 1:
|
||||
break
|
||||
|
||||
|
||||
def _collect_snapshot(default_workspace: str, selected_index: int) -> dict[str, Any]:
|
||||
missions = sorted(list_missions(), key=lambda item: item.get("updated_at", ""), reverse=True)
|
||||
if missions:
|
||||
selected_index = max(0, min(selected_index, len(missions) - 1))
|
||||
selected_mission = missions[selected_index]
|
||||
workspace = str(selected_mission.get("workspace", default_workspace))
|
||||
else:
|
||||
selected_mission = None
|
||||
workspace = default_workspace
|
||||
|
||||
health = daemon_request("health", auto_start=False)
|
||||
memory = _safe_memory_status(workspace)
|
||||
review = _normalize_buddy(selected_mission.get("buddy_review") if selected_mission else None)
|
||||
persona_lines = _safe_persona_lines()
|
||||
decision_lines = _safe_decision_lines(workspace)
|
||||
events = _recent_events(selected_mission["mission_id"] if selected_mission else None)
|
||||
fleet = load_fleet_config()
|
||||
return {
|
||||
"missions": missions,
|
||||
"selected_index": selected_index,
|
||||
"selected_mission": selected_mission,
|
||||
"workspace": workspace,
|
||||
"health": health,
|
||||
"memory": memory,
|
||||
"review": review,
|
||||
"persona_lines": persona_lines,
|
||||
"decision_lines": decision_lines,
|
||||
"events": events,
|
||||
"fleet_count": len(fleet["machines"]),
|
||||
}
|
||||
|
||||
|
||||
def _run(stdscr: Any, workspace: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except curses.error:
|
||||
pass
|
||||
stdscr.nodelay(False)
|
||||
stdscr.timeout(250)
|
||||
colors = _init_colors()
|
||||
selected_index = 0
|
||||
flash = ""
|
||||
flash_until = 0.0
|
||||
|
||||
while True:
|
||||
snapshot = _collect_snapshot(workspace, selected_index)
|
||||
selected_index = snapshot["selected_index"]
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
_draw_header(
|
||||
stdscr,
|
||||
snapshot["workspace"],
|
||||
len(snapshot["missions"]),
|
||||
snapshot["fleet_count"],
|
||||
snapshot["memory"],
|
||||
snapshot["health"],
|
||||
colors,
|
||||
)
|
||||
|
||||
top = 4
|
||||
timeline_height = min(10, max(7, height // 4))
|
||||
main_height = max(10, height - top - timeline_height - 1)
|
||||
left_width = max(28, min(34, width // 4))
|
||||
right_width = max(34, min(42, width // 3))
|
||||
center_width = max(28, width - left_width - right_width - 4)
|
||||
|
||||
_draw_missions(stdscr, top, 0, main_height, left_width, snapshot["missions"], selected_index, colors)
|
||||
_draw_board(stdscr, top, left_width + 1, main_height, center_width, snapshot["selected_mission"], colors)
|
||||
_draw_buddy(stdscr, top, left_width + center_width + 2, main_height, right_width, snapshot["review"], snapshot["memory"], colors)
|
||||
_draw_timeline(stdscr, top + main_height, 0, timeline_height, width, snapshot["events"], snapshot["persona_lines"], snapshot["decision_lines"], colors)
|
||||
|
||||
if flash and time.time() < flash_until:
|
||||
_safe_addstr(stdscr, height - 1, 2, _clip(flash, width - 4), colors["warn"])
|
||||
else:
|
||||
flash = ""
|
||||
_safe_addstr(stdscr, height - 1, 2, "q quit | j/k select mission | z open cockpit | e rebuild memory | s summarize mission", colors["muted"])
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
if key == -1:
|
||||
continue
|
||||
if key in (ord("q"), 27):
|
||||
return None
|
||||
if key in (ord("j"), curses.KEY_DOWN):
|
||||
if snapshot["missions"]:
|
||||
selected_index = min(len(snapshot["missions"]) - 1, selected_index + 1)
|
||||
continue
|
||||
if key in (ord("k"), curses.KEY_UP):
|
||||
if snapshot["missions"]:
|
||||
selected_index = max(0, selected_index - 1)
|
||||
continue
|
||||
if key == ord("z"):
|
||||
return {"action": "cockpit", "workspace": snapshot["workspace"]}
|
||||
if key == ord("e"):
|
||||
try:
|
||||
rebuild_workspace_memory(snapshot["workspace"], enroll=True)
|
||||
flash = f"memory rebuilt for {snapshot['workspace']}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
flash = _error_line("memory rebuild failed", exc)
|
||||
flash_until = time.time() + 2.5
|
||||
continue
|
||||
if key == ord("s") and snapshot["selected_mission"]:
|
||||
try:
|
||||
summarize_mission(snapshot["selected_mission"]["mission_id"])
|
||||
flash = f"mission summarized: {snapshot['selected_mission']['mission_id']}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
flash = _error_line("summary failed", exc)
|
||||
flash_until = time.time() + 2.5
|
||||
continue
|
||||
|
||||
|
||||
def run_tui(workspace: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
rebuild_workspace_memory(workspace, enroll=True)
|
||||
except Exception:
|
||||
pass
|
||||
return curses.wrapper(lambda stdscr: _run(stdscr, str(Path(workspace).expanduser().resolve())))
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"local_machine": "command-center",
|
||||
"machines": [
|
||||
{
|
||||
"label": "command-center",
|
||||
"target": "local",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["omc", "cli-local", "zellij"]
|
||||
},
|
||||
{
|
||||
"label": "builder-a",
|
||||
"target": "dev@builder-a",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["cli-ssh", "zellij"]
|
||||
},
|
||||
{
|
||||
"label": "builder-b",
|
||||
"target": "dev@builder-b",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["cli-ssh", "zellij"]
|
||||
},
|
||||
{
|
||||
"label": "edge-a",
|
||||
"target": "dev@edge-a",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["cli-ssh", "zellij"]
|
||||
},
|
||||
{
|
||||
"label": "lab-a",
|
||||
"target": "dev@lab-a",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["cli-ssh", "zellij"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user