opencommand 0.1.0__py3-none-any.whl

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 (37) hide show
  1. opencommand/__init__.py +4 -0
  2. opencommand/cli.py +375 -0
  3. opencommand/core/agent.py +91 -0
  4. opencommand/core/config.py +65 -0
  5. opencommand/core/errors.py +23 -0
  6. opencommand/core/logging.py +65 -0
  7. opencommand/core/supervisor.py +81 -0
  8. opencommand/core/tools.py +136 -0
  9. opencommand/engine/__init__.py +66 -0
  10. opencommand/engine/runner.py +275 -0
  11. opencommand/modules/knowledge.py +85 -0
  12. opencommand/modules/memory/MEMORY.md +14 -0
  13. opencommand/modules/skills/bash.md +14 -0
  14. opencommand/modules/skills/powershell.md +15 -0
  15. opencommand/modules/skills/python.md +20 -0
  16. opencommand/modules/skills/system.md +38 -0
  17. opencommand/modules/system/automatic.py +151 -0
  18. opencommand/modules/system/cron.py +193 -0
  19. opencommand/modules/system/notify.py +85 -0
  20. opencommand/modules/system/platform.py +197 -0
  21. opencommand/modules/templates.py +227 -0
  22. opencommand/modules/tools/desktop.py +127 -0
  23. opencommand/modules/tools/files.py +82 -0
  24. opencommand/modules/tools/instructions.py +163 -0
  25. opencommand/modules/tools/memory.py +78 -0
  26. opencommand/modules/tools/playwright.py +61 -0
  27. opencommand/modules/tools/research.py +46 -0
  28. opencommand/modules/tools/system.py +163 -0
  29. opencommand/modules/tools/terminal.py +53 -0
  30. opencommand/providers/provider.py +157 -0
  31. opencommand/swarm/agents/commander.py +530 -0
  32. opencommand/swarm/agents/worker.py +26 -0
  33. opencommand/tui/dashboard.py +41 -0
  34. opencommand-0.1.0.dist-info/METADATA +76 -0
  35. opencommand-0.1.0.dist-info/RECORD +37 -0
  36. opencommand-0.1.0.dist-info/WHEEL +4 -0
  37. opencommand-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,4 @@
