gillm 0.1.3__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.
gillm/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """gillm: GUI Control Plugin with NLP & Intent Contracts.
2
+
3
+ This package consolidates GUI control logic across the Koru ecosystem,
4
+ integrates NLP2DSL client SDK for parsing intents, and supports
5
+ contract validation via Intract.
6
+ """
7
+
8
+ from gillm import capture, focus, injection, intents, nlp_bridge, orchestrator
9
+ from gillm.orchestrator import DriveOrchestrator
10
+
11
+ __version__ = "0.1.3"
12
+ __all__ = [
13
+ "DriveOrchestrator",
14
+ "capture",
15
+ "focus",
16
+ "injection",
17
+ "intents",
18
+ "nlp_bridge",
19
+ "orchestrator",
20
+ ]
@@ -0,0 +1,16 @@
1
+ """Screen capture and vision logic."""
2
+
3
+ from gillm.capture.mss_backend import (
4
+ CapturedImage,
5
+ capture_primary_rgb,
6
+ capture_primary_rgb_wayland_fallback,
7
+ )
8
+ from gillm.capture.portal_backend import PortalCaptureError, capture_portal_png
9
+
10
+ __all__ = [
11
+ "CapturedImage",
12
+ "PortalCaptureError",
13
+ "capture_portal_png",
14
+ "capture_primary_rgb",
15
+ "capture_primary_rgb_wayland_fallback",
16
+ ]
@@ -0,0 +1,130 @@
1
+ """Cross-platform screenshot capture with mss backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ from gillm.capture.portal_backend import PortalCaptureError, capture_portal_png
9
+
10
+
11
+ def resolve_scale(override: float | None) -> float:
12
+ if override is not None:
13
+ value = override
14
+ else:
15
+ raw = os.environ.get("KORU_VISION_SCALE", "0.2").strip() or "0.2"
16
+ try:
17
+ value = float(raw)
18
+ except ValueError:
19
+ value = 0.2
20
+ return max(0.05, min(1.0, value))
21
+
22
+
23
+ def downscale_rgb_nearest(
24
+ rgb: bytes, src_w: int, src_h: int, dst_w: int, dst_h: int
25
+ ) -> bytes:
26
+ if dst_w >= src_w and dst_h >= src_h:
27
+ return rgb
28
+ src_stride = src_w * 3
29
+ cols = [(x * src_w // dst_w) * 3 for x in range(dst_w)]
30
+ out = bytearray(dst_w * dst_h * 3)
31
+ out_off = 0
32
+ src_view = memoryview(rgb)
33
+ for y in range(dst_h):
34
+ row_base = (y * src_h // dst_h) * src_stride
35
+ for col_off in cols:
36
+ src_off = row_base + col_off
37
+ out[out_off : out_off + 3] = src_view[src_off : src_off + 3]
38
+ out_off += 3
39
+ return bytes(out)
40
+
41
+
42
+ def rgb_mostly_black(rgb: bytes, *, threshold: float = 0.98) -> bool:
43
+ if not rgb:
44
+ return True
45
+ step = max(1, (len(rgb) // 3) // 8000)
46
+ black = 0
47
+ total = 0
48
+ for offset in range(0, len(rgb) - 2, 3 * step):
49
+ total += 1
50
+ if rgb[offset : offset + 3] == b"\x00\x00\x00":
51
+ black += 1
52
+ return total > 0 and (black / total) >= threshold
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class CapturedImage:
57
+ """Portable raw image container."""
58
+
59
+ width: int
60
+ height: int
61
+ rgb: bytes
62
+ scale: float
63
+
64
+
65
+ def capture_primary_rgb(*, scale: float | None = None) -> CapturedImage:
66
+ """Capture the primary monitor as RGB pixels, scaled by ``scale``."""
67
+ import mss
68
+
69
+ scale_val = resolve_scale(scale)
70
+ with mss.mss() as sct:
71
+ monitor = sct.monitors[1] # primary monitor
72
+ img = sct.grab(monitor)
73
+ src_w = img.width
74
+ src_h = img.height
75
+ dst_w = int(src_w * scale_val)
76
+ dst_h = int(src_h * scale_val)
77
+ rgb_raw = img.rgb
78
+ if scale_val < 1.0:
79
+ rgb_scaled = downscale_rgb_nearest(rgb_raw, src_w, src_h, dst_w, dst_h)
80
+ return CapturedImage(
81
+ width=dst_w,
82
+ height=dst_h,
83
+ rgb=rgb_scaled,
84
+ scale=scale_val,
85
+ )
86
+ return CapturedImage(
87
+ width=src_w,
88
+ height=src_h,
89
+ rgb=rgb_raw,
90
+ scale=1.0,
91
+ )
92
+
93
+
94
+ def capture_primary_rgb_wayland_fallback(*, scale: float | None = None) -> CapturedImage:
95
+ """Try mss, fallback to XDG Desktop Portal if mostly black (typical on Wayland)."""
96
+ try:
97
+ img = capture_primary_rgb(scale=scale)
98
+ if not rgb_mostly_black(img.rgb):
99
+ return img
100
+ except Exception:
101
+ pass
102
+
103
+ # Portal path
104
+ png_bytes = capture_portal_png()
105
+ return _parse_png_to_rgb(png_bytes, scale=scale)
106
+
107
+
108
+ def _parse_png_to_rgb(png_bytes: bytes, *, scale: float | None = None) -> CapturedImage:
109
+ # Use pillow dynamically if available
110
+ try:
111
+ from io import BytesIO
112
+ from PIL import Image
113
+
114
+ img = Image.open(BytesIO(png_bytes))
115
+ if img.mode != "RGB":
116
+ img = img.convert("RGB")
117
+ src_w, src_h = img.size
118
+ scale_val = resolve_scale(scale)
119
+ dst_w = int(src_w * scale_val)
120
+ dst_h = int(src_h * scale_val)
121
+ if scale_val < 1.0:
122
+ img = img.resize((dst_w, dst_h), Image.Resampling.NEAREST)
123
+ return CapturedImage(
124
+ width=img.size[0],
125
+ height=img.size[1],
126
+ rgb=img.tobytes(),
127
+ scale=scale_val,
128
+ )
129
+ except Exception as exc:
130
+ raise PortalCaptureError(f"cannot parse PNG bytes to RGB (PIL required for portal parsing): {exc}") from exc
@@ -0,0 +1,113 @@
1
+ """Wayland-friendly screenshot via XDG Desktop Portal.
2
+
3
+ Extracted from ``koruvision.portal_capture``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import urllib.parse
13
+ from pathlib import Path
14
+
15
+
16
+ class PortalCaptureError(RuntimeError):
17
+ """Portal screenshot failed."""
18
+
19
+
20
+ _INLINE_SCRIPT = r"""
21
+ import sys
22
+ import dbus
23
+ import dbus.mainloop.glib
24
+ from gi.repository import GLib
25
+
26
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
27
+ bus = dbus.SessionBus()
28
+ token = "koruvision_portal"
29
+ sender = bus.get_unique_name()[1:].replace(".", "_")
30
+ request_path = f"/org/freedesktop/portal/desktop/request/{sender}/{token}"
31
+ state = {"uri": None, "error": None}
32
+
33
+ def _on_response(response, results):
34
+ if int(response) != 0:
35
+ state["error"] = f"portal response code {response}"
36
+ elif "uri" in results:
37
+ state["uri"] = str(results["uri"])
38
+ else:
39
+ state["error"] = "portal response missing uri"
40
+ loop.quit()
41
+
42
+ bus.add_signal_receiver(
43
+ _on_response,
44
+ dbus_interface="org.freedesktop.portal.Request",
45
+ path=request_path,
46
+ signal_name="Response",
47
+ )
48
+ proxy = bus.get_object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop")
49
+ iface = dbus.Interface(proxy, "org.freedesktop.portal.Screenshot")
50
+ iface.Screenshot("", {"handle_token": token, "interactive": False})
51
+
52
+ loop = GLib.MainLoop()
53
+ GLib.timeout_add(12000, lambda: (loop.quit(), False)[1])
54
+ loop.run()
55
+
56
+ if state["error"]:
57
+ print(state["error"], file=sys.stderr)
58
+ sys.exit(2)
59
+ if not state["uri"]:
60
+ print("portal screenshot timed out", file=sys.stderr)
61
+ sys.exit(3)
62
+ print(state["uri"])
63
+ """
64
+
65
+
66
+ def _portal_python() -> str:
67
+ override = os.environ.get("KORU_PORTAL_PYTHON", "").strip()
68
+ if override:
69
+ return override
70
+ candidates = ["/usr/bin/python3", shutil.which("python3"), sys.executable]
71
+ for candidate in candidates:
72
+ if not candidate:
73
+ continue
74
+ try:
75
+ proc = subprocess.run(
76
+ [candidate, "-c", "import dbus; import gi"],
77
+ capture_output=True,
78
+ timeout=5,
79
+ check=False,
80
+ )
81
+ except (OSError, subprocess.TimeoutExpired):
82
+ continue
83
+ if proc.returncode == 0:
84
+ return candidate
85
+ return sys.executable
86
+
87
+
88
+ def capture_portal_png(*, timeout_seconds: float = 15.0) -> bytes:
89
+ """Capture the screen through ``org.freedesktop.portal.Screenshot``."""
90
+ python = _portal_python()
91
+ try:
92
+ proc = subprocess.run(
93
+ [python, "-c", _INLINE_SCRIPT],
94
+ capture_output=True,
95
+ timeout=timeout_seconds,
96
+ text=True,
97
+ check=False,
98
+ )
99
+ except (OSError, subprocess.TimeoutExpired) as exc:
100
+ raise PortalCaptureError(f"portal subprocess failed: {exc}") from exc
101
+ if proc.returncode != 0:
102
+ detail = (proc.stderr or "").strip() or f"exit code {proc.returncode}"
103
+ raise PortalCaptureError(
104
+ f"portal capture failed via {python}: {detail}."
105
+ )
106
+ uri = (proc.stdout or "").strip()
107
+ if not uri:
108
+ raise PortalCaptureError("portal returned an empty URI")
109
+ path = Path(urllib.parse.urlparse(uri).path)
110
+ try:
111
+ return path.read_bytes()
112
+ except OSError as exc:
113
+ raise PortalCaptureError(f"cannot read portal screenshot at {path}") from exc
gillm/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """CLI entry point package."""
2
+
3
+ from gillm.cli.main import main
4
+
5
+ __all__ = ["main"]
gillm/cli/main.py ADDED
@@ -0,0 +1,80 @@
1
+ """Command-line interface for the gillm GUI control engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from gillm.orchestrator.drive import DriveOrchestrator
10
+
11
+
12
+ def main() -> int:
13
+ parser = argparse.ArgumentParser(
14
+ description="gillm: LLM/NLP-driven GUI Control Engine"
15
+ )
16
+ subparsers = parser.add_subparsers(dest="command", help="Sub-commands")
17
+
18
+ # Command: run (execute direct actions or DSL file)
19
+ run_parser = subparsers.add_parser("run", help="Execute GUI actions from file")
20
+ run_parser.add_argument("file", help="Path to JSON file containing GUI steps")
21
+ run_parser.add_argument("--dry-run", action="store_true", help="Log actions without executing them")
22
+
23
+ # Command: nlp (execute natural language command)
24
+ nlp_parser = subparsers.add_parser("nlp", help="Translate and execute natural language GUI commands")
25
+ nlp_parser.add_argument("instruction", help="Natural language instruction (e.g. 'focus vscode and type hello')")
26
+ nlp_parser.add_argument("--dry-run", action="store_true", help="Log actions without executing them")
27
+
28
+ # Command: capture (take screen capture)
29
+ capture_parser = subparsers.add_parser("capture", help="Take a primary display screen capture")
30
+ capture_parser.add_argument("--scale", type=float, default=0.2, help="Image scale factor (default: 0.2)")
31
+
32
+ args = parser.parse_args()
33
+
34
+ if not args.command:
35
+ parser.print_help()
36
+ return 0
37
+
38
+ orchestrator = DriveOrchestrator(log_fn=lambda msg: print(f"[*] {msg}"))
39
+
40
+ if args.command == "run":
41
+ try:
42
+ with open(args.file, encoding="utf-8") as f:
43
+ steps = json.load(f)
44
+ if not isinstance(steps, list):
45
+ print("[-] Error: Workflow file must contain a JSON list of steps", file=sys.stderr)
46
+ return 1
47
+ results = orchestrator.execute_workflow(steps, dry_run=args.dry_run)
48
+ print("[+] Execution finished:")
49
+ print(json.dumps(results, indent=2))
50
+ return 0
51
+ except Exception as exc:
52
+ print(f"[-] Error: {exc}", file=sys.stderr)
53
+ return 1
54
+
55
+ elif args.command == "nlp":
56
+ try:
57
+ print(f"[*] Parsing and executing instruction: {args.instruction!r}")
58
+ results = orchestrator.drive_natural_language(args.instruction, dry_run=args.dry_run)
59
+ print("[+] Execution finished:")
60
+ print(json.dumps(results, indent=2))
61
+ return 0
62
+ except Exception as exc:
63
+ print(f"[-] Error: {exc}", file=sys.stderr)
64
+ return 1
65
+
66
+ elif args.command == "capture":
67
+ try:
68
+ print(f"[*] Capturing screenshot (scale={args.scale})...")
69
+ img = orchestrator.capture_screenshot(scale=args.scale)
70
+ print(f"[+] Success: Captured {img.width}x{img.height} screen at scale {img.scale}")
71
+ return 0
72
+ except Exception as exc:
73
+ print(f"[-] Error: {exc}", file=sys.stderr)
74
+ return 1
75
+
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main())
gillm/config.py ADDED
@@ -0,0 +1,112 @@
1
+ """User-tunable autopilot config.
2
+
3
+ Self-contained config module for gillm.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ import tomllib
11
+ from dataclasses import dataclass, field
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+
15
+
16
+ def resolve_xdg_path(relative_path: str) -> Path:
17
+ """Resolve an XDG-style config path."""
18
+ xdg = os.environ.get("XDG_CONFIG_HOME")
19
+ base = Path(xdg) if xdg else Path.home() / ".config"
20
+ return base / relative_path
21
+
22
+
23
+ # Single source of truth for built-in submit keys.
24
+ _BUILTIN_SUBMIT_KEYS: dict[str, str] = {
25
+ "default": "Return",
26
+ "antigravity": "Return",
27
+ "windsurf": "Return",
28
+ "vscode": "Return",
29
+ "vscodium": "Return",
30
+ "cursor": "Return",
31
+ "jetbrains": "ctrl+Return",
32
+ "zed": "Return",
33
+ }
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AutopilotConfig:
38
+ """In-memory view of ``autopilot.toml`` (or defaults)."""
39
+
40
+ submit_keys: dict[str, str] = field(default_factory=lambda: dict(_BUILTIN_SUBMIT_KEYS))
41
+ source: Path | None = None
42
+
43
+ def submit_key_for(self, ide: str) -> str:
44
+ """Return the configured submit key for ``ide`` (or the default)."""
45
+ return self.submit_keys.get(ide) or self.submit_keys.get("default", "Return")
46
+
47
+
48
+ def default_config_path() -> Path:
49
+ """Resolve the XDG-style config path for autopilot."""
50
+ return resolve_xdg_path("koru/autopilot.toml")
51
+
52
+
53
+ def _merge_submit_keys(raw: object) -> dict[str, str]:
54
+ """Validate and merge user-provided ``[submit_keys]`` over defaults."""
55
+ merged = dict(_BUILTIN_SUBMIT_KEYS)
56
+ if not isinstance(raw, dict):
57
+ return merged
58
+ for ide, key in raw.items():
59
+ if not isinstance(ide, str) or not ide:
60
+ continue
61
+ if not isinstance(key, str) or not key:
62
+ continue
63
+ merged[ide] = key
64
+ return merged
65
+
66
+
67
+ def load_config(path: Path | None = None) -> AutopilotConfig:
68
+ """Read the TOML config from ``path`` (or the default location)."""
69
+ config_path = path or default_config_path()
70
+ if not config_path.is_file():
71
+ return AutopilotConfig()
72
+ try:
73
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
74
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
75
+ print(
76
+ f"gillm autopilot: ignoring malformed config {config_path}: {exc}",
77
+ file=sys.stderr,
78
+ )
79
+ return AutopilotConfig()
80
+ submit_keys = _merge_submit_keys(data.get("submit_keys"))
81
+ return AutopilotConfig(submit_keys=submit_keys, source=config_path)
82
+
83
+
84
+ @lru_cache(maxsize=1)
85
+ def _cached_config() -> AutopilotConfig:
86
+ return load_config()
87
+
88
+
89
+ def cached_config() -> AutopilotConfig:
90
+ """Process-lifetime memoised :func:`load_config`."""
91
+ for mod_name in ("koru.autopilot.injector", "koruide.injector", "koru.autopilot.config"):
92
+ if mod_name in sys.modules:
93
+ mod = sys.modules[mod_name]
94
+ if hasattr(mod, "cached_config"):
95
+ val = getattr(mod, "cached_config")
96
+ if val is not cached_config and callable(val):
97
+ return val()
98
+ return _cached_config()
99
+
100
+
101
+ def clear_config_cache() -> None:
102
+ """Drop the cached config."""
103
+ _cached_config.cache_clear()
104
+
105
+
106
+ __all__ = [
107
+ "AutopilotConfig",
108
+ "default_config_path",
109
+ "load_config",
110
+ "cached_config",
111
+ "clear_config_cache",
112
+ ]
@@ -0,0 +1,30 @@
1
+ """OS strategies for window focus and key injection."""
2
+
3
+ from gillm.focus.registry import (
4
+ get_os_strategy,
5
+ list_os_strategy_ids,
6
+ register_os_strategy,
7
+ resolve_active_os_strategy,
8
+ )
9
+ from gillm.focus.strategy import (
10
+ FocusOutcome,
11
+ KeySequence,
12
+ OsCapabilities,
13
+ OsStrategy,
14
+ StaticOsIdentityMixin,
15
+ )
16
+
17
+ # Auto-import strategies to register them
18
+ from gillm.focus import darwin, wayland, windows, x11
19
+
20
+ __all__ = [
21
+ "FocusOutcome",
22
+ "KeySequence",
23
+ "OsCapabilities",
24
+ "OsStrategy",
25
+ "StaticOsIdentityMixin",
26
+ "get_os_strategy",
27
+ "list_os_strategy_ids",
28
+ "register_os_strategy",
29
+ "resolve_active_os_strategy",
30
+ ]
gillm/focus/darwin.py ADDED
@@ -0,0 +1,72 @@
1
+ """macOS strategy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+
10
+ from gillm.focus.registry import register_os_strategy
11
+ from gillm.focus.strategy import (
12
+ FocusOutcome,
13
+ KeySequence,
14
+ OsCapabilities,
15
+ OsStrategy,
16
+ StaticOsIdentityMixin,
17
+ )
18
+
19
+
20
+ def _run(argv: list[str], *, timeout: float = 10.0) -> subprocess.CompletedProcess[str]:
21
+ return subprocess.run(
22
+ argv,
23
+ capture_output=True,
24
+ text=True,
25
+ check=False,
26
+ timeout=timeout,
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DarwinStrategy(StaticOsIdentityMixin, OsStrategy):
32
+ OS_ID = "darwin"
33
+ OS_LABEL = "macOS"
34
+
35
+ def matches_current_environment(self) -> bool:
36
+ return sys.platform == "darwin"
37
+
38
+ def capabilities(self) -> OsCapabilities:
39
+ osascript = shutil.which("osascript")
40
+ focus_methods = ("osascript",) if osascript else ()
41
+ keyboard_tool: str | None = None
42
+ if shutil.which("cliclick"):
43
+ keyboard_tool = "cliclick"
44
+ elif osascript:
45
+ keyboard_tool = "osascript"
46
+ return OsCapabilities(
47
+ can_focus_window=bool(osascript),
48
+ can_inject_keys=keyboard_tool is not None,
49
+ can_paste_clipboard=bool(shutil.which("pbpaste")),
50
+ focus_methods=focus_methods,
51
+ keyboard_tool=keyboard_tool,
52
+ )
53
+
54
+ def focus_window(self, window_name_hints: tuple[str, ...]) -> FocusOutcome:
55
+ if not shutil.which("osascript"):
56
+ return FocusOutcome(ok=False, detail="darwin: osascript not on PATH")
57
+ for hint in window_name_hints:
58
+ script = f'tell application "{hint}" to activate'
59
+ if _run(["osascript", "-e", script]).returncode == 0:
60
+ return FocusOutcome(ok=True, method="osascript")
61
+ return FocusOutcome(
62
+ ok=False,
63
+ detail="darwin: no application activated for the given hints",
64
+ )
65
+
66
+ def inject_keys(self, sequence: KeySequence) -> bool:
67
+ return False
68
+
69
+
70
+ register_os_strategy(DarwinStrategy())
71
+
72
+ __all__ = ["DarwinStrategy"]
@@ -0,0 +1,50 @@
1
+ """Registry + resolver for :class:`OsStrategy` implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gillm.focus.strategy import OsStrategy
6
+
7
+ _REGISTRY: list[OsStrategy] = []
8
+
9
+
10
+ def register_os_strategy(strategy: OsStrategy) -> None:
11
+ """Register ``strategy``."""
12
+ existing = [s for s in _REGISTRY if s.id == strategy.id]
13
+ if existing:
14
+ for old in existing:
15
+ _REGISTRY.remove(old)
16
+ _REGISTRY.append(strategy)
17
+
18
+
19
+ def get_os_strategy(strategy_id: str) -> OsStrategy | None:
20
+ """Return the strategy with ``id == strategy_id`` if registered."""
21
+ for strategy in _REGISTRY:
22
+ if strategy.id == strategy_id:
23
+ return strategy
24
+ return None
25
+
26
+
27
+ def list_os_strategy_ids() -> tuple[str, ...]:
28
+ """Return the ids of every registered strategy."""
29
+ return tuple(s.id for s in _REGISTRY)
30
+
31
+
32
+ def resolve_active_os_strategy() -> OsStrategy:
33
+ """Return the strategy whose ``matches_current_environment`` is true."""
34
+ for strategy in _REGISTRY:
35
+ if strategy.matches_current_environment():
36
+ return strategy
37
+ if not _REGISTRY:
38
+ raise RuntimeError(
39
+ "gillm.focus.registry: no OsStrategy registered; "
40
+ "did import of gillm.focus fail?"
41
+ )
42
+ return _REGISTRY[-1]
43
+
44
+
45
+ __all__ = [
46
+ "get_os_strategy",
47
+ "list_os_strategy_ids",
48
+ "register_os_strategy",
49
+ "resolve_active_os_strategy",
50
+ ]