opencommand 0.1.3__tar.gz → 0.1.4__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.
Files changed (41) hide show
  1. {opencommand-0.1.3 → opencommand-0.1.4}/PKG-INFO +1 -1
  2. {opencommand-0.1.3 → opencommand-0.1.4}/pyproject.toml +1 -1
  3. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/cli.py +22 -4
  4. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/core/config.py +29 -1
  5. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/engine/__init__.py +25 -4
  6. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/engine/runner.py +29 -1
  7. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/system/automatic.py +23 -0
  8. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/system/platform.py +14 -2
  9. {opencommand-0.1.3 → opencommand-0.1.4}/README.md +0 -0
  10. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/__init__.py +0 -0
  11. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/core/agent.py +0 -0
  12. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/core/errors.py +0 -0
  13. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/core/logging.py +0 -0
  14. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/core/supervisor.py +0 -0
  15. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/core/tools.py +0 -0
  16. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/knowledge.py +0 -0
  17. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/memory/MEMORY.md +0 -0
  18. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/skills/bash.md +0 -0
  19. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/skills/desktop_linux.md +0 -0
  20. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/skills/desktop_macos.md +0 -0
  21. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/skills/desktop_windows.md +0 -0
  22. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/skills/powershell.md +0 -0
  23. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/skills/python.md +0 -0
  24. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/skills/system.md +0 -0
  25. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/system/cron.py +0 -0
  26. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/system/notify.py +0 -0
  27. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/templates.py +0 -0
  28. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/desktop.py +0 -0
  29. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/desktop_safety.py +0 -0
  30. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/files.py +0 -0
  31. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/instructions.py +0 -0
  32. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/memory.py +0 -0
  33. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/playwright.py +0 -0
  34. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/research.py +0 -0
  35. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/system.py +0 -0
  36. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/terminal.py +0 -0
  37. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/modules/tools/yara_rules/default.yar +0 -0
  38. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/providers/provider.py +0 -0
  39. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/swarm/agents/commander.py +0 -0
  40. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/swarm/agents/worker.py +0 -0
  41. {opencommand-0.1.3 → opencommand-0.1.4}/src/opencommand/tui/dashboard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: opencommand
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
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
5
  Author: DSiENT
6
6
  Author-email: DSiENT <w.caskey7@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opencommand"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
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
5
  readme = "README.md"
6
6
  authors = [
@@ -60,7 +60,7 @@ def _load_cfg(path: str | None) -> OpenCommandConfig:
60
60
 
61
61
 
62
62
  @click.group()
63
- @click.version_option(version="0.1.3")
63
+ @click.version_option(version="0.1.4")
64
64
  @click.option("-v", "--verbose", is_flag=True, help="Enable verbose (DEBUG) logging.")
65
65
  def cli(verbose: bool) -> None:
66
66
  """OpenCommand - all-in-one local model runner & command center."""
@@ -73,7 +73,7 @@ def cli(verbose: bool) -> None:
73
73
  def workspace(path: str | None) -> None:
74
74
  """Show workspace info and embedded engine status."""
75
75
  cfg = _load_cfg(path)
76
- engine = Engine(model_dir=cfg.workspace / "models")
76
+ engine = Engine(model_dir=cfg.workspace / "models", device_map=cfg.device_map)
77
77
  click.echo(f"Workspace: {cfg.workspace}")
78
78
  click.echo(f"Platform: {describe()}")
79
79
  click.echo(engine.status())
@@ -180,10 +180,27 @@ def config(path: str | None) -> None:
180
180
  cfg.max_test_loops = click.prompt(
181
181
  "Max test+debug fix loops / phase", type=int, default=cfg.max_test_loops)
182
182
  cfg.n_gpu_layers = click.prompt(
183
- "GPU layers to offload (0=CPU, -1=all)", type=int, default=cfg.n_gpu_layers)
183
+ "GPU layers to offload (0=CPU, -1=all) [legacy default for unpinned roles]",
184
+ type=int, default=cfg.n_gpu_layers)
184
185
  cfg.goal_file = click.prompt(
185
186
  "Session goal spec file name", default=cfg.goal_file)
186
187
 
188
+ # --- Per-role device placement ---------------------------------------
189
+ click.echo(click.style("\nModel device placement", fg="cyan", bold=True))
190
+ click.echo("Pin each model role to GPU (offload all layers) or CPU+RAM.")
191
+ click.echo("Example: Commander=GPU, Workers=CPU, VL model=GPU (low VRAM host).")
192
+ dm = dict(cfg.device_map)
193
+ for role in ("commander", "worker", "vision", "vision_small"):
194
+ cur = dm.get(role, "default")
195
+ choice = click.prompt(
196
+ f" {role} device", type=click.Choice(["gpu", "cpu", "default"]),
197
+ default=cur)
198
+ if choice == "default":
199
+ dm.pop(role, None)
200
+ else:
201
+ dm[role] = choice
202
+ cfg.device_map = dm
203
+
187
204
  # --- Desktop takeover safety policy ------------------------------------
