opencommand 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencommand-0.1.0/PKG-INFO +76 -0
- opencommand-0.1.0/README.md +54 -0
- opencommand-0.1.0/pyproject.toml +39 -0
- opencommand-0.1.0/src/opencommand/__init__.py +4 -0
- opencommand-0.1.0/src/opencommand/cli.py +375 -0
- opencommand-0.1.0/src/opencommand/core/agent.py +91 -0
- opencommand-0.1.0/src/opencommand/core/config.py +65 -0
- opencommand-0.1.0/src/opencommand/core/errors.py +23 -0
- opencommand-0.1.0/src/opencommand/core/logging.py +65 -0
- opencommand-0.1.0/src/opencommand/core/supervisor.py +81 -0
- opencommand-0.1.0/src/opencommand/core/tools.py +136 -0
- opencommand-0.1.0/src/opencommand/engine/__init__.py +66 -0
- opencommand-0.1.0/src/opencommand/engine/runner.py +275 -0
- opencommand-0.1.0/src/opencommand/modules/knowledge.py +85 -0
- opencommand-0.1.0/src/opencommand/modules/memory/MEMORY.md +14 -0
- opencommand-0.1.0/src/opencommand/modules/skills/bash.md +14 -0
- opencommand-0.1.0/src/opencommand/modules/skills/powershell.md +15 -0
- opencommand-0.1.0/src/opencommand/modules/skills/python.md +20 -0
- opencommand-0.1.0/src/opencommand/modules/skills/system.md +38 -0
- opencommand-0.1.0/src/opencommand/modules/system/automatic.py +151 -0
- opencommand-0.1.0/src/opencommand/modules/system/cron.py +193 -0
- opencommand-0.1.0/src/opencommand/modules/system/notify.py +85 -0
- opencommand-0.1.0/src/opencommand/modules/system/platform.py +197 -0
- opencommand-0.1.0/src/opencommand/modules/templates.py +227 -0
- opencommand-0.1.0/src/opencommand/modules/tools/desktop.py +127 -0
- opencommand-0.1.0/src/opencommand/modules/tools/files.py +82 -0
- opencommand-0.1.0/src/opencommand/modules/tools/instructions.py +163 -0
- opencommand-0.1.0/src/opencommand/modules/tools/memory.py +78 -0
- opencommand-0.1.0/src/opencommand/modules/tools/playwright.py +61 -0
- opencommand-0.1.0/src/opencommand/modules/tools/research.py +46 -0
- opencommand-0.1.0/src/opencommand/modules/tools/system.py +163 -0
- opencommand-0.1.0/src/opencommand/modules/tools/terminal.py +53 -0
- opencommand-0.1.0/src/opencommand/providers/provider.py +157 -0
- opencommand-0.1.0/src/opencommand/swarm/agents/commander.py +530 -0
- opencommand-0.1.0/src/opencommand/swarm/agents/worker.py +26 -0
- opencommand-0.1.0/src/opencommand/tui/dashboard.py +41 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: opencommand
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local-first swarm agent framework: a Commander plans and dispatches a swarm of Workers that research, code, test, and interactively drive the computer to complete long-horizon codebase goals.
|
|
5
|
+
Author: DSiENT
|
|
6
|
+
Author-email: DSiENT <w.caskey7@gmail.com>
|
|
7
|
+
Requires-Dist: asciimatics>=1.15.0
|
|
8
|
+
Requires-Dist: click>=8.4.2
|
|
9
|
+
Requires-Dist: playwright>=1.61.0
|
|
10
|
+
Requires-Dist: pyautogui>=0.9.54
|
|
11
|
+
Requires-Dist: pynput>=1.8.2
|
|
12
|
+
Requires-Dist: requests>=2.34.2
|
|
13
|
+
Requires-Dist: mss>=9.0.0
|
|
14
|
+
Requires-Dist: pillow>=11.0.0
|
|
15
|
+
Requires-Dist: llama-cpp-python>=0.3.33
|
|
16
|
+
Requires-Dist: watchdog>=6.0.0
|
|
17
|
+
Requires-Dist: ntfy>=2.7.1
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Project-URL: Homepage, https://github.com/D5-Interactive/OpenCommand
|
|
20
|
+
Project-URL: Repository, https://github.com/D5-Interactive/OpenCommand
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# OpenCommand
|
|
24
|
+
|
|
25
|
+
Local-first swarm agent framework. A **Commander** model plans and dispatches a
|
|
26
|
+
swarm of **Workers** that research, code, test, and interactively drive the
|
|
27
|
+
computer (mouse, keyboard, screen) to complete long-horizon codebase goals.
|
|
28
|
+
|
|
29
|
+
Everything runs **embedded** — models are GGUF files loaded directly with
|
|
30
|
+
`llama-cpp-python` (no API servers, no network at runtime). Designed for
|
|
31
|
+
**Python 3.14** (free-threaded).
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Run without installing (ephemeral):
|
|
37
|
+
uv tool run opencommand --help
|
|
38
|
+
|
|
39
|
+
# Or install globally:
|
|
40
|
+
uv tool install opencommand
|
|
41
|
+
playwright install # optional, for the playwright tool
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> **Note:** the `llama-cpp-python` prebuilt CPU wheel may crash on older CPUs
|
|
45
|
+
> (illegal-instruction). If so, rebuild from source with AVX2:
|
|
46
|
+
> ```bat
|
|
47
|
+
> call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
|
|
48
|
+
> set CMAKE_ARGS=-DGGML_AVX2=ON
|
|
49
|
+
> uv pip install --no-binary llama-cpp-python --no-cache llama-cpp-python
|
|
50
|
+
> ```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
opencommand models list # list built-in embedded models
|
|
56
|
+
opencommand models pull all # download GGUF models into ./models
|
|
57
|
+
opencommand run "Add unit tests for the engine module."
|
|
58
|
+
opencommand run "Build a Panda3D FPS" --pipeline advanced --verify "uv run pytest -q"
|
|
59
|
+
opencommand tui # live swarm dashboard
|
|
60
|
+
opencommand cron add healthcheck "every 1h" "echo ok"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Built-in models
|
|
64
|
+
|
|
65
|
+
| Role | Model |
|
|
66
|
+
|---------------|----------------------------------------|
|
|
67
|
+
| commander | `deepreinforce-ai/Ornith-1.0-9B-GGUF` |
|
|
68
|
+
| worker | `unsloth/Qwen3-4B-Instruct-2507-GGUF` |
|
|
69
|
+
| vision | `unsloth/Qwen3-VL-4B-Instruct-GGUF` |
|
|
70
|
+
| vision_small | `openbmb/MiniCPM-V-4.6-gguf` |
|
|
71
|
+
|
|
72
|
+
## Links
|
|
73
|
+
|
|
74
|
+
- `DESIGN.md` — full architecture & research notes
|
|
75
|
+
- `TODO.md` — roadmap tracker
|
|
76
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# OpenCommand
|
|
2
|
+
|
|
3
|
+
Local-first swarm agent framework. A **Commander** model plans and dispatches a
|
|
4
|
+
swarm of **Workers** that research, code, test, and interactively drive the
|
|
5
|
+
computer (mouse, keyboard, screen) to complete long-horizon codebase goals.
|
|
6
|
+
|
|
7
|
+
Everything runs **embedded** — models are GGUF files loaded directly with
|
|
8
|
+
`llama-cpp-python` (no API servers, no network at runtime). Designed for
|
|
9
|
+
**Python 3.14** (free-threaded).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Run without installing (ephemeral):
|
|
15
|
+
uv tool run opencommand --help
|
|
16
|
+
|
|
17
|
+
# Or install globally:
|
|
18
|
+
uv tool install opencommand
|
|
19
|
+
playwright install # optional, for the playwright tool
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
> **Note:** the `llama-cpp-python` prebuilt CPU wheel may crash on older CPUs
|
|
23
|
+
> (illegal-instruction). If so, rebuild from source with AVX2:
|
|
24
|
+
> ```bat
|
|
25
|
+
> call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
|
|
26
|
+
> set CMAKE_ARGS=-DGGML_AVX2=ON
|
|
27
|
+
> uv pip install --no-binary llama-cpp-python --no-cache llama-cpp-python
|
|
28
|
+
> ```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
opencommand models list # list built-in embedded models
|
|
34
|
+
opencommand models pull all # download GGUF models into ./models
|
|
35
|
+
opencommand run "Add unit tests for the engine module."
|
|
36
|
+
opencommand run "Build a Panda3D FPS" --pipeline advanced --verify "uv run pytest -q"
|
|
37
|
+
opencommand tui # live swarm dashboard
|
|
38
|
+
opencommand cron add healthcheck "every 1h" "echo ok"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Built-in models
|
|
42
|
+
|
|
43
|
+
| Role | Model |
|
|
44
|
+
|---------------|----------------------------------------|
|
|
45
|
+
| commander | `deepreinforce-ai/Ornith-1.0-9B-GGUF` |
|
|
46
|
+
| worker | `unsloth/Qwen3-4B-Instruct-2507-GGUF` |
|
|
47
|
+
| vision | `unsloth/Qwen3-VL-4B-Instruct-GGUF` |
|
|
48
|
+
| vision_small | `openbmb/MiniCPM-V-4.6-gguf` |
|
|
49
|
+
|
|
50
|
+
## Links
|
|
51
|
+
|
|
52
|
+
- `DESIGN.md` — full architecture & research notes
|
|
53
|
+
- `TODO.md` — roadmap tracker
|
|
54
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opencommand"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Local-first swarm agent framework: a Commander plans and dispatches a swarm of Workers that research, code, test, and interactively drive the computer to complete long-horizon codebase goals."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "DSiENT", email = "w.caskey7@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"asciimatics>=1.15.0",
|
|
12
|
+
"click>=8.4.2",
|
|
13
|
+
"playwright>=1.61.0",
|
|
14
|
+
"pyautogui>=0.9.54",
|
|
15
|
+
"pynput>=1.8.2",
|
|
16
|
+
"requests>=2.34.2",
|
|
17
|
+
"mss>=9.0.0",
|
|
18
|
+
"pillow>=11.0.0",
|
|
19
|
+
"llama-cpp-python>=0.3.33",
|
|
20
|
+
"watchdog>=6.0.0",
|
|
21
|
+
"ntfy>=2.7.1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
opencommand = "opencommand:main"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/D5-Interactive/OpenCommand"
|
|
29
|
+
Repository = "https://github.com/D5-Interactive/OpenCommand"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = ["pytest>=8.0"]
|
|
33
|
+
|
|
34
|
+
[tool.uv]
|
|
35
|
+
package = true
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.11.19,<0.12.0"]
|
|
39
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""click-based CLI for OpenCommand (fully embedded, no external servers).
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
opencommand workspace [path] # show workspace + engine status
|
|
5
|
+
opencommand models pull [role] # download built-in GGUF models
|
|
6
|
+
opencommand models list # list built-in model catalog
|
|
7
|
+
opencommand run "goal" # run the swarm autonomously
|
|
8
|
+
opencommand tui # live swarm dashboard
|
|
9
|
+
opencommand notify "msg" # send a test ntfy notification
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from .core.config import OpenCommandConfig
|
|
22
|
+
from .core.logging import configure_logging, get_logger
|
|
23
|
+
from .core.supervisor import Supervisor
|
|
24
|
+
from .core.tools import ToolRegistry
|
|
25
|
+
from .engine import Engine
|
|
26
|
+
from .modules.system.platform import describe
|
|
27
|
+
|
|
28
|
+
log = get_logger("cli")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_tools(engine: Engine, cfg: OpenCommandConfig) -> ToolRegistry:
|
|
32
|
+
from .modules.tools.desktop import DesktopTool
|
|
33
|
+
from .modules.tools.files import FilesTool
|
|
34
|
+
from .modules.tools.instructions import InstructionsTool
|
|
35
|
+
from .modules.tools.memory import MemoryTool
|
|
36
|
+
from .modules.tools.playwright import PlaywrightTool
|
|
37
|
+
from .modules.tools.research import ResearchTool
|
|
38
|
+
from .modules.tools.system import SystemTool
|
|
39
|
+
from .modules.tools.terminal import TerminalTool
|
|
40
|
+
|
|
41
|
+
reg = ToolRegistry()
|
|
42
|
+
reg.register(TerminalTool())
|
|
43
|
+
reg.register(FilesTool(cfg.workspace))
|
|
44
|
+
reg.register(MemoryTool(cfg.workspace))
|
|
45
|
+
reg.register(InstructionsTool(cfg.workspace))
|
|
46
|
+
reg.register(ResearchTool())
|
|
47
|
+
reg.register(PlaywrightTool())
|
|
48
|
+
reg.register(SystemTool())
|
|
49
|
+
vision_role = cfg.vision_model if cfg.vision else None
|
|
50
|
+
reg.register(DesktopTool(engine.runner(vision_role) if vision_role else None))
|
|
51
|
+
return reg
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_cfg(path: str | None) -> OpenCommandConfig:
|
|
55
|
+
ws = Path(path).resolve() if path else Path.cwd()
|
|
56
|
+
return OpenCommandConfig.load(ws)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@click.group()
|
|
60
|
+
@click.version_option(version="0.1.0")
|
|
61
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose (DEBUG) logging.")
|
|
62
|
+
def cli(verbose: bool) -> None:
|
|
63
|
+
"""OpenCommand - all-in-one local model runner & command center."""
|
|
64
|
+
configure_logging(level=logging.DEBUG if verbose else logging.INFO,
|
|
65
|
+
workspace=_load_cfg(None).workspace, verbose=verbose)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@cli.command()
|
|
69
|
+
@click.argument("path", required=False)
|
|
70
|
+
def workspace(path: str | None) -> None:
|
|
71
|
+
"""Show workspace info and embedded engine status."""
|
|
72
|
+
cfg = _load_cfg(path)
|
|
73
|
+
engine = Engine(model_dir=cfg.workspace / "models")
|
|
74
|
+
click.echo(f"Workspace: {cfg.workspace}")
|
|
75
|
+
click.echo(f"Platform: {describe()}")
|
|
76
|
+
click.echo(engine.status())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@cli.command()
|
|
80
|
+
@click.argument("path", required=False)
|
|
81
|
+
@click.option("--apply", is_flag=True, help="Write the recommended config to the workspace.")
|
|
82
|
+
@click.option("--rebuild", is_flag=True, help="Rebuild llama-cpp-python from source with detected ISA flags.")
|
|
83
|
+
def setup(path: str | None, apply: bool, rebuild: bool) -> None:
|
|
84
|
+
"""Autodetect the host and show (or --apply / --rebuild) a per-host setup."""
|
|
85
|
+
from .modules.system.platform import detect, recommend_config
|
|
86
|
+
|
|
87
|
+
ws = Path(path).resolve() if path else Path.cwd()
|
|
88
|
+
d = detect()
|
|
89
|
+
rec = recommend_config(d)
|
|
90
|
+
click.echo(describe())
|
|
91
|
+
click.echo(f"Cores: {d['cores']} RAM: {d['ram_gb']} GB GPU: {d['gpu']['backend']}")
|
|
92
|
+
click.echo(f"ISA: {', '.join(d['isa']['extensions']) or 'base'}")
|
|
93
|
+
click.echo(f"Tools: {', '.join(k for k, v in d['tools'].items() if v) or 'none'}")
|
|
94
|
+
click.echo("\nRecommended config:")
|
|
95
|
+
click.echo(f" max_workers={rec['config']['max_workers']} "
|
|
96
|
+
f"pool_size={rec['engine']['pool_size']} "
|
|
97
|
+
f"n_gpu_layers={rec['runner']['n_gpu_layers']}")
|
|
98
|
+
click.echo(f" vision={rec['config']['vision']}")
|
|
99
|
+
if rec["build"]["cmake_args"]:
|
|
100
|
+
click.echo(f" llama.cpp build: {rec['build']['cmake_args']}")
|
|
101
|
+
if apply:
|
|
102
|
+
cfg = OpenCommandConfig.from_host(ws)
|
|
103
|
+
cfg.save()
|
|
104
|
+
click.echo(click.style(f"\nWrote config -> {cfg._path()}", fg="green"))
|
|
105
|
+
if rebuild:
|
|
106
|
+
_rebuild_llama_cpp(rec["build"]["cmake_args"])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _rebuild_llama_cpp(cmake_args: str) -> None:
|
|
110
|
+
"""Reinstall llama-cpp-python from source with the given CMAKE_ARGS.
|
|
111
|
+
|
|
112
|
+
Mirrors the manual x64 AVX2 build: on Windows we must compile inside the
|
|
113
|
+
MSVC x64 dev shell (vcvars64.bat), otherwise a 32-bit DLL is produced.
|
|
114
|
+
"""
|
|
115
|
+
import subprocess
|
|
116
|
+
|
|
117
|
+
click.echo(click.style(f"\nRebuilding llama-cpp-python (CMAKE_ARGS={cmake_args or 'none'})...",
|
|
118
|
+
fg="yellow"))
|
|
119
|
+
if not cmake_args:
|
|
120
|
+
click.echo("No ISA build flags detected; building with defaults.")
|
|
121
|
+
cmd, shell, env = _build_rebuild_command(cmake_args)
|
|
122
|
+
try:
|
|
123
|
+
subprocess.run(cmd, shell=shell, env=env, check=True)
|
|
124
|
+
click.echo(click.style("llama-cpp-python rebuilt.", fg="green"))
|
|
125
|
+
except subprocess.CalledProcessError as e:
|
|
126
|
+
click.echo(click.style(f"Rebuild failed (exit {e.returncode}). "
|
|
127
|
+
"Ensure MSVC Build Tools 2022 is installed.", fg="red"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_rebuild_command(cmake_args: str) -> tuple:
|
|
131
|
+
"""Return (command, shell, env) for the llama-cpp-python source rebuild.
|
|
132
|
+
|
|
133
|
+
Windows compiles inside the MSVC x64 dev shell; POSIX sets CMAKE_ARGS in env.
|
|
134
|
+
"""
|
|
135
|
+
base = ["uv", "pip", "install", "--no-binary", "llama-cpp-python",
|
|
136
|
+
"--no-cache", "llama-cpp-python"]
|
|
137
|
+
if sys.platform.startswith("win"):
|
|
138
|
+
vcvars = (r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools"
|
|
139
|
+
r"\Common7\Tools\vcvars64.bat")
|
|
140
|
+
if not Path(vcvars).exists():
|
|
141
|
+
vcvars = (r"C:\Program Files (x86)\Microsoft Visual Studio\2022"
|
|
142
|
+
r"\BuildTools\Common7\Tools\vcvars64.bat")
|
|
143
|
+
cmd = f'call "{vcvars}" && set CMAKE_ARGS={cmake_args} && ' + " ".join(base)
|
|
144
|
+
return cmd, True, None
|
|
145
|
+
return base, False, dict(os.environ, CMAKE_ARGS=cmake_args)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@cli.group()
|
|
149
|
+
def models() -> None:
|
|
150
|
+
"""Manage built-in embedded models."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@models.command("list")
|
|
154
|
+
def models_list() -> None:
|
|
155
|
+
"""List the built-in model catalog."""
|
|
156
|
+
from .engine.runner import CATALOG
|
|
157
|
+
|
|
158
|
+
for spec in CATALOG.values():
|
|
159
|
+
click.echo(f" {spec.role:10} {spec.repo_id} [{spec.kind}] fmt={spec.chat_format}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@models.command("pull")
|
|
163
|
+
@click.argument("role", required=False)
|
|
164
|
+
@click.option("--path", default=None, help="Workspace directory.")
|
|
165
|
+
def models_pull(role: str | None, path: str | None) -> None:
|
|
166
|
+
"""Download built-in GGUF model(s). Role: commander|worker|vision|all."""
|
|
167
|
+
cfg = _load_cfg(path)
|
|
168
|
+
engine = Engine(model_dir=cfg.workspace / "models")
|
|
169
|
+
roles = ["commander", "worker", "vision", "vision_small"] if role in (None, "all") else [role]
|
|
170
|
+
for r in roles:
|
|
171
|
+
click.echo(f"Downloading {r}...")
|
|
172
|
+
p = engine.ensure_downloaded(r)
|
|
173
|
+
click.echo(f" -> {p}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@cli.group()
|
|
177
|
+
def cron() -> None:
|
|
178
|
+
"""Schedule recurring tasks (e.g. 'every 1h', 'daily 09:00')."""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@cron.command("add")
|
|
182
|
+
@click.argument("name")
|
|
183
|
+
@click.argument("schedule")
|
|
184
|
+
@click.argument("command")
|
|
185
|
+
@click.option("--path", default=None, help="Workspace directory.")
|
|
186
|
+
def cron_add(name: str, schedule: str, command: str, path: str | None) -> None:
|
|
187
|
+
"""Add a cron job NAME on SCHEDULE that runs COMMAND (shell)."""
|
|
188
|
+
from .modules.system.cron import CronScheduler
|
|
189
|
+
|
|
190
|
+
cfg = _load_cfg(path)
|
|
191
|
+
cron_file = cfg.workspace / ".opencommand" / "cron.json"
|
|
192
|
+
sched = CronScheduler.load(cron_file)
|
|
193
|
+
sched.schedule_command(name, schedule, command)
|
|
194
|
+
sched.save(cron_file)
|
|
195
|
+
click.echo(f"Added cron job '{name}' on '{schedule}': {command}")
|
|
196
|
+
click.echo(f"Persisted to {cron_file}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@cron.command("list")
|
|
200
|
+
@click.option("--path", default=None, help="Workspace directory.")
|
|
201
|
+
def cron_list(path: str | None) -> None:
|
|
202
|
+
"""List persisted cron jobs."""
|
|
203
|
+
from .modules.system.cron import CronScheduler
|
|
204
|
+
|
|
205
|
+
cfg = _load_cfg(path)
|
|
206
|
+
cron_file = cfg.workspace / ".opencommand" / "cron.json"
|
|
207
|
+
sched = CronScheduler.load(cron_file)
|
|
208
|
+
click.echo(sched.status())
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@cron.command("remove")
|
|
212
|
+
@click.argument("name")
|
|
213
|
+
@click.option("--path", default=None, help="Workspace directory.")
|
|
214
|
+
def cron_remove(name: str, path: str | None) -> None:
|
|
215
|
+
"""Remove a persisted cron job by NAME."""
|
|
216
|
+
import json as _json
|
|
217
|
+
|
|
218
|
+
cfg = _load_cfg(path)
|
|
219
|
+
cron_file = cfg.workspace / ".opencommand" / "cron.json"
|
|
220
|
+
if not cron_file.exists():
|
|
221
|
+
click.echo("No cron jobs persisted.")
|
|
222
|
+
return
|
|
223
|
+
data = _json.loads(cron_file.read_text(encoding="utf-8"))
|
|
224
|
+
kept = [j for j in data if j["name"] != name]
|
|
225
|
+
if len(kept) == len(data):
|
|
226
|
+
click.echo(f"No job named '{name}'.")
|
|
227
|
+
return
|
|
228
|
+
cron_file.write_text(_json.dumps(kept, indent=2), encoding="utf-8")
|
|
229
|
+
click.echo(f"Removed cron job '{name}'.")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@cron.command("status")
|
|
233
|
+
@click.option("--path", default=None, help="Workspace directory.")
|
|
234
|
+
def cron_status(path: str | None) -> None:
|
|
235
|
+
"""Show loaded cron jobs (live in-process schedules)."""
|
|
236
|
+
from .modules.system.cron import CronScheduler
|
|
237
|
+
|
|
238
|
+
cfg = _load_cfg(path)
|
|
239
|
+
cron_file = cfg.workspace / ".opencommand" / "cron.json"
|
|
240
|
+
sched = CronScheduler.load(cron_file)
|
|
241
|
+
click.echo(sched.status())
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@cron.command("start")
|
|
245
|
+
@click.option("--path", default=None, help="Workspace directory.")
|
|
246
|
+
def cron_start(path: str | None) -> None:
|
|
247
|
+
"""Load persisted cron jobs and run the scheduler in the foreground."""
|
|
248
|
+
import asyncio
|
|
249
|
+
|
|
250
|
+
from .modules.system.cron import CronScheduler
|
|
251
|
+
|
|
252
|
+
cfg = _load_cfg(path)
|
|
253
|
+
cron_file = cfg.workspace / ".opencommand" / "cron.json"
|
|
254
|
+
sched = CronScheduler.load(cron_file)
|
|
255
|
+
if not sched._jobs:
|
|
256
|
+
click.echo("No persisted cron jobs. Use `cron add` first.")
|
|
257
|
+
return
|
|
258
|
+
click.echo(sched.status())
|
|
259
|
+
click.echo("Running scheduler (Ctrl-C to stop)...")
|
|
260
|
+
|
|
261
|
+
async def _run() -> None:
|
|
262
|
+
sched.start()
|
|
263
|
+
try:
|
|
264
|
+
while not sched._stop.is_set():
|
|
265
|
+
await asyncio.sleep(sched._tick)
|
|
266
|
+
finally:
|
|
267
|
+
sched.stop()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
asyncio.run(_run())
|
|
271
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
272
|
+
pass
|
|
273
|
+
click.echo("Scheduler stopped.")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@cli.command()
|
|
277
|
+
@click.argument("goal")
|
|
278
|
+
@click.option("--path", default=None, help="Workspace directory.")
|
|
279
|
+
@click.option("--max-iter", default=5, help="Automatic-mode iterations.")
|
|
280
|
+
@click.option("--verify", default=None, help="Shell command to verify success (e.g. 'pytest -q').")
|
|
281
|
+
@click.option("--pool", default=None, type=int, help="Worker model pool size (parallelism).")
|
|
282
|
+
@click.option("--no-vision", is_flag=True, help="Disable the desktop/vision tool.")
|
|
283
|
+
@click.option("--vision-model", default=None,
|
|
284
|
+
type=click.Choice(["vision", "vision_small"]),
|
|
285
|
+
help="Vision model for the desktop tool (vision=Qwen3-VL-4B, "
|
|
286
|
+
"vision_small=MiniCPM-V-4.6 0.8B for CPU/low-RAM).")
|
|
287
|
+
@click.option("--pipeline", default=None,
|
|
288
|
+
type=click.Choice(["standard", "advanced"]),
|
|
289
|
+
help="Pipeline: standard (plan->research->workers) or advanced "
|
|
290
|
+
"(Phase 6: scaffold + research-context + commander review gate).")
|
|
291
|
+
@click.option("--live-verify", is_flag=True,
|
|
292
|
+
help="After tests pass, run the desktop tool to interactively verify UI goals.")
|
|
293
|
+
def run(goal: str, path: str | None, max_iter: int, verify: str | None,
|
|
294
|
+
pool: int | None, no_vision: bool, vision_model: str | None,
|
|
295
|
+
pipeline: str | None, live_verify: bool) -> None:
|
|
296
|
+
"""Run a GOAL autonomously using embedded models."""
|
|
297
|
+
import asyncio
|
|
298
|
+
|
|
299
|
+
from .modules.system.automatic import run_automatic
|
|
300
|
+
from .swarm.agents.commander import Commander
|
|
301
|
+
|
|
302
|
+
cfg = _load_cfg(path)
|
|
303
|
+
if no_vision:
|
|
304
|
+
cfg.vision = None
|
|
305
|
+
if vision_model:
|
|
306
|
+
cfg.vision_model = vision_model
|
|
307
|
+
if pipeline:
|
|
308
|
+
cfg.pipeline = pipeline
|
|
309
|
+
if live_verify:
|
|
310
|
+
cfg.live_verify = True
|
|
311
|
+
engine = Engine(model_dir=cfg.workspace / "models",
|
|
312
|
+
pool_size=pool or cfg.max_workers)
|
|
313
|
+
sup = Supervisor(max_retries=cfg.max_retries)
|
|
314
|
+
tools = _build_tools(engine, cfg)
|
|
315
|
+
commander = Commander(engine, sup, tools, cfg.workspace)
|
|
316
|
+
# Boot persisted cron jobs: they run inside the agent event loop and dispatch
|
|
317
|
+
# goals through the Commander (or run shell commands) when they fire.
|
|
318
|
+
from .modules.system.cron import CronScheduler
|
|
319
|
+
cron_file = cfg.workspace / ".opencommand" / "cron.json"
|
|
320
|
+
cron = CronScheduler.load(cron_file)
|
|
321
|
+
if cron._jobs:
|
|
322
|
+
cron.on_goal = lambda g: run_automatic(
|
|
323
|
+
commander, g, max_iterations=max_iter, verify_cmd=verify,
|
|
324
|
+
pipeline=cfg.pipeline, live_verify=cfg.live_verify)
|
|
325
|
+
click.echo(click.style(f"Loaded {len(cron._jobs)} cron job(s).", fg="green"))
|
|
326
|
+
|
|
327
|
+
async def _main() -> str:
|
|
328
|
+
if cron._jobs:
|
|
329
|
+
cron.start()
|
|
330
|
+
try:
|
|
331
|
+
return await run_automatic(
|
|
332
|
+
commander, goal, max_iterations=max_iter, verify_cmd=verify,
|
|
333
|
+
pipeline=cfg.pipeline, live_verify=cfg.live_verify)
|
|
334
|
+
finally:
|
|
335
|
+
cron.stop()
|
|
336
|
+
sup.shutdown()
|
|
337
|
+
|
|
338
|
+
click.echo(f"OpenCommand on {describe()}")
|
|
339
|
+
click.echo(click.style(f"Pipeline: {cfg.pipeline}"
|
|
340
|
+
+ (" + live-verify" if cfg.live_verify else ""), fg="cyan"))
|
|
341
|
+
click.echo(click.style("Loading embedded models...", fg="cyan"))
|
|
342
|
+
click.echo(click.style("Running automatic mode...", fg="cyan"))
|
|
343
|
+
report = asyncio.run(_main())
|
|
344
|
+
click.echo(report)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@cli.command()
|
|
348
|
+
def tui() -> None:
|
|
349
|
+
"""Launch the live swarm dashboard (asciimatics)."""
|
|
350
|
+
from .tui.dashboard import run_dashboard
|
|
351
|
+
|
|
352
|
+
run_dashboard()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@cli.command()
|
|
356
|
+
@click.argument("message")
|
|
357
|
+
@click.option("--title", default=None, help="Notification title.")
|
|
358
|
+
@click.option("--priority", default=3, help="ntfy priority 1..5 (default 3).")
|
|
359
|
+
def notify(message: str, title: str | None, priority: int) -> None:
|
|
360
|
+
"""Send a test ntfy notification (needs NTFY_TOPIC env var)."""
|
|
361
|
+
from .modules.system.notify import Notifier
|
|
362
|
+
|
|
363
|
+
n = Notifier()
|
|
364
|
+
if not n.enabled:
|
|
365
|
+
click.echo(
|
|
366
|
+
"ntfy not configured. Set NTFY_TOPIC (e.g. "
|
|
367
|
+
"https://ntfy.sh/your-topic) in the environment."
|
|
368
|
+
)
|
|
369
|
+
return
|
|
370
|
+
ok = n.send(message, title=title, priority=priority)
|
|
371
|
+
click.echo("Notification sent." if ok else "Notification FAILED.")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
if __name__ == "__main__": # pragma: no cover
|
|
375
|
+
cli()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""AgentLoop: the per-worker reasoning loop (prompt -> LLM -> tools -> observe)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from ..providers.provider import BaseProvider, ChatMessage
|
|
8
|
+
from .errors import ContextWindowError, RetryableError
|
|
9
|
+
from .tools import ToolRegistry, parse_tool_calls
|
|
10
|
+
|
|
11
|
+
SYSTEM_PROMPT = (
|
|
12
|
+
"You are an OpenCommand agent operating inside a workspace on {platform}. "
|
|
13
|
+
"You complete tasks by calling tools. Think step by step, then emit tool "
|
|
14
|
+
"calls. When the task is fully done, reply with a final summary and no "
|
|
15
|
+
"tool calls. Be concise and correct; prefer running tests to claiming success."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AgentState:
|
|
21
|
+
messages: list[ChatMessage] = field(default_factory=list)
|
|
22
|
+
steps: int = 0
|
|
23
|
+
max_steps: int = 40
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentLoop:
|
|
27
|
+
"""Drives one worker: repeatedly calls the LLM and executes its tools."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
provider: BaseProvider,
|
|
32
|
+
tools: ToolRegistry,
|
|
33
|
+
system: str | None = None,
|
|
34
|
+
platform: str = "this machine",
|
|
35
|
+
) -> None:
|
|
36
|
+
self.provider = provider
|
|
37
|
+
self.tools = tools
|
|
38
|
+
self.system = system or SYSTEM_PROMPT.format(platform=platform)
|
|
39
|
+
|
|
40
|
+
async def run(self, task: str, max_steps: int = 40) -> str:
|
|
41
|
+
state = AgentState(max_steps=max_steps)
|
|
42
|
+
state.messages.append(ChatMessage(role="system", content=self.system))
|
|
43
|
+
state.messages.append(ChatMessage(role="user", content=task))
|
|
44
|
+
while state.steps < state.max_steps:
|
|
45
|
+
state.steps += 1
|
|
46
|
+
try:
|
|
47
|
+
msg = await self.provider.acomplete(state.messages, tools=self.tools.schemas())
|
|
48
|
+
except ContextWindowError:
|
|
49
|
+
# Bound history and retry instead of failing the whole run.
|
|
50
|
+
state.messages = _truncate(state.messages, keep=20)
|
|
51
|
+
state.messages.append(ChatMessage(
|
|
52
|
+
role="system",
|
|
53
|
+
content="Context was truncated to fit the model window. Continue."))
|
|
54
|
+
continue
|
|
55
|
+
except Exception as e: # noqa: BLE001
|
|
56
|
+
if "context" in str(e).lower():
|
|
57
|
+
raise ContextWindowError(str(e)) from e
|
|
58
|
+
raise RetryableError(str(e)) from e # noqa: TRY003
|
|
59
|
+
state.messages.append(msg)
|
|
60
|
+
calls = parse_tool_calls(msg)
|
|
61
|
+
if not calls:
|
|
62
|
+
# No structured calls. If the model emitted a tool-call marker in
|
|
63
|
+
# text but parsing failed, nudge it to retry rather than stop.
|
|
64
|
+
if "<function=" in (msg.content or "") or "<tool_call" in (msg.content or ""):
|
|
65
|
+
state.messages.append(ChatMessage(
|
|
66
|
+
role="system",
|
|
67
|
+
content=("Your tool call could not be parsed. Re-emit it using the "
|
|
68
|
+
"exact <tool_call><function=name><parameter=key>value"
|
|
69
|
+
"</parameter></function></tool_call> format, or reply with a "
|
|
70
|
+
"final summary if the task is done.")))
|
|
71
|
+
continue
|
|
72
|
+
return msg.content or "(no output)"
|
|
73
|
+
for call in calls:
|
|
74
|
+
result = await self.tools.adispatch(call)
|
|
75
|
+
obs = result.output if result.ok else f"ERROR: {result.error}"
|
|
76
|
+
state.messages.append(
|
|
77
|
+
ChatMessage(role="tool", content=obs, name=call.name, tool_call_id=call.id)
|
|
78
|
+
)
|
|
79
|
+
# Proactively cap history to stay within n_ctx on long tool loops.
|
|
80
|
+
if len(state.messages) > 24:
|
|
81
|
+
state.messages = _truncate(state.messages, keep=20)
|
|
82
|
+
return "(max steps reached)"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _truncate(messages: list[ChatMessage], keep: int = 20) -> list[ChatMessage]:
|
|
86
|
+
"""Keep system + recent messages to bound context on retry."""
|
|
87
|
+
if len(messages) <= keep:
|
|
88
|
+
return messages
|
|
89
|
+
head = [m for m in messages if m.role == "system"]
|
|
90
|
+
tail = messages[-(keep - len(head)) :]
|
|
91
|
+
return head + tail
|