1
+ def main() -> None:
2
+ from .cli import cli
3
+
4
+ cli()
opencommand/cli.py ADDED
@@ -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
@@ -0,0 +1,65 @@
1
+ """Configuration for OpenCommand: workspace, model flags, limits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class OpenCommandConfig:
11
+ """Runtime configuration, persisted to <workspace>/.opencommand/config.json."""
12
+
13
+ workspace: Path = field(default_factory=Path.cwd)
14
+ vision: bool = True
15
+ vision_model: str = "vision" # catalog role for the desktop/vision tool
16
+ max_workers: int = 4
17
+ max_retries: int = 3
18
+ pipeline: str = "standard" # standard | advanced (Phase 6)
19
+ live_verify: bool = False # interactive desktop check after verify passes
20
+
21
+ def save(self) -> None:
22
+ self._path().parent.mkdir(parents=True, exist_ok=True)
23
+ self._path().write_text(self.to_json(), encoding="utf-8")
24
+
25
+ def to_json(self) -> str:
26
+ import json
27
+ return json.dumps({
28
+ "workspace": str(self.workspace),
29
+ "vision": self.vision,
30
+ "vision_model": self.vision_model,
31
+ "max_workers": self.max_workers,
32
+ "max_retries": self.max_retries,
33
+ "pipeline": self.pipeline,
34
+ "live_verify": self.live_verify,
35
+ }, indent=2)
36
+
37
+ @classmethod
38
+ def load(cls, workspace: Path) -> "OpenCommandConfig":
39
+ path = workspace / ".opencommand" / "config.json"
40
+ if not path.exists():
41
+ return cls(workspace=workspace)
42
+ import json
43
+ d = json.loads(path.read_text(encoding="utf-8"))
44
+ return cls(
45
+ workspace=Path(d.get("workspace", workspace)),
46
+ vision=d.get("vision", True),
47
+ vision_model=d.get("vision_model", "vision"),
48
+ max_workers=d.get("max_workers", 4),
49
+ max_retries=d.get("max_retries", 3),
50
+ pipeline=d.get("pipeline", "standard"),
51
+ live_verify=d.get("live_verify", False),
52
+ )
53
+
54
+ def _path(self) -> Path:
55
+ return self.workspace / ".opencommand" / "config.json"
56
+
57
+ @classmethod
58
+ def from_host(cls, workspace: Path) -> "OpenCommandConfig":
59
+ """Build a config tuned to the current host via platform autodetection."""
60
+ from ..modules.system.platform import detect, recommend_config
61
+ rec = recommend_config(detect())
62
+ cfg = cls(workspace=workspace)
63
+ for k, v in rec["config"].items():
64
+ setattr(cfg, k, v)
65
+ return cfg
@@ -0,0 +1,23 @@
1
+ """Error types for OpenCommand agents and tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class OpenCommandError(Exception):
7
+ """Base class for all OpenCommand errors."""
8
+
9
+
10
+ class ToolError(OpenCommandError):
11
+ """Raised when a tool fails."""
12
+
13
+
14
+ class AgentError(OpenCommandError):
15
+ """Raised when an agent loop fails irrecoverably."""
16
+
17
+
18
+ class RetryableError(OpenCommandError):
19
+ """Signal the supervisor to retry the task (e.g. transient LLM/HTTP error)."""
20
+
21
+
22
+ class ContextWindowError(RetryableError):
23
+ """Context overflow - supervisor should summarize/truncate and retry."""
@@ -0,0 +1,65 @@
1
+ """Structured logging for OpenCommand.
2
+
3
+ Provides a module-level logger configured for verbose, machine-readable output.
4
+ Logs are emitted to stderr (so they never corrupt tool stdout) and optionally to
5
+ a rotating file under the workspace. Every component imports ``get_logger`` and
6
+ logs at the appropriate level so a full autonomous run is fully auditable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import sys
13
+ from logging.handlers import RotatingFileHandler
14
+ from pathlib import Path
15
+
16
+ _DEFAULT_FORMAT = (
17
+ "%(asctime)s.%(msecs)03d | %(levelname)-8s | %(name)s | "
18
+ "%(funcName)s:%(lineno)d | %(message)s"
19
+ )
20
+ _DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
21
+
22
+ _CONFIGURED = False
23
+
24
+
25
+ def configure_logging(level: int = logging.INFO, workspace: Path | None = None,
26
+ verbose: bool = False) -> None:
27
+ """Configure the root OpenCommand logger once.
28
+
29
+ ``verbose`` raises the level to DEBUG and attaches a rotating file handler
30
+ under ``<workspace>/.opencommand/logs/opencommand.log`` when a workspace is
31
+ given.
32
+ """
33
+ global _CONFIGURED
34
+ if _CONFIGURED:
35
+ return
36
+ _CONFIGURED = True
37
+
38
+ if verbose:
39
+ level = logging.DEBUG
40
+
41
+ root = logging.getLogger("opencommand")
42
+ root.setLevel(level)
43
+ root.propagate = False
44
+
45
+ formatter = logging.Formatter(_DEFAULT_FORMAT, datefmt=_DATE_FORMAT)
46
+
47
+ stream = logging.StreamHandler(sys.stderr)
48
+ stream.setFormatter(formatter)
49
+ root.addHandler(stream)
50
+
51
+ if workspace is not None:
52
+ log_dir = workspace / ".opencommand" / "logs"
53
+ log_dir.mkdir(parents=True, exist_ok=True)
54
+ file_handler = RotatingFileHandler(
55
+ log_dir / "opencommand.log", maxBytes=5_000_000, backupCount=5,
56
+ encoding="utf-8")
57
+ file_handler.setFormatter(formatter)
58
+ root.addHandler(file_handler)
59
+
60
+
61
+ def get_logger(name: str) -> logging.Logger:
62
+ """Return a child logger under the ``opencommand`` namespace."""
63
+ if not name.startswith("opencommand"):
64
+ name = f"opencommand.{name}"
65
+ return logging.getLogger(name)