188
205
  click.echo(click.style("\nDesktop takeover safety policy", fg="cyan", bold=True))
189
206
  click.echo("Defines exactly what the desktop agent is allowed to do.")
@@ -421,7 +438,8 @@ def run(goal: str | None, path: str | None, goal_file: str | None, max_iter: int
421
438
  click.echo(click.style(f"Loaded goal spec: {goal_path}", fg="green"))
422
439
 
423
440
  engine = Engine(model_dir=cfg.workspace / "models",
424
- pool_size=pool or cfg.max_workers)
441
+ pool_size=pool or cfg.max_workers,
442
+ device_map=cfg.device_map)
425
443
  sup = Supervisor(max_retries=cfg.max_retries)
426
444
  tools = _build_tools(engine, cfg)
427
445
  commander = Commander(engine, sup, tools, cfg.workspace)
@@ -22,10 +22,17 @@ class OpenCommandConfig:
22
22
  max_research_loops: int = 3 # research refinement loops per phase
23
23
  max_test_loops: int = 5 # test+debug fix loops per phase
24
24
  batch_size: int = 4 # workers spawned per swarm batch
25
- n_gpu_layers: int = 0 # 0 = CPU; -1 = all GPU layers
25
+ n_gpu_layers: int = 0 # 0 = CPU; -1 = all GPU layers (legacy global default)
26
26
  goal_file: str = "Goal.md" # session goal spec file name
27
27
  # --- Desktop takeover safety policy (see modules/tools/desktop_safety.py) -
28
28
  desktop_policy: dict = field(default_factory=dict) # empty -> safe defaults
29
+ # --- Per-role device placement -----------------------------------------
30
+ # Maps a model role to "gpu" (offload all layers) or "cpu" (RAM only).
31
+ # Roles: commander, worker, vision, vision_small. Any role not present
32
+ # falls back to the legacy `n_gpu_layers` (GPU if -1, else CPU). This is
33
+ # how a low-VRAM host keeps the Commander on the GPU while Workers run in
34
+ # RAM, and the VL model stays on the GPU for the desktop agent.
35
+ device_map: dict = field(default_factory=dict) # empty -> all roles use n_gpu_layers
29
36
 
30
37
  def save(self) -> None:
31
38
  self._path().parent.mkdir(parents=True, exist_ok=True)
@@ -48,6 +55,7 @@ class OpenCommandConfig:
48
55
  "n_gpu_layers": self.n_gpu_layers,
49
56
  "goal_file": self.goal_file,
50
57
  "desktop_policy": self.desktop_policy,
58
+ "device_map": self.device_map,
51
59
  }, indent=2)
52
60
 
53
61
  @classmethod
@@ -72,11 +80,27 @@ class OpenCommandConfig:
72
80
  n_gpu_layers=d.get("n_gpu_layers", 0),
73
81
  goal_file=d.get("goal_file", "Goal.md"),
74
82
  desktop_policy=d.get("desktop_policy", {}),
83
+ device_map=d.get("device_map", {}),
75
84
  )
76
85
 
77
86
  def _path(self) -> Path:
78
87
  return self.workspace / ".opencommand" / "config.json"
79
88
 
89
+ def n_gpu_layers_for(self, role: str) -> int:
90
+ """Resolve GPU offload layers for a model role.
91
+
92
+ If ``device_map`` pins the role to "gpu" -> -1 (all layers offloaded);
93
+ if pinned to "cpu" -> 0 (RAM only). When the role is absent from
94
+ ``device_map`` we fall back to the legacy global ``n_gpu_layers`` so
95
+ existing configs keep working.
96
+ """
97
+ dev = self.device_map.get(role)
98
+ if dev == "gpu":
99
+ return -1
100
+ if dev == "cpu":
101
+ return 0
102
+ return self.n_gpu_layers
103
+
80
104
  @classmethod
81
105
  def from_host(cls, workspace: Path) -> "OpenCommandConfig":
82
106
  """Build a config tuned to the current host via platform autodetection."""
@@ -85,4 +109,8 @@ class OpenCommandConfig:
85
109
  cfg = cls(workspace=workspace)
86
110
  for k, v in rec["config"].items():
87
111
  setattr(cfg, k, v)
112
+ # device_map is a nested dict; apply it explicitly so it isn't
113
+ # clobbered by the flat setattr loop above.
114
+ if "device_map" in rec["config"]:
115
+ cfg.device_map = dict(rec["config"]["device_map"])
88
116
  return cfg
@@ -18,18 +18,34 @@ class Engine:
18
18
  dedicated instance per worker index for true parallelism.
