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,15 @@
1
+ # PowerShell Skill
2
+
3
+ Use `powershell` for Windows automation. OpenCommand's `terminal` tool runs
4
+ `cmd.exe /c` on Windows; for PowerShell-specific cmdlets, wrap with
5
+ `powershell -NoProfile -Command "..."`.
6
+
7
+ ## Conventions
8
+ - Use `;` to chain and `&&` (PowerShell 7+) for conditional chains.
9
+ - Prefer `Get-ChildItem`, `Get-Content`, `Select-String` over `dir`/`type`.
10
+ - Manage Python via `uv` (Windows-supported): `uv add`, `uv run`.
11
+ - Services: `Get-Process`, `Start-Process -NoNewWindow`.
12
+ - Paths use backslashes or forward slashes; quote paths with spaces.
13
+
14
+ ## When to use
15
+ - Windows-only tooling, registry, WSL interop, native Windows builds.
@@ -0,0 +1,20 @@
1
+ # Python Skill
2
+
3
+ OpenCommand targets **Python 3.14** (free-threaded build) and uses **`uv`** for
4
+ all Python environment and task management.
5
+
6
+ ## Conventions
7
+ - Use `uv` everywhere: `uv add <pkg>`, `uv run <module>`, `uv sync`, `uv build`.
8
+ - Target Python >= 3.14; leverage free-threading for parallel worker I/O.
9
+ - Prefer `asyncio` + `TaskGroup` for concurrency; `subprocess` (not `os.popen`,
10
+ which is soft-deprecated in 3.14) for shelling out.
11
+ - Type hints required; `from __future__ import annotations` is fine (3.14
12
+ defers annotation evaluation).
13
+ - Use `pathlib.Path`, `logging`, and `dataclasses` over manual boilerplate.
14
+
15
+ ## Testing
16
+ - `uv run pytest` for unit tests; `uv run pytest -q` in CI.
17
+ - For interactive/UI testing, use the `desktop` tool (vision + pyautogui).
18
+
19
+ ## When to use
20
+ - Implementing agents, tools, providers, and the CLI/TUI in OpenCommand.
@@ -0,0 +1,38 @@
1
+ # System Skill
2
+
3
+ OpenCommand's `system` tool gives the swarm **situational awareness** and
4
+ **safety introspection** over the host machine. Use it for monitoring, scanning,
5
+ and scheduled automation.
6
+
7
+ ## `system` tool actions
8
+ - `list_processes` — snapshot of running processes (pid/name/cpu/mem). Use to
9
+ spot runaway agents, duplicate loops, or suspicious apps before/after actions.
10
+ - `scan_file <path> <rules>` — run YARA rules over a single file. `rules` is a
11
+ path to a `.yar` file or inline YARA rule text.
12
+ - `scan_directory <path> <rules>` — recursive YARA scan of a directory
13
+ (e.g. `Downloads`, build artifacts). `recursive` defaults to true.
14
+ - `watch <path> [seconds=10]` — use `watchdog` to report filesystem changes in
15
+ a directory (catches new downloads, temp files, agent writes).
16
+
17
+ > YARA is invoked via the `yara` CLI when present on PATH. If the binary is
18
+ > missing the tool reports it clearly instead of crashing. Install YARA
19
+ > (https://github.com/VirusTotal/yara) to enable scanning.
20
+
21
+ ## Cron utility (`modules/system/cron.py`)
22
+ Schedule recurring tasks without external services. A `CronScheduler` runs jobs
23
+ in the background (asyncio).
24
+
25
+ Schedule syntax:
26
+ - `every 30m` / `every 1h` — fixed interval
27
+ - `hourly :15` — every hour at minute 15
28
+ - `daily 09:00` — every day at 09:00 local time
29
+ - `*/5 * * * *` — full 5-field cron (requires optional `croniter`)
30
+
31
+ CLI:
32
+ - `opencommand cron add <name> <schedule> "<command>"` — register a shell job
33
+ - `opencommand cron status` — show scheduler info
34
+
35
+ ## When to use
36
+ - Pre-flight safety checks (list processes, scan a downloaded binary).
37
+ - Watching `Downloads` / build dirs for changes during long autonomous runs.
38
+ - Scheduling maintenance (e.g. `daily 03:00` cleanup, `every 1h` health ping).
@@ -0,0 +1,151 @@
1
+ """Automatic mode: the long-running, self-correcting loop.
2
+
3
+ Runs the Commander's pipeline for a goal, then verifies the result with a real
4
+ test command (exit-code gate, not string matching). On failure it re-dispatches
5
+ a focused fixer with the failing report as context, preserving the original goal
6
+ so the Commander never re-plans from a fix prompt. Loops until tests pass or
7
+ max iterations is hit. This is the main mode for large codebases and hours-long
8
+ autonomous runs.
9
+
10
+ Phase 6 adds two gates on top of the exit-code check:
11
+ * a Commander review gate (evaluator-optimizer) that can reject work even when
12
+ tests pass (wrong thing built, requirements missing);
13
+ * an optional live-verify hook (the `desktop` tool) for interactive validation
14
+ when the goal is UI/UX facing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import subprocess
20
+ from dataclasses import dataclass
21
+
22
+ from ...core.logging import get_logger
23
+ from ...swarm.agents.commander import Commander
24
+ from .notify import default_notifier
25
+
26
+ log = get_logger("system.automatic")
27
+
28
+
29
+ async def run_automatic(commander: Commander, goal: str, max_iterations: int = 5,
30
+ verify_cmd: str | None = None, pipeline: str = "standard",
31
+ live_verify: bool = False) -> str:
32
+ """Iteratively execute the goal, auto-fixing failures, until 100% met.
33
+
34
+ ``pipeline`` is "standard" or "advanced" (scaffold + research loop +
35
+ commander review gate + test/debug loop). ``live_verify`` runs the desktop
36
+ tool as a final interactive check when the goal is UI/UX facing.
37
+
38
+ The loop only returns success when BOTH the exit-code verify gate AND the
39
+ Commander review gate pass (and, if enabled, the live-verify check). If the
40
+ Commander flags the work as off-track, the whole pipeline is re-run with the
41
+ failing report as fix context so the swarm re-grounds on the goal.
42
+ """
43
+ original_goal = goal
44
+ if default_notifier.enabled:
45
+ default_notifier.send(f"Started autonomous run for: {goal[:120]}",
46
+ title="OpenCommand: started", priority=3,
47
+ tags=["rocket", "opencommand"])
48
+ last = ""
49
+ for i in range(max_iterations):
50
+ log.info("iteration=%d/%d goal=%r pipeline=%s", i + 1, max_iterations,
51
+ original_goal, pipeline)
52
+ last = await _execute(commander, original_goal, pipeline,
53
+ verify_cmd=verify_cmd, live_verify=live_verify)
54
+ # Exit-code gate (real verification, not string matching).
55
+ if verify_cmd:
56
+ rc, out = _verify(commander, verify_cmd)
57
+ if rc != 0:
58
+ last = (f"{last}\n\n[verify] {verify_cmd} -> FAIL (exit {rc})\n{out}")
59
+ log.warning("verify FAIL exit=%d iteration=%d", rc, i + 1)
60
+ else:
61
+ # Tests pass: now the Commander review gate (evaluator-optimizer).
62
+ review = await commander.review_goal(original_goal, last)
63
+ if review.passed and not review.off_track:
64
+ if live_verify:
65
+ lv = await _live_verify(commander, goal)
66
+ if not lv.ok:
67
+ last = (f"{last}\n\n[live-verify] FAIL: {lv.summary}\n"
68
+ f"{lv.detail}")
69
+ goal = _fix_prompt(original_goal, last)
70
+ continue
71
+ last = f"{last}\n\n[live-verify] PASS: {lv.summary}"
72
+ if default_notifier.enabled:
73
+ default_notifier.send(f"Goal complete after {i + 1} iteration(s).",
74
+ title="OpenCommand: success", priority=4,
75
+ tags=["white_check_mark", "opencommand"])
76
+ log.info("goal complete after %d iteration(s)", i + 1)
77
+ return f"{last}\n\n[verify] {verify_cmd} -> PASS (exit 0)"
78
+ # Tests pass but Commander says wrong thing built / off track.
79
+ reason = "OFF TRACK" if review.off_track else "REJECTED"
80
+ last = (f"{last}\n\n[review] {reason}: {review.feedback}\n"
81
+ f"missing: {', '.join(review.missing)}")
82
+ log.warning("commander %s iteration=%d missing=%s", reason.lower(),
83
+ i + 1, review.missing)
84
+ else:
85
+ # No verification command: fall back to the old string heuristic.
86
+ if "FAILED" not in last and "ERROR" not in last:
87
+ if default_notifier.enabled:
88
+ default_notifier.send(f"Goal complete after {i + 1} iteration(s).",
89
+ title="OpenCommand: success", priority=4,
90
+ tags=["white_check_mark", "opencommand"])
91
+ return last
92
+ goal = _fix_prompt(original_goal, last)
93
+ if default_notifier.enabled:
94
+ default_notifier.send(f"Stopped after {max_iterations} iterations (not resolved).",
95
+ title="OpenCommand: stopped", priority=5,
96
+ tags=["warning", "opencommand"])
97
+ return f"(automatic mode stopped after {max_iterations} iterations)\n{last}"
98
+
99
+
100
+ async def _execute(commander: Commander, goal: str, pipeline: str,
101
+ verify_cmd: str | None = None, live_verify: bool = False) -> str:
102
+ if pipeline == "advanced":
103
+ return await commander.execute_advanced(
104
+ goal, verify_cmd=verify_cmd, live_verify=live_verify)
105
+ return await commander.execute(goal)
106
+
107
+
108
+ def _fix_prompt(original_goal: str, last: str) -> str:
109
+ return (f"Previous attempt failed. The original goal is still:\n{original_goal}\n\n"
110
+ f"Fix the issues shown below, then re-run the verification command.\n\n{last}")
111
+
112
+
113
+ async def _live_verify(commander: Commander, goal: str) -> "LiveVerifyResult":
114
+ """Interactive validation via the desktop tool (vision + action loop).
115
+
116
+ Returns a LiveVerifyResult. Only meaningful when the desktop tool is wired
117
+ and vision is enabled; otherwise reports a no-op pass so the pipeline can
118
+ continue without a UI target.
119
+ """
120
+ from ...modules.tools.desktop import DesktopTool
121
+
122
+ desktop = next((t for t in commander.tools._tools.values()
123
+ if isinstance(t, DesktopTool)), None)
124
+ if desktop is None or not getattr(desktop, "vision", None):
125
+ return LiveVerifyResult(ok=True, summary="no desktop tool/vision; skipped",
126
+ detail="")
127
+ try:
128
+ out = desktop.run(f"Verify the goal is satisfied on screen: {goal}", steps=8)
129
+ except Exception as e: # noqa: BLE001
130
+ return LiveVerifyResult(ok=False, summary="desktop tool error",
131
+ detail=str(e))
132
+ ok = "ERROR" not in out and "done" in out.lower()
133
+ return LiveVerifyResult(ok=ok, summary="interactive check completed", detail=out)
134
+
135
+
136
+ @dataclass
137
+ class LiveVerifyResult:
138
+ ok: bool = False
139
+ summary: str = ""
140
+ detail: str = ""
141
+
142
+
143
+ def _verify(commander: Commander, cmd: str) -> tuple[int, str]:
144
+ """Run the verification command in the workspace; return (rc, output)."""
145
+ try:
146
+ proc = subprocess.run(cmd, shell=True, cwd=str(commander.workspace),
147
+ capture_output=True, text=True, timeout=600)
148
+ except subprocess.TimeoutExpired as e:
149
+ return 124, f"verify timed out: {e}"
150
+ out = f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}"[:4000]
151
+ return proc.returncode, out
@@ -0,0 +1,193 @@
1
+ """Lightweight cron utility for OpenCommand.
2
+
3
+ Schedules tasks at configurable times without external services. A single
4
+ CronScheduler runs in the background (asyncio) and fires jobs when their
5
+ schedule matches. Supported syntax:
6
+ - "every 30m" / "every 1h" fixed interval
7
+ - "hourly :15" every hour at minute 15
8
+ - "daily 09:00" every day at 09:00 local time
9
+ - "*/5 * * * *" 5-field cron (requires optional croniter)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timedelta
19
+ from pathlib import Path
20
+ from typing import Awaitable, Callable
21
+
22
+ JobFn = Callable[[], Awaitable[None] | None]
23
+
24
+
25
+ @dataclass
26
+ class CronJob:
27
+ name: str
28
+ schedule: str
29
+ fn: JobFn
30
+ command: str = "" # shell command (serializable)
31
+ goal: str = "" # swarm goal (serializable); takes precedence over command
32
+ last_run: datetime | None = None
33
+ _interval: timedelta | None = field(default=None, repr=False)
34
+ _daily_at: tuple[int, int] | None = field(default=None, repr=False)
35
+ _croniter = None # optional croniter object
36
+
37
+ def __post_init__(self) -> None:
38
+ self._parse()
39
+
40
+ def _parse(self) -> None:
41
+ s = self.schedule.strip().lower()
42
+ m = re.fullmatch(r"every\s+(\d+)\s*([mh])", s)
43
+ if m:
44
+ n = int(m.group(1))
45
+ self._interval = timedelta(minutes=n) if m.group(2) == "m" else timedelta(hours=n)
46
+ return
47
+ m = re.fullmatch(r"hourly\s*:(\d{1,2})", s)
48
+ if m:
49
+ self._daily_at = (0, int(m.group(1))) # sentinel: hourly at minute
50
+ return
51
+ m = re.fullmatch(r"daily\s*(\d{1,2}):(\d{2})", s)
52
+ if m:
53
+ self._daily_at = (int(m.group(1)), int(m.group(2)))
54
+ return
55
+ try:
56
+ from croniter import croniter # type: ignore
57
+ self._croniter = croniter(s, datetime.now())
58
+ except Exception:
59
+ raise ValueError(f"Unsupported cron schedule: {self.schedule!r}")
60
+
61
+ def next_run(self, now: datetime) -> datetime:
62
+ if self._interval is not None:
63
+ return (self.last_run or now) + self._interval
64
+ if self._daily_at is not None:
65
+ hh, mm = self._daily_at
66
+ if hh == 0: # hourly at minute mm
67
+ nxt = now.replace(minute=mm, second=0, microsecond=0)
68
+ if nxt <= now:
69
+ nxt += timedelta(hours=1)
70
+ return nxt
71
+ nxt = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
72
+ if nxt <= now:
73
+ nxt += timedelta(days=1)
74
+ return nxt
75
+ if self._croniter is not None:
76
+ # get_next() advances the iterator, so compute a non-advancing preview
77
+ # for status/display; the loop uses _next_fire() which advances once.
78
+ return self._croniter.get_next(datetime, start_time=now)
79
+ raise ValueError("no schedule parsed")
80
+
81
+ def _next_fire(self, now: datetime) -> datetime:
82
+ """Advance the croniter to the next fire time (mutates the iterator)."""
83
+ if self._croniter is not None:
84
+ return self._croniter.get_next(datetime)
85
+ return self.next_run(now)
86
+
87
+ async def _call(self) -> None:
88
+ result = self.fn()
89
+ if asyncio.iscoroutine(result):
90
+ await result
91
+
92
+
93
+ class CronScheduler:
94
+ """Runs registered jobs in the background until stopped.
95
+
96
+ Persisted jobs are either shell ``command``s or swarm ``goal``s. A goal job
97
+ is dispatched through a callback (set via ``on_goal``) so the scheduler can
98
+ drive the real Commander/Worker loop instead of just a subprocess.
99
+ """
100
+
101
+ def __init__(self, tick: float = 15.0) -> None:
102
+ self._jobs: list[CronJob] = []
103
+ self._tick = tick
104
+ self._task: asyncio.Task | None = None
105
+ self._stop = asyncio.Event()
106
+ self.on_goal: Callable[[str], Awaitable[None]] | None = None
107
+
108
+ def add(self, name: str, schedule: str, fn: JobFn) -> CronJob:
109
+ job = CronJob(name=name, schedule=schedule, fn=fn)
110
+ self._jobs.append(job)
111
+ return job
112
+
113
+ def schedule_command(self, name: str, schedule: str, command: str) -> CronJob:
114
+ """Schedule a shell command (runs via subprocess)."""
115
+ async def _run() -> None:
116
+ proc = await asyncio.create_subprocess_shell(
117
+ command, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
118
+ await proc.wait()
119
+ job = self.add(name, schedule, _run)
120
+ job.command = command
121
+ return job
122
+
123
+ def schedule_goal(self, name: str, schedule: str, goal: str) -> CronJob:
124
+ """Schedule a swarm goal; dispatched through ``on_goal`` when fired."""
125
+ async def _run() -> None:
126
+ if self.on_goal is not None:
127
+ res = self.on_goal(goal)
128
+ if asyncio.iscoroutine(res):
129
+ await res
130
+ job = self.add(name, schedule, _run)
131
+ job.goal = goal
132
+ return job
133
+
134
+ def save(self, path: Path) -> None:
135
+ """Persist serializable jobs to <workspace>/.opencommand/cron.json."""
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ data = [{"name": j.name, "schedule": j.schedule,
138
+ "command": j.command, "goal": j.goal}
139
+ for j in self._jobs if j.command or j.goal]
140
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
141
+
142
+ @classmethod
143
+ def load(cls, path: Path) -> "CronScheduler":
144
+ """Load persisted jobs from cron.json (if present)."""
145
+ sched = cls()
146
+ if not path.exists():
147
+ return sched
148
+ try:
149
+ data = json.loads(path.read_text(encoding="utf-8"))
150
+ except Exception:
151
+ return sched
152
+ for item in data:
153
+ if item.get("goal"):
154
+ sched.schedule_goal(item["name"], item["schedule"], item["goal"])
155
+ else:
156
+ sched.schedule_command(item["name"], item["schedule"], item.get("command", ""))
157
+ return sched
158
+
159
+ def start(self) -> None:
160
+ if self._task is None or self._task.done():
161
+ self._stop.clear()
162
+ self._task = asyncio.create_task(self._loop())
163
+
164
+ async def _loop(self) -> None:
165
+ while not self._stop.is_set():
166
+ now = datetime.now()
167
+ for job in self._jobs:
168
+ try:
169
+ if now >= job._next_fire(now):
170
+ job.last_run = now
171
+ await job._call()
172
+ except Exception: # noqa: BLE001 - a bad job shouldn't kill the loop
173
+ pass
174
+ try:
175
+ await asyncio.wait_for(self._stop.wait(), timeout=self._tick)
176
+ except asyncio.TimeoutError:
177
+ continue
178
+
179
+ def stop(self) -> None:
180
+ self._stop.set()
181
+
182
+ def status(self) -> str:
183
+ now = datetime.now()
184
+ lines = [f"Cron jobs ({len(self._jobs)}):"]
185
+ for j in self._jobs:
186
+ try:
187
+ nxt = j.next_run(now).strftime("%Y-%m-%d %H:%M:%S")
188
+ except Exception:
189
+ nxt = "?"
190
+ last = j.last_run.strftime("%Y-%m-%d %H:%M:%S") if j.last_run else "never"
191
+ kind = "goal" if j.goal else ("cmd" if j.command else "fn")
192
+ lines.append(f" - {j.name} [{j.schedule}] ({kind}) next={nxt} last={last}")
193
+ return "\n".join(lines)
@@ -0,0 +1,85 @@
1
+ """ntfy push notifications for OpenCommand.
2
+
3
+ Lets the swarm push status / completion / failure alerts to an ntfy topic so
4
+ long-running autonomous runs can be monitored from a phone or another machine.
5
+ Uses the ntfy package with a requests POST fallback. Configure via env vars
6
+ (no secrets in code):
7
+ NTFY_TOPIC e.g. https://ntfy.sh/my-secret-topic (required to send)
8
+ NTFY_TOKEN optional bearer token for private servers
9
+ NTFY_PRIORITY optional 1..5 (default 3)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+
17
+ try:
18
+ from ntfy import notify # type: ignore
19
+ _HAVE_NTFY = True
20
+ except Exception: # pragma: no cover - ntfy is a hard dep but keep import soft
21
+ notify = None # type: ignore
22
+ _HAVE_NTFY = False
23
+
24
+
25
+ @dataclass
26
+ class Notifier:
27
+ """Thin wrapper around ntfy with env-var configuration."""
28
+
29
+ topic: str | None = None
30
+ token: str | None = None
31
+ priority: int = 3
32
+ default_tags: list[str] = field(default_factory=lambda: ["robot"])
33
+
34
+ def __post_init__(self) -> None:
35
+ self.topic = self.topic or os.environ.get("NTFY_TOPIC")
36
+ self.token = self.token or os.environ.get("NTFY_TOKEN")
37
+ if os.environ.get("NTFY_PRIORITY"):
38
+ try:
39
+ self.priority = int(os.environ["NTFY_PRIORITY"])
40
+ except ValueError:
41
+ pass
42
+
43
+ @property
44
+ def enabled(self) -> bool:
45
+ return bool(self.topic)
46
+
47
+ def send(self, message: str, *, title: str | None = None,
48
+ priority: int | None = None, tags: list[str] | None = None) -> bool:
49
+ """Send a notification. Returns True if sent, False if disabled/unavailable."""
50
+ if not self.topic:
51
+ return False
52
+ prio = priority or self.priority
53
+ all_tags = (tags or []) + self.default_tags
54
+ if _HAVE_NTFY and notify is not None:
55
+ try:
56
+ notify(message=message, title=title or "OpenCommand", topic=self.topic,
57
+ priority=prio, tags=all_tags, auth=self.token)
58
+ return True
59
+ except Exception:
60
+ pass # fall through to requests fallback
61
+ return self._send_requests(message, title=title, priority=prio, tags=all_tags)
62
+
63
+ def _send_requests(self, message: str, *, title: str | None,
64
+ priority: int, tags: list[str]) -> bool:
65
+ try:
66
+ import requests
67
+ except Exception:
68
+ return False
69
+ headers = {
70
+ "Title": (title or "OpenCommand").replace("\n", " "),
71
+ "Priority": str(priority),
72
+ "Tags": ",".join(tags),
73
+ }
74
+ if self.token:
75
+ headers["Authorization"] = f"Bearer {self.token}"
76
+ try:
77
+ resp = requests.post(self.topic, data=message.encode("utf-8"),
78
+ headers=headers, timeout=15)
79
+ return resp.ok
80
+ except Exception:
81
+ return False
82
+
83
+
84
+ # Module-level default notifier (configured from env). Import and call .send().
85
+ default_notifier = Notifier()