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.
- opencommand/__init__.py +4 -0
- opencommand/cli.py +375 -0
- opencommand/core/agent.py +91 -0
- opencommand/core/config.py +65 -0
- opencommand/core/errors.py +23 -0
- opencommand/core/logging.py +65 -0
- opencommand/core/supervisor.py +81 -0
- opencommand/core/tools.py +136 -0
- opencommand/engine/__init__.py +66 -0
- opencommand/engine/runner.py +275 -0
- opencommand/modules/knowledge.py +85 -0
- opencommand/modules/memory/MEMORY.md +14 -0
- opencommand/modules/skills/bash.md +14 -0
- opencommand/modules/skills/powershell.md +15 -0
- opencommand/modules/skills/python.md +20 -0
- opencommand/modules/skills/system.md +38 -0
- opencommand/modules/system/automatic.py +151 -0
- opencommand/modules/system/cron.py +193 -0
- opencommand/modules/system/notify.py +85 -0
- opencommand/modules/system/platform.py +197 -0
- opencommand/modules/templates.py +227 -0
- opencommand/modules/tools/desktop.py +127 -0
- opencommand/modules/tools/files.py +82 -0
- opencommand/modules/tools/instructions.py +163 -0
- opencommand/modules/tools/memory.py +78 -0
- opencommand/modules/tools/playwright.py +61 -0
- opencommand/modules/tools/research.py +46 -0
- opencommand/modules/tools/system.py +163 -0
- opencommand/modules/tools/terminal.py +53 -0
- opencommand/providers/provider.py +157 -0
- opencommand/swarm/agents/commander.py +530 -0
- opencommand/swarm/agents/worker.py +26 -0
- opencommand/tui/dashboard.py +41 -0
- opencommand-0.1.0.dist-info/METADATA +76 -0
- opencommand-0.1.0.dist-info/RECORD +37 -0
- opencommand-0.1.0.dist-info/WHEEL +4 -0
- opencommand-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Platform / host autodetection for OpenCommand setup and per-host config.
|
|
2
|
+
|
|
3
|
+
On first run (or `opencommand setup`) this probes the host and derives a
|
|
4
|
+
recommended configuration: shell, Python build (free-threaded vs GIL), CPU
|
|
5
|
+
topology (core count -> worker pool size), instruction-set support (AVX2 /
|
|
6
|
+
AVX-512 -> llama.cpp build flags), GPU backend (CUDA / ROCm / Metal / Vulkan /
|
|
7
|
+
none -> n_gpu_layers), available system tools (git, yara, ffmpeg, playwright
|
|
8
|
+
browsers), and total RAM. The result feeds `recommend_config()` which returns an
|
|
9
|
+
`OpenCommandConfig` plus engine hints, so the swarm is tuned per machine without
|
|
10
|
+
hand-editing anything.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def detect() -> dict:
|
|
23
|
+
"""Return a full host descriptor dict (everything OpenCommand cares about)."""
|
|
24
|
+
return {
|
|
25
|
+
"system": platform.system(),
|
|
26
|
+
"release": platform.release(),
|
|
27
|
+
"machine": platform.machine(),
|
|
28
|
+
"python": sys.version.split()[0],
|
|
29
|
+
"free_threaded": _free_threaded(),
|
|
30
|
+
"shell": shell_kind(),
|
|
31
|
+
"cores": os.cpu_count() or 1,
|
|
32
|
+
"isa": _isa(),
|
|
33
|
+
"gpu": _gpu(),
|
|
34
|
+
"ram_gb": _ram_gb(),
|
|
35
|
+
"tools": _tools(),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def shell_kind() -> str:
|
|
40
|
+
return "cmd" if sys.platform.startswith("win") else "sh"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _free_threaded() -> bool:
|
|
44
|
+
fn = getattr(sys, "_is_gil_enabled", None)
|
|
45
|
+
if fn is None:
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
return not fn()
|
|
49
|
+
except Exception: # noqa: BLE001
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _isa() -> dict:
|
|
54
|
+
"""Detect CPU instruction-set extensions (drives llama.cpp build flags)."""
|
|
55
|
+
caps: set[str] = set()
|
|
56
|
+
try:
|
|
57
|
+
import cpuid # type: ignore
|
|
58
|
+
except Exception:
|
|
59
|
+
cpuid = None
|
|
60
|
+
if cpuid is not None and hasattr(cpuid, "get_extended_feature_bits"):
|
|
61
|
+
bits = cpuid.get_extended_feature_bits(0)
|
|
62
|
+
if bits & (1 << 28):
|
|
63
|
+
caps.add("avx")
|
|
64
|
+
if bits & (1 << 5):
|
|
65
|
+
caps.add("avx2")
|
|
66
|
+
if bits & (1 << 16):
|
|
67
|
+
caps.add("avx512f")
|
|
68
|
+
else:
|
|
69
|
+
if sys.platform.startswith("linux"):
|
|
70
|
+
try:
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
info = Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="ignore")
|
|
73
|
+
for line in info.splitlines():
|
|
74
|
+
if line.lower().startswith("flags"):
|
|
75
|
+
for f in line.split()[2:]:
|
|
76
|
+
if f in ("avx", "avx2", "avx512f"):
|
|
77
|
+
caps.add(f)
|
|
78
|
+
break
|
|
79
|
+
except Exception: # noqa: BLE001
|
|
80
|
+
pass
|
|
81
|
+
if not caps:
|
|
82
|
+
mach = platform.machine().lower()
|
|
83
|
+
if mach in ("x86_64", "amd64"):
|
|
84
|
+
caps.add("avx2") # assume baseline AVX2 on modern x86_64
|
|
85
|
+
elif mach in ("arm64", "aarch64"):
|
|
86
|
+
caps.add("neon")
|
|
87
|
+
return {"extensions": sorted(caps), "avx2": "avx2" in caps, "avx512": "avx512f" in caps}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _gpu() -> dict:
|
|
91
|
+
"""Detect the best available GPU backend for llama.cpp offload."""
|
|
92
|
+
backend = "none"
|
|
93
|
+
detail = ""
|
|
94
|
+
if shutil.which("nvidia-smi"):
|
|
95
|
+
try:
|
|
96
|
+
out = subprocess.run(["nvidia-smi", "--query-gpu=name,memory.total",
|
|
97
|
+
"--format=csv,noheader"], capture_output=True,
|
|
98
|
+
text=True, timeout=10).stdout.strip()
|
|
99
|
+
backend, detail = "cuda", out.replace("\n", " | ")
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
backend = "cuda"
|
|
102
|
+
elif shutil.which("rocm-smi") or os.path.exists("/opt/rocm"):
|
|
103
|
+
backend = "rocm"
|
|
104
|
+
elif platform.system() == "Darwin" and platform.machine() == "arm64":
|
|
105
|
+
backend, detail = "metal", "Apple Silicon"
|
|
106
|
+
elif shutil.which("vulkaninfo") or os.environ.get("VK_ICD_FILENAMES"):
|
|
107
|
+
backend = "vulkan"
|
|
108
|
+
return {"backend": backend, "detail": detail}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _ram_gb() -> float:
|
|
112
|
+
"""Total system RAM in GB. Uses ctypes (no extra deps); Linux falls back to /proc."""
|
|
113
|
+
if sys.platform.startswith("win"):
|
|
114
|
+
try:
|
|
115
|
+
import ctypes
|
|
116
|
+
|
|
117
|
+
class _MEMORYSTATUSEX(ctypes.Structure):
|
|
118
|
+
_fields_ = [
|
|
119
|
+
("dwLength", ctypes.c_ulong),
|
|
120
|
+
("dwMemoryLoad", ctypes.c_ulong),
|
|
121
|
+
("ullTotalPhys", ctypes.c_ulonglong),
|
|
122
|
+
("ullAvailPhys", ctypes.c_ulonglong),
|
|
123
|
+
("ullTotalPageFile", ctypes.c_ulonglong),
|
|
124
|
+
("ullAvailPageFile", ctypes.c_ulonglong),
|
|
125
|
+
("ullTotalVirtual", ctypes.c_ulonglong),
|
|
126
|
+
("ullAvailVirtual", ctypes.c_ulonglong),
|
|
127
|
+
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
stat = _MEMORYSTATUSEX()
|
|
131
|
+
stat.dwLength = ctypes.sizeof(stat)
|
|
132
|
+
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
|
|
133
|
+
return round(stat.ullTotalPhys / (1024 ** 3), 1)
|
|
134
|
+
except Exception: # noqa: BLE001
|
|
135
|
+
pass
|
|
136
|
+
try:
|
|
137
|
+
if sys.platform.startswith("linux"):
|
|
138
|
+
from pathlib import Path
|
|
139
|
+
for line in Path("/proc/meminfo").read_text(encoding="utf-8",
|
|
140
|
+
errors="ignore").splitlines():
|
|
141
|
+
if line.startswith("MemTotal:"):
|
|
142
|
+
return round(int(line.split()[1]) / 1024 / 1024, 1)
|
|
143
|
+
except Exception: # noqa: BLE001
|
|
144
|
+
pass
|
|
145
|
+
return 0.0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _tools() -> dict:
|
|
149
|
+
"""Detect optional external tools the swarm can use."""
|
|
150
|
+
found: dict[str, bool] = {}
|
|
151
|
+
for name in ("git", "yara", "ffmpeg", "cmake", "cl", "gcc", "cc"):
|
|
152
|
+
found[name] = shutil.which(name) is not None
|
|
153
|
+
pw = False
|
|
154
|
+
try:
|
|
155
|
+
from playwright.sync_api import sync_playwright
|
|
156
|
+
with sync_playwright() as p:
|
|
157
|
+
pw = p.chromium.executable_path is not None
|
|
158
|
+
except Exception: # noqa: BLE001
|
|
159
|
+
pw = False
|
|
160
|
+
found["playwright_chromium"] = pw
|
|
161
|
+
return found
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def describe() -> str:
|
|
165
|
+
d = detect()
|
|
166
|
+
ft = "free-threaded" if d["free_threaded"] else "GIL"
|
|
167
|
+
gpu = d["gpu"]["backend"]
|
|
168
|
+
isa = ",".join(d["isa"]["extensions"]) or "base"
|
|
169
|
+
return (f"{d['system']} {d['release']} ({d['machine']}), Python {d['python']} "
|
|
170
|
+
f"[{ft}], {d['cores']} cores, ISA={isa}, GPU={gpu}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def recommend_config(d: dict | None = None) -> dict:
|
|
174
|
+
"""Map a host descriptor to OpenCommand + engine configuration hints.
|
|
175
|
+
|
|
176
|
+
Returns a dict with `config` (kwargs for OpenCommandConfig) and `engine`
|
|
177
|
+
(kwargs for Engine), ready to apply on setup.
|
|
178
|
+
"""
|
|
179
|
+
d = d or detect()
|
|
180
|
+
cores = max(1, d["cores"])
|
|
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
|
|
183
|
+
build_flags = []
|
|
184
|
+
if d["isa"]["avx512"]:
|
|
185
|
+
build_flags.append("-DGGML_AVX512=ON")
|
|
186
|
+
elif d["isa"]["avx2"]:
|
|
187
|
+
build_flags.append("-DGGML_AVX2=ON")
|
|
188
|
+
return {
|
|
189
|
+
"config": {
|
|
190
|
+
"max_workers": pool,
|
|
191
|
+
"vision": d["gpu"]["backend"] != "none" or cores >= 4,
|
|
192
|
+
},
|
|
193
|
+
"engine": {"pool_size": pool},
|
|
194
|
+
"runner": {"n_gpu_layers": n_gpu_layers},
|
|
195
|
+
"build": {"cmake_args": " ".join(build_flags) if build_flags else ""},
|
|
196
|
+
}
|
|
197
|
+
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Required markdown templates for every artifact OpenCommand can create.
|
|
2
|
+
|
|
3
|
+
Each template declares the mandatory front-matter / header fields and the body
|
|
4
|
+
sections an artifact must contain. The instructions tool and commander use these
|
|
5
|
+
to ensure every skill, task, doc, memory, scaffold, research, plan, and report
|
|
6
|
+
note is complete and consistent, so the knowledge base and memory store stay
|
|
7
|
+
structured and machine-readable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Template:
|
|
17
|
+
"""A required-shape template for one markdown artifact type."""
|
|
18
|
+
|
|
19
|
+
kind: str
|
|
20
|
+
title: str
|
|
21
|
+
# YAML-ish front-matter keys that MUST be present (value may be filled later).
|
|
22
|
+
required_fields: list[str] = field(default_factory=list)
|
|
23
|
+
# Markdown section headings (## ...) that MUST appear in the body.
|
|
24
|
+
required_sections: list[str] = field(default_factory=list)
|
|
25
|
+
# A concrete skeleton the writer fills in.
|
|
26
|
+
skeleton: str = ""
|
|
27
|
+
|
|
28
|
+
def render(self, **values: str) -> str:
|
|
29
|
+
"""Render the skeleton, substituting {field} placeholders from ``values``."""
|
|
30
|
+
try:
|
|
31
|
+
return self.skeleton.format(**values)
|
|
32
|
+
except (KeyError, IndexError):
|
|
33
|
+
return self.skeleton
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
TEMPLATES: dict[str, Template] = {
|
|
37
|
+
"skill": Template(
|
|
38
|
+
kind="skill",
|
|
39
|
+
title="Skill",
|
|
40
|
+
required_fields=["name", "category", "updated", "tags"],
|
|
41
|
+
required_sections=["Summary", "Conventions", "When to use", "Examples", "Gotchas"],
|
|
42
|
+
skeleton=(
|
|
43
|
+
"---\n"
|
|
44
|
+
"name: {name}\n"
|
|
45
|
+
"category: {category}\n"
|
|
46
|
+
"updated: {updated}\n"
|
|
47
|
+
"tags: [{tags}]\n"
|
|
48
|
+
"---\n\n"
|
|
49
|
+
"# {name}\n\n"
|
|
50
|
+
"## Summary\n{description}\n\n"
|
|
51
|
+
"## Conventions\n- \n\n"
|
|
52
|
+
"## When to use\n- \n\n"
|
|
53
|
+
"## Examples\n```\n\n```\n\n"
|
|
54
|
+
"## Gotchas\n- \n"
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
"task": Template(
|
|
58
|
+
kind="task",
|
|
59
|
+
title="Task Playbook",
|
|
60
|
+
required_fields=["name", "goal", "updated", "tags"],
|
|
61
|
+
required_sections=["Objective", "Preconditions", "Steps", "Verification", "Rollback"],
|
|
62
|
+
skeleton=(
|
|
63
|
+
"---\n"
|
|
64
|
+
"name: {name}\n"
|
|
65
|
+
"goal: {goal}\n"
|
|
66
|
+
"updated: {updated}\n"
|
|
67
|
+
"tags: [{tags}]\n"
|
|
68
|
+
"---\n\n"
|
|
69
|
+
"# {name}\n\n"
|
|
70
|
+
"## Objective\n\n"
|
|
71
|
+
"## Preconditions\n- \n\n"
|
|
72
|
+
"## Steps\n1. \n\n"
|
|
73
|
+
"## Verification\n- \n\n"
|
|
74
|
+
"## Rollback\n- \n"
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
"doc": Template(
|
|
78
|
+
kind="doc",
|
|
79
|
+
title="Reference Doc",
|
|
80
|
+
required_fields=["name", "topic", "updated", "tags"],
|
|
81
|
+
required_sections=["Overview", "Details", "References"],
|
|
82
|
+
skeleton=(
|
|
83
|
+
"---\n"
|
|
84
|
+
"name: {name}\n"
|
|
85
|
+
"topic: {topic}\n"
|
|
86
|
+
"updated: {updated}\n"
|
|
87
|
+
"tags: [{tags}]\n"
|
|
88
|
+
"---\n\n"
|
|
89
|
+
"# {name}\n\n"
|
|
90
|
+
"## Overview\n\n"
|
|
91
|
+
"## Details\n\n"
|
|
92
|
+
"## References\n- \n"
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
"memory": Template(
|
|
96
|
+
kind="memory",
|
|
97
|
+
title="Memory Note",
|
|
98
|
+
required_fields=["topic", "tags", "created"],
|
|
99
|
+
required_sections=["Body"],
|
|
100
|
+
skeleton=(
|
|
101
|
+
"---\n"
|
|
102
|
+
"topic: {topic}\n"
|
|
103
|
+
"tags: [{tags}]\n"
|
|
104
|
+
"created: {created}\n"
|
|
105
|
+
"---\n\n"
|
|
106
|
+
"# {topic}\n\n"
|
|
107
|
+
"## Body\n\n"
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
"scaffold": Template(
|
|
111
|
+
kind="scaffold",
|
|
112
|
+
title="Scaffold Manifest",
|
|
113
|
+
required_fields=["phase", "index", "created"],
|
|
114
|
+
required_sections=["Purpose", "Layout", "Conventions"],
|
|
115
|
+
skeleton=(
|
|
116
|
+
"---\n"
|
|
117
|
+
"phase: {phase}\n"
|
|
118
|
+
"index: {index}\n"
|
|
119
|
+
"created: {created}\n"
|
|
120
|
+
"---\n\n"
|
|
121
|
+
"# Scaffold: {phase}\n\n"
|
|
122
|
+
"## Purpose\n\n"
|
|
123
|
+
"## Layout\n- \n\n"
|
|
124
|
+
"## Conventions\n- Write implementation under this directory.\n"
|
|
125
|
+
"- Do not modify files outside this phase's scope.\n"
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
"research": Template(
|
|
129
|
+
kind="research",
|
|
130
|
+
title="Research Findings",
|
|
131
|
+
required_fields=["phase", "iteration", "created"],
|
|
132
|
+
required_sections=["Question", "Findings", "Sources", "Confidence", "Gaps"],
|
|
133
|
+
skeleton=(
|
|
134
|
+
"---\n"
|
|
135
|
+
"phase: {phase}\n"
|
|
136
|
+
"iteration: {iteration}\n"
|
|
137
|
+
"created: {created}\n"
|
|
138
|
+
"---\n\n"
|
|
139
|
+
"# Research: {phase} (iter {iteration})\n\n"
|
|
140
|
+
"## Question\n\n"
|
|
141
|
+
"## Findings\n- \n\n"
|
|
142
|
+
"## Sources\n- \n\n"
|
|
143
|
+
"## Confidence\n- high | medium | low\n\n"
|
|
144
|
+
"## Gaps\n- \n"
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
"plan": Template(
|
|
148
|
+
kind="plan",
|
|
149
|
+
title="Goal Plan",
|
|
150
|
+
required_fields=["goal", "created", "phases"],
|
|
151
|
+
required_sections=["Goal", "Phases", "Assumptions"],
|
|
152
|
+
skeleton=(
|
|
153
|
+
"---\n"
|
|
154
|
+
"goal: {goal}\n"
|
|
155
|
+
"created: {created}\n"
|
|
156
|
+
"phases: {phases}\n"
|
|
157
|
+
"---\n\n"
|
|
158
|
+
"# Plan\n\n"
|
|
159
|
+
"## Goal\n{goal}\n\n"
|
|
160
|
+
"## Phases\n\n"
|
|
161
|
+
"## Assumptions\n- \n"
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
"report": Template(
|
|
165
|
+
kind="report",
|
|
166
|
+
title="Goal Report",
|
|
167
|
+
required_fields=["goal", "created", "status"],
|
|
168
|
+
required_sections=["Summary", "Phases", "Review", "Self-Improvement"],
|
|
169
|
+
skeleton=(
|
|
170
|
+
"---\n"
|
|
171
|
+
"goal: {goal}\n"
|
|
172
|
+
"created: {created}\n"
|
|
173
|
+
"status: {status}\n"
|
|
174
|
+
"---\n\n"
|
|
175
|
+
"# Report\n\n"
|
|
176
|
+
"## Summary\n\n"
|
|
177
|
+
"## Phases\n\n"
|
|
178
|
+
"## Review\n\n"
|
|
179
|
+
"## Self-Improvement\n\n"
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_template(kind: str) -> Template | None:
|
|
186
|
+
return TEMPLATES.get(kind)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def template_for(kind: str) -> Template:
|
|
190
|
+
"""Return the template for ``kind`` or raise ``KeyError``."""
|
|
191
|
+
t = TEMPLATES.get(kind)
|
|
192
|
+
if t is None:
|
|
193
|
+
raise KeyError(f"no template for kind {kind!r}")
|
|
194
|
+
return t
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def validate_markdown(kind: str, text: str) -> list[str]:
|
|
198
|
+
"""Return a list of missing required front-matter fields/sections for ``kind``."""
|
|
199
|
+
t = TEMPLATES.get(kind)
|
|
200
|
+
if t is None:
|
|
201
|
+
return [f"unknown kind: {kind}"]
|
|
202
|
+
missing: list[str] = []
|
|
203
|
+
fm = _parse_front_matter(text)
|
|
204
|
+
for field_name in t.required_fields:
|
|
205
|
+
if field_name not in fm:
|
|
206
|
+
missing.append(f"field:{field_name}")
|
|
207
|
+
for section in t.required_sections:
|
|
208
|
+
if f"## {section}" not in text:
|
|
209
|
+
missing.append(f"section:{section}")
|
|
210
|
+
return missing
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_front_matter(text: str) -> dict[str, str]:
|
|
214
|
+
"""Extract a simple YAML front-matter block (key: value) into a dict."""
|
|
215
|
+
if not text.startswith("---"):
|
|
216
|
+
return {}
|
|
217
|
+
end = text.find("\n---", 3)
|
|
218
|
+
if end == -1:
|
|
219
|
+
return {}
|
|
220
|
+
block = text[3:end]
|
|
221
|
+
fm: dict[str, str] = {}
|
|
222
|
+
for line in block.splitlines():
|
|
223
|
+
if ":" not in line:
|
|
224
|
+
continue
|
|
225
|
+
key, _, val = line.partition(":")
|
|
226
|
+
fm[key.strip()] = val.strip()
|
|
227
|
+
return fm
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Desktop control tool: realtime vision + mouse/keyboard action loop.
|
|
2
|
+
|
|
3
|
+
Captures the screen, asks a small embedded vision-language model what is
|
|
4
|
+
happening and what to do next, then executes the chosen action via
|
|
5
|
+
pyautogui/pynput. Used for fully interactive testing (e.g. clicking UI,
|
|
6
|
+
verifying behavior). The vision model is the built-in Qwen3-VL (or MiniCPM-V)
|
|
7
|
+
the Engine (no external server).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import io
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from ...core.tools import BaseTool
|
|
17
|
+
from ...engine.runner import LocalRunner
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import pyautogui
|
|
21
|
+
import mss
|
|
22
|
+
_HAVE_DESKTOP = True
|
|
23
|
+
except Exception: # pragma: no cover
|
|
24
|
+
_HAVE_DESKTOP = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DesktopStep:
|
|
29
|
+
observation: str
|
|
30
|
+
action: str
|
|
31
|
+
ok: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def capture_screen() -> bytes:
|
|
35
|
+
"""Return a PNG screenshot of the primary monitor as bytes."""
|
|
36
|
+
if not _HAVE_DESKTOP:
|
|
37
|
+
raise RuntimeError("pyautogui/mss not available")
|
|
38
|
+
with mss.mss() as sct:
|
|
39
|
+
img = sct.grab(sct.monitors[1])
|
|
40
|
+
import PIL.Image
|
|
41
|
+
buf = io.BytesIO()
|
|
42
|
+
PIL.Image.frombytes("RGB", img.size, img.rgb).save(buf, format="PNG")
|
|
43
|
+
return buf.getvalue()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def act(action: str) -> None:
|
|
47
|
+
"""Execute a high-level action string: click(x,y), type(text), key(name), wait."""
|
|
48
|
+
if not _HAVE_DESKTOP:
|
|
49
|
+
raise RuntimeError("pyautogui not available")
|
|
50
|
+
action = action.strip()
|
|
51
|
+
if action.startswith("click("):
|
|
52
|
+
import re
|
|
53
|
+
m = re.match(r"click\((\d+),\s*(\d+)\)", action)
|
|
54
|
+
if m:
|
|
55
|
+
pyautogui.click(int(m.group(1)), int(m.group(2)))
|
|
56
|
+
elif action.startswith("type("):
|
|
57
|
+
pyautogui.write(action[len("type("):-1], interval=0.02)
|
|
58
|
+
elif action.startswith("key("):
|
|
59
|
+
pyautogui.press(action[len("key("):-1])
|
|
60
|
+
elif action.startswith("wait("):
|
|
61
|
+
import time
|
|
62
|
+
time.sleep(float(action[len("wait("):-1]))
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError(f"unknown action: {action}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DesktopTool(BaseTool):
|
|
68
|
+
name = "desktop"
|
|
69
|
+
description = (
|
|
70
|
+
"Realtime computer control: capture screen, ask a small vision model what "
|
|
71
|
+
"to do, and execute mouse/keyboard actions. For interactive testing."
|
|
72
|
+
)
|
|
73
|
+
parameters = {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"properties": {
|
|
76
|
+
"goal": {"type": "string", "description": "What to accomplish on screen."},
|
|
77
|
+
"steps": {"type": "integer", "description": "Max interaction steps (default 10)."},
|
|
78
|
+
},
|
|
79
|
+
"required": ["goal"],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def __init__(self, vision_runner: LocalRunner | None = None) -> None:
|
|
83
|
+
self.vision = vision_runner
|
|
84
|
+
|
|
85
|
+
def run(self, goal: str, steps: int = 10) -> str:
|
|
86
|
+
if not _HAVE_DESKTOP:
|
|
87
|
+
return "ERROR: desktop control libs (pyautogui/mss) not installed"
|
|
88
|
+
if self.vision is None:
|
|
89
|
+
return "ERROR: no vision model configured for desktop tool"
|
|
90
|
+
history: list[DesktopStep] = []
|
|
91
|
+
for i in range(steps):
|
|
92
|
+
b64 = base64.b64encode(capture_screen()).decode()
|
|
93
|
+
content = [
|
|
94
|
+
{"type": "text", "text": (
|
|
95
|
+
f"Goal: {goal}\nStep {i+1}. Describe the screen and decide the "
|
|
96
|
+
"next single action as: click(x,y) | type(text) | key(name) | "
|
|
97
|
+
"wait(secs) | done. Reply with ACTION: <action> and a short WHY.")},
|
|
98
|
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
|
|
99
|
+
]
|
|
100
|
+
resp = self.vision.chat([{"role": "user", "content": content}],
|
|
101
|
+
temperature=0.1, max_tokens=256)
|
|
102
|
+
text = resp["choices"][0]["message"].get("content") or ""
|
|
103
|
+
action = self._extract_action(text)
|
|
104
|
+
if action == "done" or action == "":
|
|
105
|
+
history.append(DesktopStep(text, "done", True))
|
|
106
|
+
break
|
|
107
|
+
try:
|
|
108
|
+
act(action)
|
|
109
|
+
history.append(DesktopStep(text, action, True))
|
|
110
|
+
except Exception as e: # noqa: BLE001
|
|
111
|
+
history.append(DesktopStep(text, action, False))
|
|
112
|
+
return f"ERROR on step {i+1}: {e}\n{self._summarize(history)}"
|
|
113
|
+
return self._summarize(history)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _extract_action(text: str) -> str:
|
|
117
|
+
for line in text.splitlines():
|
|
118
|
+
if line.strip().upper().startswith("ACTION:"):
|
|
119
|
+
return line.split(":", 1)[1].strip()
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _summarize(history: list[DesktopStep]) -> str:
|
|
124
|
+
lines = [f"desktop loop: {len(history)} steps"]
|
|
125
|
+
for i, s in enumerate(history, 1):
|
|
126
|
+
lines.append(f" {i}. {s.action} -> {'ok' if s.ok else 'FAIL'}")
|
|
127
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Filesystem tool: deterministic file authoring for agents (write/read/edit/list).
|
|
2
|
+
|
|
3
|
+
On Windows the shell tool cannot reliably emit multi-line files (no heredoc /
|
|
4
|
+
cat >), so agents need a real file API to write full codebases. All paths are
|
|
5
|
+
resolved under the workspace root; traversal outside it is rejected.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ...core.tools import BaseTool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FilesTool(BaseTool):
|
|
16
|
+
name = "files"
|
|
17
|
+
description = (
|
|
18
|
+
"Author and inspect files in the workspace: write/create, read, edit "
|
|
19
|
+
"(old->new string replace), list a directory, and mkdir. Paths are "
|
|
20
|
+
"relative to or inside the workspace root; traversal outside is rejected."
|
|
21
|
+
)
|
|
22
|
+
parameters = {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"action": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"enum": ["write", "read", "edit", "list", "mkdir"],
|
|
28
|
+
"description": "Which filesystem operation to perform.",
|
|
29
|
+
},
|
|
30
|
+
"path": {"type": "string", "description": "Target path (relative to workspace)."},
|
|
31
|
+
"content": {"type": "string", "description": "File content for write."},
|
|
32
|
+
"old": {"type": "string", "description": "Substring to replace for edit."},
|
|
33
|
+
"new": {"type": "string", "description": "Replacement substring for edit."},
|
|
34
|
+
},
|
|
35
|
+
"required": ["action", "path"],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def __init__(self, workspace: Path) -> None:
|
|
39
|
+
self.root = Path(workspace).resolve()
|
|
40
|
+
|
|
41
|
+
def _resolve(self, path: str) -> Path:
|
|
42
|
+
p = (self.root / path).resolve()
|
|
43
|
+
if self.root not in p.parents and p != self.root:
|
|
44
|
+
raise ValueError(f"path escapes workspace: {path}")
|
|
45
|
+
return p
|
|
46
|
+
|
|
47
|
+
def run(self, action: str, path: str, content: str | None = None,
|
|
48
|
+
old: str | None = None, new: str | None = None) -> str:
|
|
49
|
+
try:
|
|
50
|
+
p = self._resolve(path)
|
|
51
|
+
except ValueError as e:
|
|
52
|
+
return f"ERROR: {e}"
|
|
53
|
+
if action == "write":
|
|
54
|
+
if content is None:
|
|
55
|
+
return "ERROR: write requires 'content'"
|
|
56
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
p.write_text(content, encoding="utf-8")
|
|
58
|
+
return f"Wrote {p} ({len(content)} bytes)"
|
|
59
|
+
if action == "read":
|
|
60
|
+
if not p.is_file():
|
|
61
|
+
return f"ERROR: not a file: {path}"
|
|
62
|
+
return p.read_text(encoding="utf-8")
|
|
63
|
+
if action == "edit":
|
|
64
|
+
if old is None or new is None:
|
|
65
|
+
return "ERROR: edit requires 'old' and 'new'"
|
|
66
|
+
if not p.is_file():
|
|
67
|
+
return f"ERROR: not a file: {path}"
|
|
68
|
+
text = p.read_text(encoding="utf-8")
|
|
69
|
+
if old not in text:
|
|
70
|
+
return "ERROR: 'old' substring not found"
|
|
71
|
+
p.write_text(text.replace(old, new, 1), encoding="utf-8")
|
|
72
|
+
return f"Edited {p}"
|
|
73
|
+
if action == "list":
|
|
74
|
+
if not p.is_dir():
|
|
75
|
+
return f"ERROR: not a directory: {path}"
|
|
76
|
+
items = sorted(
|
|
77
|
+
(f"{'d' if c.is_dir() else 'f'} {c.name}" for c in p.iterdir()))
|
|
78
|
+
return "\n".join(items) if items else "(empty)"
|
|
79
|
+
if action == "mkdir":
|
|
80
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
return f"Mkdir {p}"
|
|
82
|
+
return f"ERROR: unknown action '{action}'"
|