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,81 @@
1
+ """Supervisor: dispatches and monitors worker coroutines with crash containment.
2
+
3
+ Uses asyncio.TaskGroup for structured concurrency. A failing worker is retried
4
+ up to ``max_retries``; a worker crash never takes down the event loop.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from dataclasses import dataclass
11
+ from typing import Awaitable, Callable
12
+
13
+ from .errors import RetryableError
14
+ from .logging import get_logger
15
+
16
+ log = get_logger("core.supervisor")
17
+
18
+
19
+ @dataclass
20
+ class WorkerHandle:
21
+ name: str
22
+ task: str
23
+ status: str = "pending" # pending|running|done|failed|retrying
24
+ attempts: int = 0
25
+ result: str | None = None
26
+ error: str | None = None
27
+
28
+
29
+ class Supervisor:
30
+ """Runs worker coroutines with supervision and restart policy."""
31
+
32
+ def __init__(self, max_retries: int = 3) -> None:
33
+ self.max_retries = max_retries
34
+ self.handles: dict[str, WorkerHandle] = {}
35
+
36
+ def register(self, name: str, task: str) -> WorkerHandle:
37
+ h = WorkerHandle(name=name, task=task)
38
+ self.handles[name] = h
39
+ return h
40
+
41
+ async def run_worker(self, name: str, coro_fn: Callable[[], Awaitable[str]]) -> str:
42
+ """Run ``coro_fn`` with retry/restart containment. Returns final result."""
43
+ handle = self.handles.get(name) or self.register(name, name)
44
+ last_err: Exception | None = None
45
+ while handle.attempts <= self.max_retries:
46
+ handle.attempts += 1
47
+ handle.status = "running" if handle.attempts == 1 else "retrying"
48
+ try:
49
+ result = await coro_fn()
50
+ handle.status = "done"
51
+ handle.result = result
52
+ return result
53
+ except RetryableError as e:
54
+ last_err = e
55
+ await asyncio.sleep(min(2**handle.attempts, 8))
56
+ log.warning("worker %s retryable error attempt=%d: %s",
57
+ name, handle.attempts, e)
58
+ continue
59
+ except Exception as e: # noqa: BLE001 - containment
60
+ handle.status = "failed"
61
+ handle.error = f"{type(e).__name__}: {e}"
62
+ last_err = e
63
+ log.error("worker %s failed: %s", name, handle.error)
64
+ break
65
+ handle.status = "failed"
66
+ handle.error = str(last_err)
67
+ return f"FAILED after {handle.attempts} attempt(s): {last_err}"
68
+
69
+ async def run_all(self, jobs: dict[str, Callable[[], Awaitable[str]]]) -> dict[str, str]:
70
+ """Run all jobs concurrently; each isolated by run_worker."""
71
+ for name in jobs:
72
+ self.register(name, name)
73
+ async with asyncio.TaskGroup() as tg:
74
+ tasks = {name: tg.create_task(self.run_worker(name, fn)) for name, fn in jobs.items()}
75
+ return {name: t.result() for name, t in tasks.items()}
76
+
77
+ def summary(self) -> str:
78
+ lines = ["Supervisor (in-process, crash-contained):"]
79
+ for h in self.handles.values():
80
+ lines.append(f" - {h.name}: {h.status} (attempts={h.attempts})")
81
+ return "\n".join(lines)
@@ -0,0 +1,136 @@
1
+ """Tool registry and the BaseTool protocol used by agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Callable
9
+
10
+ from .errors import ToolError
11
+
12
+
13
+ @dataclass
14
+ class ToolCall:
15
+ """A tool invocation requested by the model."""
16
+
17
+ id: str
18
+ name: str
19
+ arguments: dict[str, Any]
20
+
21
+
22
+ @dataclass
23
+ class ToolResult:
24
+ ok: bool
25
+ output: str
26
+ error: str | None = None
27
+
28
+
29
+ class BaseTool:
30
+ """A tool an agent can call. Subclass and implement ``run``."""
31
+
32
+ name: str = "base"
33
+ description: str = ""
34
+ parameters: dict[str, Any] = field(default_factory=dict)
35
+
36
+ def schema(self) -> dict[str, Any]:
37
+ return {
38
+ "type": "function",
39
+ "function": {
40
+ "name": self.name,
41
+ "description": self.description,
42
+ "parameters": self.parameters or {"type": "object", "properties": {}},
43
+ },
44
+ }
45
+
46
+ def run(self, **kwargs: Any) -> str:
47
+ raise NotImplementedError
48
+
49
+ async def arun(self, **kwargs: Any) -> str:
50
+ return self.run(**kwargs)
51
+
52
+
53
+ class ToolRegistry:
54
+ """Holds the available tools and dispatches calls."""
55
+
56
+ def __init__(self) -> None:
57
+ self._tools: dict[str, BaseTool] = {}
58
+
59
+ def register(self, tool: BaseTool) -> None:
60
+ self._tools[tool.name] = tool
61
+
62
+ def get(self, name: str) -> BaseTool:
63
+ tool = self._tools.get(name)
64
+ if tool is None:
65
+ raise ToolError(f"Unknown tool: {name}")
66
+ return tool
67
+
68
+ def schemas(self) -> list[dict[str, Any]]:
69
+ return [t.schema() for t in self._tools.values()]
70
+
71
+ def dispatch(self, call: ToolCall) -> ToolResult:
72
+ try:
73
+ return ToolResult(ok=True, output=str(self.get(call.name).run(**call.arguments)))
74
+ except Exception as e: # noqa: BLE001 - surface as tool error
75
+ return ToolResult(ok=False, output="", error=f"{type(e).__name__}: {e}")
76
+
77
+ async def adispatch(self, call: ToolCall) -> ToolResult:
78
+ try:
79
+ return ToolResult(ok=True, output=str(await self.get(call.name).arun(**call.arguments)))
80
+ except Exception as e: # noqa: BLE001
81
+ return ToolResult(ok=False, output="", error=f"{type(e).__name__}: {e}")
82
+
83
+
84
+ def parse_tool_calls(message: Any) -> list[ToolCall]:
85
+ """Extract tool calls from an OpenAI-compatible chat message object."""
86
+ calls: list[ToolCall] = []
87
+ raw = getattr(message, "tool_calls", None)
88
+ if not raw:
89
+ return calls
90
+ for tc in raw:
91
+ args = {}
92
+ if tc.function.arguments:
93
+ try:
94
+ args = json.loads(tc.function.arguments)
95
+ except json.JSONDecodeError:
96
+ args = {}
97
+ calls.append(ToolCall(id=tc.id, name=tc.function.name, arguments=args))
98
+ return calls
99
+
100
+
101
+ def tool(
102
+ name: str, description: str, parameters: dict[str, Any] | None = None
103
+ ) -> Callable[[Callable[..., str]], BaseTool]:
104
+ """Decorator to turn a plain function into a registered BaseTool."""
105
+
106
+ def decorator(fn: Callable[..., str]) -> BaseTool:
107
+ sig = inspect.signature(fn)
108
+
109
+ class _FnTool(BaseTool):
110
+ @property
111
+ def name(self) -> str: # type: ignore[override]
112
+ return name
113
+
114
+ @property
115
+ def description(self) -> str: # type: ignore[override]
116
+ return description
117
+
118
+ @property
119
+ def parameters(self) -> dict[str, Any]: # type: ignore[override]
120
+ return parameters or _default_params(sig)
121
+
122
+ def run(self, **kwargs: Any) -> str:
123
+ return fn(**kwargs)
124
+
125
+ return _FnTool()
126
+
127
+ return decorator
128
+
129
+
130
+ def _default_params(sig: inspect.Signature) -> dict[str, Any]:
131
+ props: dict[str, Any] = {}
132
+ for pname, p in sig.parameters.items():
133
+ if pname in ("self", "cls"):
134
+ continue
135
+ props[pname] = {"type": "string", "description": pname}
136
+ return {"type": "object", "properties": props, "required": list(props)}
@@ -0,0 +1,66 @@
1
+ """Embedded model engine: load and manage built-in GGUF models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .runner import CATALOG, LocalRunner
8
+
9
+ DEFAULT_MODEL_DIR = Path(__file__).resolve().parent.parent.parent.parent / "models"
10
+
11
+
12
+ class Engine:
13
+ """Owns the commander/worker/vision runners and lazy-loads them.
14
+
15
+ Each role has a small pool of runner instances so concurrent workers don't
16
+ serialize on a single model. `runner(role)` returns a shared instance for
17
+ sequential use (e.g. the Commander); `worker_runner(role, i)` returns a
18
+ dedicated instance per worker index for true parallelism.
19
+ """
20
+
21
+ def __init__(self, model_dir: Path | None = None, pool_size: int = 4) -> None:
22
+ self.model_dir = model_dir or DEFAULT_MODEL_DIR
23
+ self.pool_size = max(1, pool_size)
24
+ self._runners: dict[str, LocalRunner] = {}
25
+ self._pools: dict[str, list[LocalRunner]] = {}
26
+
27
+ def runner(self, role: str) -> LocalRunner:
28
+ if role not in self._runners:
29
+ spec = CATALOG.get(role)
30
+ if spec is None:
31
+ raise KeyError(f"No built-in model for role '{role}'")
32
+ self._runners[role] = LocalRunner(spec, self.model_dir / role)
33
+ return self._runners[role]
34
+
35
+ def worker_runner(self, role: str, index: int = 0) -> LocalRunner:
36
+ """Return a dedicated runner for worker `index` (round-robin pool)."""
37
+ spec = CATALOG.get(role)
38
+ if spec is None:
39
+ raise KeyError(f"No built-in model for role '{role}'")
40
+ pool = self._pools.setdefault(role, [])
41
+ i = index % self.pool_size
42
+ while len(pool) <= i:
43
+ pool.append(LocalRunner(spec, self.model_dir / role))
44
+ return pool[i]
45
+
46
+ def ensure_downloaded(self, role: str) -> Path:
47
+ """Download (if needed) and return the model path without loading weights."""
48
+ spec = CATALOG[role]
49
+ r = LocalRunner(spec, self.model_dir / role)
50
+ return r.download()
51
+
52
+ def download_all(self) -> dict[str, Path]:
53
+ return {role: self.ensure_downloaded(role) for role in CATALOG}
54
+
55
+ def status(self) -> str:
56
+ lines = [f"Engine model dir: {self.model_dir}"]
57
+ for role, spec in CATALOG.items():
58
+ runner = self._runners.get(role)
59
+ if runner is None:
60
+ state = "not loaded"
61
+ elif runner._llm is None:
62
+ state = "instantiated (lazy)"
63
+ else:
64
+ state = "loaded"
65
+ lines.append(f" - {role}: {spec.repo_id} [{spec.kind}] {state}")
66
+ return "\n".join(lines)
@@ -0,0 +1,275 @@
1
+ """OpenCommand embedded model runner.
2
+
3
+ Loads GGUF models directly with llama-cpp-python (no server, no network at
4
+ runtime). Supports text + native tool calling and vision (Qwen3-VL / MiniCPM-V
5
+ via the MTMD handler). Models live under <workspace>/models and are
6
+ downloaded on first use via Hugging Face Hub.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import threading
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ..core.logging import get_logger
19
+ from ..providers.provider import ChatMessage
20
+
21
+ log = get_logger("engine.runner")
22
+
23
+
24
+ @dataclass
25
+ class ModelSpec:
26
+ """Declarative description of a built-in model."""
27
+
28
+ key: str
29
+ role: str # commander | worker | vision
30
+ repo_id: str
31
+ filename: str # glob matched against repo files
32
+ kind: str = "text" # text | vision
33
+ chat_format: str | None = None # None -> auto-detect from GGUF metadata
34
+ n_ctx: int = 8192
35
+ n_gpu_layers: int = 0 # 0 = CPU; -1 = all GPU layers
36
+ mmproj: str | None = None # multimodal projector glob (vision)
37
+ description: str = ""
38
+
39
+
40
+ # Catalog (verified July 2026). Commander = Ornith-1.0-9B (agentic coding, MIT).
41
+ # Worker = Qwen3-4B-Instruct-2507 (text, native tool calling). Vision = Qwen3-VL-4B
42
+ # (purpose-built GUI agent, MTMD + mmproj) as primary, with MiniCPM-V-4.6 (0.8B,
43
+ # ~529MB) as a lightweight CPU fallback. chat_format is None for text roles so
44
+ # llama.cpp auto-detects the native tool-calling template from GGUF metadata.
45
+ CATALOG: dict[str, ModelSpec] = {
46
+ "commander": ModelSpec(
47
+ key="commander", role="commander",
48
+ repo_id="deepreinforce-ai/Ornith-1.0-9B-GGUF",
49
+ filename="ornith-1.0-9b-Q4_K_M.gguf",
50
+ kind="text", n_ctx=8192,
51
+ description="Commander/planner model (Ornith-1.0-9B, agentic coding).",
52
+ ),
53
+ "worker": ModelSpec(
54
+ key="worker", role="worker",
55
+ repo_id="unsloth/Qwen3-4B-Instruct-2507-GGUF",
56
+ filename="Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
57
+ kind="text", n_ctx=8192,
58
+ description="Worker execution model (Qwen3-4B-Instruct, native tool calling).",
59
+ ),
60
+ "vision": ModelSpec(
61
+ key="vision", role="vision",
62
+ repo_id="unsloth/Qwen3-VL-4B-Instruct-GGUF",
63
+ filename="Qwen3-VL-4B-Instruct-Q4_K_M.gguf",
64
+ kind="vision", chat_format="mtmd", n_ctx=8192, mmproj="mmproj-F16.gguf",
65
+ description="Realtime vision model for screen understanding (Qwen3-VL-4B, GUI agent).",
66
+ ),
67
+ "vision_small": ModelSpec(
68
+ key="vision_small", role="vision_small",
69
+ repo_id="openbmb/MiniCPM-V-4.6-gguf",
70
+ filename="MiniCPM-V-4_6-Q4_K_M.gguf",
71
+ kind="vision", chat_format="mtmd", n_ctx=4096, mmproj="mmproj-model-f16.gguf",
72
+ description="Lightweight vision fallback (MiniCPM-V-4.6, 0.8B, ~529MB) for CPU/low-RAM.",
73
+ ),
74
+ }
75
+
76
+
77
+ class LocalRunner:
78
+ """Wraps a single llama_cpp.Llama instance; thread-safe for sync calls.
79
+
80
+ Loading is lazy: the model is only mapped into RAM on first inference, so
81
+ ensure_downloaded can fetch weights without requiring enough RAM (or a
82
+ compatible CPU) to actually run them.
83
+ """
84
+
85
+ def __init__(self, spec: ModelSpec, model_dir: Path) -> None:
86
+ self.spec = spec
87
+ self.model_dir = model_dir
88
+ self._llm = None
89
+ self._lock = threading.Lock()
90
+
91
+ def download(self) -> Path:
92
+ """Ensure weights exist locally; return the model path (no load)."""
93
+ return self._resolve_path()
94
+
95
+ def _resolve_path(self) -> Path:
96
+ import fnmatch
97
+ self.model_dir.mkdir(parents=True, exist_ok=True)
98
+ for f in self.model_dir.iterdir():
99
+ if fnmatch.fnmatch(f.name, self.spec.filename):
100
+ return f
101
+ return self._download()
102
+
103
+ def _download(self) -> Path:
104
+ import fnmatch
105
+ from huggingface_hub import hf_hub_download, list_repo_files
106
+ filename = self.spec.filename
107
+ # Resolve a glob to a concrete filename so HF can create a valid lock
108
+ # file (esp. on Windows).
109
+ if "*" in filename or "?" in filename:
110
+ repo_files = [f for f in list_repo_files(repo_id=self.spec.repo_id)
111
+ if f.endswith(".gguf") and f != "README.md"]
112
+ hits = [f for f in repo_files if fnmatch.fnmatch(f, filename)]
113
+ if not hits:
114
+ raise FileNotFoundError(f"No file matching {filename!r} in {self.spec.repo_id}")
115
+ filename = sorted(hits, key=len)[0]
116
+ path = hf_hub_download(repo_id=self.spec.repo_id, filename=filename,
117
+ local_dir=str(self.model_dir))
118
+ if self.spec.mmproj:
119
+ try:
120
+ hf_hub_download(repo_id=self.spec.repo_id, filename=self.spec.mmproj,
121
+ local_dir=str(self.model_dir))
122
+ except Exception: # noqa: BLE001 - mmproj optional
123
+ pass
124
+ return Path(path)
125
+
126
+ def _ensure_loaded(self) -> None:
127
+ # Guard construction with the lock so concurrent first-use from multiple
128
+ # worker threads can't build two Llama instances into the same slot.
129
+ with self._lock:
130
+ if self._llm is not None:
131
+ return
132
+ log.info("loading model role=%s path=%s", self.spec.role,
133
+ self._resolve_path().name)
134
+ from llama_cpp import Llama
135
+ kwargs: dict[str, Any] = {
136
+ "model_path": str(self._resolve_path()),
137
+ "n_ctx": self.spec.n_ctx,
138
+ "n_gpu_layers": self.spec.n_gpu_layers,
139
+ "verbose": False,
140
+ }
141
+ if self.spec.kind == "vision":
142
+ from llama_cpp.llama_chat_format import MTMDChatHandler
143
+ mmproj = next((p for p in self.model_dir.glob("*.gguf")
144
+ if "mmproj" in p.name.lower()), None)
145
+ if mmproj is None:
146
+ raise FileNotFoundError(f"No mmproj file found in {self.model_dir} for vision model")
147
+ kwargs["chat_handler"] = MTMDChatHandler(clip_model_path=str(mmproj))
148
+ kwargs["chat_format"] = self.spec.chat_format or "mtmd"
149
+ elif self.spec.chat_format:
150
+ kwargs["chat_format"] = self.spec.chat_format
151
+ self._llm = Llama(**kwargs)
152
+
153
+ def chat(self, messages: list[dict[str, Any]], tools=None, temperature=0.2,
154
+ max_tokens=1024, json_mode: bool = False) -> dict[str, Any]:
155
+ self._ensure_loaded()
156
+ with self._lock:
157
+ payload: dict[str, Any] = {
158
+ "messages": messages,
159
+ "temperature": temperature,
160
+ "max_tokens": max_tokens,
161
+ }
162
+ if tools:
163
+ payload["tools"] = tools
164
+ if json_mode:
165
+ payload["response_format"] = {"type": "json_object"}
166
+ resp = self._llm.create_chat_completion(**payload)
167
+ # Qwen3.5 emits native <tool_call> XML in the content field rather than
168
+ # the structured tool_calls field. Parse it so parse_tool_calls() works.
169
+ self._extract_native_tool_calls(resp)
170
+ return resp
171
+
172
+ @staticmethod
173
+ def _extract_native_tool_calls(resp: dict[str, Any]) -> None:
174
+ """Populate message.tool_calls from Qwen3.5 native XML in content.
175
+
176
+ Qwen3.5 streams tool invocations as:
177
+ <tool_call><function=terminal><parameter=command>
178
+ pwd && ls -la</parameter></function></tool_call>
179
+ llama.cpp does not always surface these in the structured tool_calls
180
+ field, so we parse the content and synthesize the OpenAI-compatible
181
+ structure the rest of the codebase expects.
182
+ """
183
+ import re
184
+ try:
185
+ msg = resp["choices"][0]["message"]
186
+ except (KeyError, IndexError, TypeError):
187
+ return
188
+ if msg.get("tool_calls"):
189
+ return # already structured
190
+ content = msg.get("content") or ""
191
+ if "<tool_call>" not in content and "<function=" not in content:
192
+ return
193
+ block_re = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL)
194
+ fn_re = re.compile(r"<function=([^>]+)>")
195
+ param_re = re.compile(r"<parameter=([^>]+)>(.*?)</parameter>", re.DOTALL)
196
+ calls: list[dict[str, Any]] = []
197
+ # Prefer explicit <tool_call> wrappers; fall back to bare <function=...>
198
+ # blocks (Qwen3.5 sometimes emits them without the wrapper).
199
+ blocks = block_re.findall(content) or [content]
200
+ for block in blocks:
201
+ fn_match = fn_re.search(block)
202
+ if not fn_match:
203
+ continue
204
+ name = fn_match.group(1).strip()
205
+ args: dict[str, Any] = {k.strip(): v.strip() for k, v in param_re.findall(block)}
206
+ calls.append({
207
+ "id": f"call_{len(calls)}",
208
+ "type": "function",
209
+ "function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)},
210
+ })
211
+ if calls:
212
+ msg["tool_calls"] = calls
213
+ # Drop any <think>...</think> reasoning trace (e.g. Ornith) and the
214
+ # tool-call wrapper so the remaining content is the final answer.
215
+ cleaned = block_re.sub("", content)
216
+ cleaned = re.sub(r"<think>.*?</think>", "", cleaned, flags=re.DOTALL)
217
+ msg["content"] = cleaned.strip()
218
+
219
+ def complete(self, prompt: str, temperature=0.2, max_tokens=256) -> str:
220
+ self._ensure_loaded()
221
+ with self._lock:
222
+ resp = self._llm(prompt, temperature=temperature, max_tokens=max_tokens)
223
+ return resp["choices"][0]["text"]
224
+
225
+ async def acomplete(self, messages: list, **kw) -> ChatMessage:
226
+ """Async wrapper so AgentLoop can call provider.acomplete unchanged."""
227
+ # Convert ChatMessage dataclasses to plain dicts for llama_cpp,
228
+ # preserving tool_calls / tool_call_id for multi-turn tool loops.
229
+ def _to_dict(m):
230
+ if isinstance(m, ChatMessage):
231
+ d: dict[str, Any] = {"role": m.role, "content": m.content}
232
+ if m.name:
233
+ d["name"] = m.name
234
+ if getattr(m, "tool_calls", None):
235
+ d["tool_calls"] = m.tool_calls
236
+ if getattr(m, "tool_call_id", None):
237
+ d["tool_call_id"] = m.tool_call_id
238
+ return d
239
+ return m
240
+
241
+ msgs = [_to_dict(m) for m in messages]
242
+ # Run the (blocking) llama_cpp inference in a worker thread so the event
243
+ # loop is free for other concurrent workers. The GIL is released during
244
+ # the C++ generation, so genuinely parallel token generation happens when
245
+ # each worker uses its own runner instance.
246
+ loop = asyncio.get_running_loop()
247
+ resp = await loop.run_in_executor(
248
+ None,
249
+ lambda: self.chat(
250
+ msgs, tools=kw.get("tools"),
251
+ temperature=kw.get("temperature", 0.2),
252
+ max_tokens=kw.get("max_tokens", 1024),
253
+ json_mode=kw.get("json_mode", False),
254
+ ),
255
+ )
256
+ msg = resp["choices"][0]["message"]
257
+ cm = ChatMessage(role=msg.get("role", "assistant"),
258
+ content=msg.get("content") or "", name=msg.get("name"))
259
+ # llama_cpp returns tool_calls as dicts; expose as objects so
260
+ # parse_tool_calls() works unchanged.
261
+ raw_tc = msg.get("tool_calls")
262
+ if raw_tc:
263
+
264
+ class _Fn:
265
+ def __init__(self, d):
266
+ self.name = d["function"]["name"]
267
+ self.arguments = d["function"].get("arguments", "{}")
268
+
269
+ class _TC:
270
+ def __init__(self, d):
271
+ self.id = d.get("id", "call")
272
+ self.function = _Fn(d)
273
+
274
+ cm.tool_calls = [_TC(d) for d in raw_tc]
275
+ return cm
@@ -0,0 +1,85 @@
1
+ """Knowledge base: assembles the instruction context injected into workers.
2
+
3
+ Each worker instance is bootstrapped with:
4
+ - SKILLS (modules/skills/*.md) -> how to do kinds of tasks
5
+ - TASKS (<workspace>/.opencommand/tasks/*.md) -> reusable playbooks
6
+ - TOOLS (registry schema summaries) -> what actions are available
7
+ - DOCS (<workspace>/.opencommand/docs/*.md) -> learned reference docs
8
+
9
+ This is what lets the Commander "load skills, tasks, and tools into each worker
10
+ instance" and self-improve over time: new skills/tasks/docs written by the
11
+ instructions tool are picked up on the next run (or next phase).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from ..core.tools import ToolRegistry
19
+ from .tools.instructions import builtin_skills_dir, skills_dir
20
+
21
+ _SKILLS_MAX_CHARS = 4000
22
+ _TASKS_MAX_CHARS = 2500
23
+ _DOCS_MAX_CHARS = 2500
24
+
25
+
26
+ class KnowledgeBase:
27
+ """Reads skills/tasks/docs/tools and renders a bounded context string."""
28
+
29
+ def __init__(self, workspace: Path, tools: ToolRegistry) -> None:
30
+ self.workspace = workspace
31
+ self.tools = tools
32
+
33
+ def skills(self) -> str:
34
+ # Built-in skills ship with the package (read-only defaults); the swarm
35
+ # writes new/updated skills under the workspace, which take precedence.
36
+ builtin = self._read_bounded(builtin_skills_dir(), _SKILLS_MAX_CHARS, "SKILLS")
37
+ ws = self._read_bounded(skills_dir(self.workspace), _SKILLS_MAX_CHARS, "SKILLS")
38
+ parts = [p for p in (builtin, ws) if p]
39
+ return "\n\n".join(parts)
40
+
41
+ def tasks(self) -> str:
42
+ root = self.workspace / ".opencommand" / "tasks"
43
+ return self._read_bounded(root, _TASKS_MAX_CHARS, "TASKS")
44
+
45
+ def docs(self) -> str:
46
+ root = self.workspace / ".opencommand" / "docs"
47
+ return self._read_bounded(root, _DOCS_MAX_CHARS, "DOCS")
48
+
49
+ def tools_catalog(self) -> str:
50
+ lines = []
51
+ for t in self.tools._tools.values():
52
+ params = t.parameters or {}
53
+ props = params.get("properties", {})
54
+ args = ", ".join(props.keys()) or "none"
55
+ lines.append(f"- {t.name}({args}): {t.description}")
56
+ return "AVAILABLE TOOLS:\n" + "\n".join(lines)
57
+
58
+ def worker_context(self) -> str:
59
+ """Full instruction block for a worker's system prompt."""
60
+ parts = [self.tools_catalog()]
61
+ if sk := self.skills():
62
+ parts.append(sk)
63
+ if tk := self.tasks():
64
+ parts.append(tk)
65
+ if dc := self.docs():
66
+ parts.append(dc)
67
+ return "\n\n".join(parts)
68
+
69
+ @staticmethod
70
+ def _read_bounded(root: Path, limit: int, label: str) -> str:
71
+ if not root.exists():
72
+ return ""
73
+ chunks: list[str] = []
74
+ total = 0
75
+ for f in sorted(root.glob("*.md")):
76
+ text = f.read_text(encoding="utf-8")
77
+ if total + len(text) > limit:
78
+ text = text[: max(0, limit - total)]
79
+ chunks.append(f"## {f.stem}\n{text}")
80
+ total += len(text)
81
+ if total >= limit:
82
+ break
83
+ if not chunks:
84
+ return ""
85
+ return f"{label}:\n" + "\n\n".join(chunks)
@@ -0,0 +1,14 @@
1
+ # OpenCommand Memory
2
+
3
+ Obsidian-style tagged memory store. Notes are saved per-topic as markdown files
4
+ under `<workspace>/.opencommand/memory/` by the `memory` tool. Use `#tags` for
5
+ retrieval. This file is the index.
6
+
7
+ ## Index
8
+ - `goal_plan.md` — the Commander's multi-phase plan for the current goal.
9
+ - `goal_report.md` — final report after executing a goal.
10
+
11
+ ## Conventions
12
+ - One topic per file: `topic.md` with `# Topic #tag1 #tag2` header.
13
+ - Workers and the Commander both read/write here; it is the shared long-term
14
+ memory of the swarm.
@@ -0,0 +1,14 @@
1
+ # Bash Skill
2
+
3
+ Use `bash` for Linux/macOS shell tasks. OpenCommand runs shell via the
4
+ `terminal` tool, which on POSIX uses `/bin/sh -c`.
5
+
6
+ ## Conventions
7
+ - Prefer portable POSIX sh; avoid bashisms unless the target is GNU/Linux.
8
+ - Chain with `&&` and use `set -euo pipefail` in scripts for safety.
9
+ - Use `uv` for Python: `uv add <pkg>`, `uv run <cmd>`, `uv sync`.
10
+ - Inspect processes with `ps`, `pgrep`; logs with `tail -f`.
11
+ - For long-running servers, background them and capture logs.
12
+
13
+ ## When to use
14
+ - Installing system deps, running tests, git operations, build steps.