19
19
  """
20
20
 
21
- def __init__(self, model_dir: Path | None = None, pool_size: int = 4) -> None:
21
+ def __init__(self, model_dir: Path | None = None, pool_size: int = 4,
22
+ device_map: dict | None = None) -> None:
22
23
  self.model_dir = model_dir or DEFAULT_MODEL_DIR
23
24
  self.pool_size = max(1, pool_size)
25
+ # device_map pins a role to "gpu" (-1 layers) or "cpu" (0 layers);
26
+ # roles absent fall back to the legacy global n_gpu_layers (set per-runner
27
+ # via set_gpu_layers). Kept as a dict so callers can mutate it live.
28
+ self.device_map = dict(device_map or {})
24
29
  self._runners: dict[str, LocalRunner] = {}
25
30
  self._pools: dict[str, list[LocalRunner]] = {}
26
31
 
32
+ def _gpu_layers(self, role: str) -> int:
33
+ """Resolve GPU offload layers for a role from the device map."""
34
+ dev = self.device_map.get(role)
35
+ if dev == "gpu":
36
+ return -1
37
+ if dev == "cpu":
38
+ return 0
39
+ return 0 # default: CPU/RAM unless a runner overrides via set_gpu_layers
40
+
27
41
  def runner(self, role: str) -> LocalRunner:
28
42
  if role not in self._runners:
29
43
  spec = CATALOG.get(role)
30
44
  if spec is None:
31
45
  raise KeyError(f"No built-in model for role '{role}'")
32
- self._runners[role] = LocalRunner(spec, self.model_dir / role)
46
+ r = LocalRunner(spec, self.model_dir / role)
47
+ r.set_gpu_layers(self._gpu_layers(role))
48
+ self._runners[role] = r
33
49
  return self._runners[role]
34
50
 
35
51
  def worker_runner(self, role: str, index: int = 0) -> LocalRunner:
@@ -40,7 +56,9 @@ class Engine:
40
56
  pool = self._pools.setdefault(role, [])
41
57
  i = index % self.pool_size
42
58
  while len(pool) <= i:
43
- pool.append(LocalRunner(spec, self.model_dir / role))
59
+ r = LocalRunner(spec, self.model_dir / role)
60
+ r.set_gpu_layers(self._gpu_layers(role))
61
+ pool.append(r)
44
62
  return pool[i]
45
63
 
46
64
  def ensure_downloaded(self, role: str) -> Path:
@@ -62,5 +80,8 @@ class Engine:
62
80
  state = "instantiated (lazy)"
63
81
  else:
64
82
  state = "loaded"
65
- lines.append(f" - {role}: {spec.repo_id} [{spec.kind}] {state}")
83
+ dev = self.device_map.get(role, "default")
84
+ dev_label = f"device={dev}" if dev != "default" else "device=default"
85
+ lines.append(f" - {role}: {spec.repo_id} [{spec.kind}] "
86
+ f"{state} ({dev_label})")
66
87
  return "\n".join(lines)
@@ -87,6 +87,19 @@ class LocalRunner:
87
87
  self.model_dir = model_dir
88
88
  self._llm = None
89
89
  self._lock = threading.Lock()
90
+ # Per-runner GPU offload override. The Engine sets this from the
91
+ # device_map (role -> "gpu"|"cpu"); -1 = all layers, 0 = CPU/RAM.
92
+ self._gpu_layers = spec.n_gpu_layers
93
+
94
+ def set_gpu_layers(self, n: int) -> None:
95
+ """Override how many layers to offload to the GPU for this runner.
96
+
97
+ Must be called before the model is loaded. -1 offloads all layers
98
+ (GPU mode); 0 keeps everything in RAM (CPU mode).
99
+ """
100
+ if self._llm is not None:
101
+ raise RuntimeError("cannot change GPU layers after the model is loaded")
102
+ self._gpu_layers = n
90
103
 
91
104
  def download(self) -> Path:
92
105
  """Ensure weights exist locally; return the model path (no load)."""
@@ -123,6 +136,21 @@ class LocalRunner:
123
136
  pass
124
137
  return Path(path)
125
138
 
139
+ def unload(self) -> None:
140
+ """Release the mapped weights so the RAM/VRAM is freed.
141
+
142
+ Used by the Commander to drop itself from the GPU while the VL desktop
143
+ agent runs, then re-load on demand. Safe to call when not loaded.
144
+ """
145
+ with self._lock:
146
+ if self._llm is not None:
147
+ log.info("unloading model role=%s", self.spec.role)
148
+ try:
149
+ self._llm.close()
150
+ except Exception: # noqa: BLE001
151
+ pass
152
+ self._llm = None
153
+
126
154
  def _ensure_loaded(self) -> None:
127
155
  # Guard construction with the lock so concurrent first-use from multiple
128
156
  # worker threads can't build two Llama instances into the same slot.
@@ -135,7 +163,7 @@ class LocalRunner:
135
163
  kwargs: dict[str, Any] = {
136
164
  "model_path": str(self._resolve_path()),
137
165
  "n_ctx": self.spec.n_ctx,
138
- "n_gpu_layers": self.spec.n_gpu_layers,
166
+ "n_gpu_layers": self._gpu_layers,
139
167
  "verbose": False,
140
168
  }
141
169
  if self.spec.kind == "vision":
@@ -120,6 +120,12 @@ async def _live_verify(commander: Commander, goal: str) -> "LiveVerifyResult":
120
120
  Returns a LiveVerifyResult. Only meaningful when the desktop tool is wired
121
121
  and vision is enabled; otherwise reports a no-op pass so the pipeline can
122
122
  continue without a UI target.
123
+
124
+ Device-placement behavior: by default the Commander is unloaded from the
125
+ GPU while the VL desktop agent runs (freeing VRAM for the vision model),
126
+ then re-loaded so the Commander can review the agent's actions + report and
127
+ continue the loop. If the Commander is pinned to the same device as the VL
128
+ model (both GPU) we skip the unload to avoid an unnecessary reload churn.
123
129
  """
124
130
  from ...modules.tools.desktop import DesktopTool
125
131
 
@@ -128,11 +134,28 @@ async def _live_verify(commander: Commander, goal: str) -> "LiveVerifyResult":
128
134
  if desktop is None or not getattr(desktop, "vision", None):
129
135
  return LiveVerifyResult(ok=True, summary="no desktop tool/vision; skipped",
130
136
  detail="")
137
+ # Unload the Commander from the GPU while the VL agent drives the desktop,
138
+ # unless the Commander is itself pinned to CPU (nothing to free).
139
+ commander_runner = commander.provider
140
+ unloaded = False
131
141
  try:
142
+ if getattr(commander_runner, "spec", None) is not None:
143
+ role = getattr(commander_runner.spec, "role", None)
144
+ # Only unload if the Commander sits on the GPU (would compete with VL).
145
+ if commander.engine.device_map.get(role) == "gpu":
146
+ commander_runner.unload()
147
+ unloaded = True
132
148
  out = desktop.run(f"Verify the goal is satisfied on screen: {goal}", steps=8)
133
149
  except Exception as e: # noqa: BLE001
134
150
  return LiveVerifyResult(ok=False, summary="desktop tool error",
135
151
  detail=str(e))
152
+ finally:
153
+ # Re-load the Commander so it can review the agent's report and continue.
154
+ if unloaded:
155
+ try:
156
+ commander_runner._ensure_loaded()
157
+ except Exception: # noqa: BLE001
158
+ pass
136
159
  ok = "ERROR" not in out and "done" in out.lower()
137
160
  return LiveVerifyResult(ok=ok, summary="interactive check completed", detail=out)
138
161
 
@@ -179,7 +179,18 @@ def recommend_config(d: dict | None = None) -> dict:
179
179
  d = d or detect()
180
180
  cores = max(1, d["cores"])
181
181
  pool = max(1, min(cores - 1, 8)) # leave cores for OS; cap to avoid RAM blowup
182
- n_gpu_layers = -1 if d["gpu"]["backend"] != "none" else 0
182
+ has_gpu = d["gpu"]["backend"] != "none"
183
+ n_gpu_layers = -1 if has_gpu else 0
184
+ # Per-role device placement heuristic. We don't know VRAM size from detect()
185
+ # alone, so we use a conservative split: if a GPU exists, put the small
186
+ # interactive models (commander planner + the VL desktop agent) on the GPU
187
+ # and keep the heavier worker pool in CPU+RAM (which scales with RAM, not
188
+ # VRAM). On a no-GPU host everything is CPU. This is the same split a
189
+ # low-VRAM host (e.g. 8GB VRAM / 32GB RAM) would pick by hand.
190
+ device_map = {}
191
+ if has_gpu:
192
+ device_map = {"commander": "gpu", "worker": "cpu", "vision": "gpu",
193
+ "vision_small": "gpu"}
183
194
  build_flags = []
184
195
  if d["isa"]["avx512"]:
185
196
  build_flags.append("-DGGML_AVX512=ON")
@@ -188,7 +199,8 @@ def recommend_config(d: dict | None = None) -> dict:
188
199
  return {
189
200
  "config": {
190
201
  "max_workers": pool,
191
- "vision": d["gpu"]["backend"] != "none" or cores >= 4,
202
+ "vision": has_gpu or cores >= 4,
203
+ "device_map": device_map,
192
204
  },
193
205
  "engine": {"pool_size": pool},
194
206
  "runner": {"n_gpu_layers": n_gpu_layers},
File without changes