pascal-agent 0.3.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.
pascal/tools.py ADDED
@@ -0,0 +1,672 @@
1
+ """Built-in tools for Pascal -- file, desktop, MCP.
2
+
3
+ Tool hierarchy:
4
+ 1. MCP tools -- API-level access to business tools (Slack, Gmail, GitHub...)
5
+ 2. File tools -- local filesystem (always available)
6
+ 3. Shell -- terminal commands via sandbox (in loop.py, not here)
7
+ 4. Desktop -- screenshot + coordinate click (pyautogui, last resort)
8
+
9
+ Visual tools return ContentBlock attachments → loop.py passes to LLM → LLM sees the screen.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import io
16
+ import logging
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from pascal.types import ContentBlock
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # ── Workspace boundary ──────────────────────────────────────────
25
+
26
+ _workspace: str = ""
27
+
28
+
29
+ def set_workspace(path: str) -> None:
30
+ """Set the workspace root. File tools are restricted to this directory."""
31
+ global _workspace
32
+ _workspace = str(Path(path).resolve())
33
+
34
+
35
+ def _check_path(path: str) -> str | None:
36
+ """Return error message if path is outside workspace. None if OK."""
37
+ if not _workspace:
38
+ return None # no restriction if workspace not set
39
+ try:
40
+ resolved = Path(path).resolve()
41
+ workspace = Path(_workspace).resolve()
42
+ if resolved == workspace or resolved.is_relative_to(workspace):
43
+ return None
44
+ return f"Path '{path}' is outside workspace '{_workspace}'"
45
+ except Exception as e:
46
+ return f"Invalid path: {e}"
47
+
48
+
49
+ # ── Visual helpers ──────────────────────────────────────────────
50
+
51
+ def _capture_screenshot(region: dict | None = None) -> ContentBlock | None:
52
+ """Capture screen as base64 PNG at native resolution.
53
+
54
+ Full resolution (1920x1080) preserves detail for accurate element identification.
55
+ Optional region param for zoom: {"x": 100, "y": 100, "w": 400, "h": 300}
56
+ """
57
+ try:
58
+ import pyautogui
59
+ if region:
60
+ img = pyautogui.screenshot(region=(region["x"], region["y"], region["w"], region["h"]))
61
+ else:
62
+ img = pyautogui.screenshot()
63
+ buf = io.BytesIO()
64
+ img.save(buf, format="PNG")
65
+ return ContentBlock(
66
+ type="image",
67
+ data=base64.b64encode(buf.getvalue()).decode(),
68
+ mime_type="image/png",
69
+ )
70
+ except Exception:
71
+ return None
72
+
73
+
74
+ def _image_hash(block: ContentBlock | None) -> str:
75
+ """Fast hash of image data for noop detection. No LLM call."""
76
+ if block is None or not block.data:
77
+ return ""
78
+ import hashlib
79
+ return hashlib.md5(block.data.encode()[:8192]).hexdigest()
80
+
81
+
82
+ def _check_noop(before_hash: str, after: ContentBlock | None) -> str:
83
+ """Return warning string if screen unchanged after GUI action, else ''."""
84
+ if not before_hash or after is None:
85
+ return ""
86
+ if before_hash == _image_hash(after):
87
+ return " [NOOP: screen unchanged — action may have had no effect]"
88
+ return ""
89
+
90
+
91
+ def _paste(text: str) -> None:
92
+ try:
93
+ import pyperclip
94
+ import pyautogui
95
+ pyperclip.copy(text)
96
+ pyautogui.hotkey("ctrl", "v")
97
+ except ImportError:
98
+ pass
99
+
100
+
101
+ # ── File tools (always available) ───────────────────────────────
102
+
103
+ async def read_file(params: dict[str, Any]) -> dict[str, Any]:
104
+ path = params.get("path", "")
105
+ if not path:
106
+ return {"ok": False, "output": "", "error": "path is required"}
107
+ boundary_err = _check_path(path)
108
+ if boundary_err:
109
+ return {"ok": False, "output": "", "error": boundary_err}
110
+ try:
111
+ content = Path(path).read_text(encoding="utf-8")
112
+ return {"ok": True, "output": content[:10000], "error": ""}
113
+ except Exception as e:
114
+ return {"ok": False, "output": "", "error": str(e)}
115
+
116
+
117
+ async def write_file(params: dict[str, Any]) -> dict[str, Any]:
118
+ path = params.get("path", "")
119
+ content = params.get("content", "")
120
+ if not path:
121
+ return {"ok": False, "output": "", "error": "path is required"}
122
+ boundary_err = _check_path(path)
123
+ if boundary_err:
124
+ return {"ok": False, "output": "", "error": boundary_err}
125
+ try:
126
+ p = Path(path)
127
+ p.parent.mkdir(parents=True, exist_ok=True)
128
+ p.write_text(content, encoding="utf-8")
129
+ return {"ok": True, "output": f"Wrote {len(content)} chars to {path}", "error": ""}
130
+ except Exception as e:
131
+ return {"ok": False, "output": "", "error": str(e)}
132
+
133
+
134
+ async def list_dir(params: dict[str, Any]) -> dict[str, Any]:
135
+ path = params.get("path", ".")
136
+ boundary_err = _check_path(path)
137
+ if boundary_err:
138
+ return {"ok": False, "output": "", "error": boundary_err}
139
+ try:
140
+ entries = sorted(Path(path).iterdir())
141
+ lines = []
142
+ for e in entries[:100]:
143
+ prefix = "d " if e.is_dir() else "f "
144
+ lines.append(f"{prefix}{e.name}")
145
+ return {"ok": True, "output": "\n".join(lines), "error": ""}
146
+ except Exception as e:
147
+ return {"ok": False, "output": "", "error": str(e)}
148
+
149
+
150
+ # ── Desktop tools (optional -- pyautogui) ────────────────────────
151
+
152
+ async def screenshot(params: dict[str, Any]) -> dict[str, Any]:
153
+ """Take screenshot. Optional region param for zoom: {"x":100,"y":100,"w":400,"h":300}."""
154
+ region = params.get("region")
155
+ attachment = _capture_screenshot(region=region)
156
+ if attachment is None:
157
+ return {"ok": False, "output": "", "error": "pyautogui not installed or screenshot failed"}
158
+ zoom_info = f" (zoomed: {region})" if region else " (full screen, native resolution)"
159
+ return {"ok": True, "output": f"Screenshot captured{zoom_info}", "error": "", "attachment": attachment}
160
+
161
+
162
+ async def click(params: dict[str, Any]) -> dict[str, Any]:
163
+ """Click at screen pixel coordinates."""
164
+ try:
165
+ import pyautogui
166
+ before_hash = _image_hash(_capture_screenshot())
167
+ x = int(params.get("x", 0))
168
+ y = int(params.get("y", 0))
169
+ button = str(params.get("button", "left"))
170
+ if button == "double":
171
+ pyautogui.click(x, y, button="left", clicks=2)
172
+ else:
173
+ pyautogui.click(x, y, button=button)
174
+ after = _capture_screenshot()
175
+ noop = _check_noop(before_hash, after)
176
+ return {"ok": True, "output": f"Clicked ({x}, {y}) {button}{noop}", "error": "",
177
+ "attachment": after}
178
+ except ImportError:
179
+ return {"ok": False, "output": "", "error": "pyautogui not installed"}
180
+ except Exception as e:
181
+ return {"ok": False, "output": "", "error": str(e)}
182
+
183
+
184
+ async def type_text(params: dict[str, Any]) -> dict[str, Any]:
185
+ try:
186
+ import pyautogui
187
+ before_hash = _image_hash(_capture_screenshot())
188
+ text = params.get("text", "")
189
+ if text.isascii():
190
+ pyautogui.typewrite(text, interval=0.02)
191
+ else:
192
+ _paste(text)
193
+ after = _capture_screenshot()
194
+ noop = _check_noop(before_hash, after)
195
+ return {"ok": True, "output": f"Typed {len(text)} chars{noop}", "error": "",
196
+ "attachment": after}
197
+ except ImportError:
198
+ return {"ok": False, "output": "", "error": "pyautogui not installed"}
199
+ except Exception as e:
200
+ return {"ok": False, "output": "", "error": str(e)}
201
+
202
+
203
+ async def hotkey(params: dict[str, Any]) -> dict[str, Any]:
204
+ try:
205
+ import pyautogui
206
+ before_hash = _image_hash(_capture_screenshot())
207
+ keys = params.get("keys", [])
208
+ if isinstance(keys, str):
209
+ keys = keys.split("+")
210
+ pyautogui.hotkey(*keys)
211
+ after = _capture_screenshot()
212
+ noop = _check_noop(before_hash, after)
213
+ return {"ok": True, "output": f"Pressed {'+'.join(keys)}{noop}", "error": "",
214
+ "attachment": after}
215
+ except ImportError:
216
+ return {"ok": False, "output": "", "error": "pyautogui not installed"}
217
+ except Exception as e:
218
+ return {"ok": False, "output": "", "error": str(e)}
219
+
220
+
221
+ async def scroll(params: dict[str, Any]) -> dict[str, Any]:
222
+ """Scroll at screen pixel coordinates."""
223
+ try:
224
+ import pyautogui
225
+ before_hash = _image_hash(_capture_screenshot())
226
+ clicks = int(params.get("clicks", -3))
227
+ x = params.get("x")
228
+ y = params.get("y")
229
+ if x is not None and y is not None:
230
+ pyautogui.scroll(clicks, int(x), int(y))
231
+ else:
232
+ pyautogui.scroll(clicks)
233
+ after = _capture_screenshot()
234
+ noop = _check_noop(before_hash, after)
235
+ return {"ok": True, "output": f"Scrolled {clicks}{noop}", "error": "",
236
+ "attachment": after}
237
+ except ImportError:
238
+ return {"ok": False, "output": "", "error": "pyautogui not installed"}
239
+ except Exception as e:
240
+ return {"ok": False, "output": "", "error": str(e)}
241
+
242
+
243
+ # ── Skill tool ─────────────────────────────────────────────────
244
+
245
+ async def skill(params: dict[str, Any]) -> dict[str, Any]:
246
+ """Load and return a skill's instructions from ~/.pascal/skills/<name>.md."""
247
+ name = params.get("name", "").strip()
248
+ if not name:
249
+ return {"ok": False, "output": "", "error": "skill name is required"}
250
+ skill_path = Path.home() / ".pascal" / "skills" / f"{name}.md"
251
+ if not skill_path.exists():
252
+ # Try matching by frontmatter name
253
+ skills_dir = Path.home() / ".pascal" / "skills"
254
+ if skills_dir.is_dir():
255
+ for p in skills_dir.glob("*.md"):
256
+ try:
257
+ text = p.read_text(encoding="utf-8")
258
+ if text.startswith("---"):
259
+ end = text.index("---", 3)
260
+ if f"name: {name}" in text[3:end] or f'name: "{name}"' in text[3:end]:
261
+ skill_path = p
262
+ break
263
+ except Exception:
264
+ continue
265
+ if not skill_path.exists():
266
+ return {"ok": False, "output": "", "error": f"Skill '{name}' not found in ~/.pascal/skills/"}
267
+ try:
268
+ content = skill_path.read_text(encoding="utf-8")
269
+ # Strip YAML frontmatter, return the body as instructions
270
+ if content.startswith("---"):
271
+ try:
272
+ end = content.index("---", 3)
273
+ body = content[end + 3:].strip()
274
+ except ValueError:
275
+ body = content
276
+ else:
277
+ body = content
278
+ return {"ok": True, "output": body[:5000], "error": ""}
279
+ except Exception as e:
280
+ return {"ok": False, "output": "", "error": str(e)}
281
+
282
+
283
+ # ── Channel tools (daemon mode only) ──────────────────────────────
284
+
285
+ _channel_bot = None
286
+ _owner_chat_id: int | None = None
287
+ _approval_callback = None
288
+ _channel_store = None
289
+ _reply_timestamps: list[float] = [] # Rate limiting for channel_reply
290
+ _REPLY_RATE_LIMIT = 10 # max messages per window
291
+ _REPLY_RATE_WINDOW = 60.0 # seconds
292
+
293
+
294
+ def set_channel_bot(bot, owner_chat_id: int, store=None) -> None:
295
+ """Inject Telegram bot instance + store. Called by daemon.py at startup."""
296
+ global _channel_bot, _owner_chat_id, _channel_store
297
+ _channel_bot = bot
298
+ _owner_chat_id = owner_chat_id
299
+ _channel_store = store
300
+
301
+
302
+ def set_approval_callback(fn) -> None:
303
+ """Inject approval request function. Called by daemon.py at startup."""
304
+ global _approval_callback
305
+ _approval_callback = fn
306
+
307
+
308
+ def get_channel_bot():
309
+ """Public accessor for channel bot instance."""
310
+ return _channel_bot
311
+
312
+
313
+ def get_owner_chat_id() -> int | None:
314
+ """Public accessor for owner chat ID."""
315
+ return _owner_chat_id
316
+
317
+
318
+ def get_approval_callback():
319
+ """Public accessor for approval callback."""
320
+ return _approval_callback
321
+
322
+
323
+ async def channel_reply(params: dict[str, Any]) -> dict[str, Any]:
324
+ """Send a message via the connected channel (Telegram)."""
325
+ if _channel_bot is None:
326
+ return {"ok": False, "output": "", "error": "No channel configured (not in daemon mode)"}
327
+
328
+ # Rate limiting: prevent LLM from spamming the channel
329
+ import time
330
+ now = time.time()
331
+ cutoff = now - _REPLY_RATE_WINDOW
332
+ _reply_timestamps[:] = [t for t in _reply_timestamps if t > cutoff]
333
+ if len(_reply_timestamps) >= _REPLY_RATE_LIMIT:
334
+ return {"ok": False, "output": "", "error": f"Rate limited: max {_REPLY_RATE_LIMIT} messages per {_REPLY_RATE_WINDOW:.0f}s"}
335
+ _reply_timestamps.append(now)
336
+
337
+ text = params.get("text", "")
338
+ chat_id = _owner_chat_id or 0
339
+ # Ignore LLM-provided chat_id to prevent sending to arbitrary users
340
+ if not text:
341
+ return {"ok": False, "output": "", "error": "text is required"}
342
+ if not chat_id:
343
+ return {"ok": False, "output": "", "error": "no chat_id and no owner configured"}
344
+ try:
345
+ await _channel_bot.send_message(chat_id=chat_id, text=text)
346
+ # Record in conversation history via injected store (if available)
347
+ if _channel_store is not None:
348
+ try:
349
+ _channel_store.push_conversation_turn("telegram", "assistant", text)
350
+ except Exception:
351
+ pass
352
+ return {"ok": True, "output": f"Sent to {chat_id}", "error": ""}
353
+ except Exception as e:
354
+ return {"ok": False, "output": "", "error": str(e)}
355
+
356
+
357
+ # ── UIA tools (Windows native app control) ─────────────────────
358
+
359
+ _uia_adapter = None
360
+
361
+
362
+ def _get_uia():
363
+ global _uia_adapter
364
+ if _uia_adapter is None:
365
+ from pascal.uia import UIAutomationAdapter
366
+ _uia_adapter = UIAutomationAdapter()
367
+ return _uia_adapter
368
+
369
+
370
+ def _uia_not_available(tool_name: str) -> dict[str, Any]:
371
+ return {"ok": False, "output": "", "error": f"{tool_name} requires Windows with pywinauto. Use screenshot tool instead."}
372
+
373
+
374
+ async def uia_snapshot(params: dict[str, Any]) -> dict[str, Any]:
375
+ """Accessibility tree snapshot of a window."""
376
+ try:
377
+ from pascal.uia import render_snapshot
378
+ uia = _get_uia()
379
+ except ImportError:
380
+ return _uia_not_available("uia_snapshot")
381
+ window_title = params.get("window_title")
382
+ max_depth = int(params.get("max_depth", 3))
383
+ interactive_only = bool(params.get("interactive_only", False))
384
+ try:
385
+ elements = uia.snapshot(
386
+ window_title=window_title,
387
+ max_depth=max_depth,
388
+ interactive_only=interactive_only,
389
+ )
390
+ if not elements:
391
+ return {"ok": False, "output": "", "error": f"No window found or empty tree (title={window_title})"}
392
+ text = render_snapshot(elements, window_title=window_title or "")
393
+ return {"ok": True, "output": text, "error": ""}
394
+ except Exception as e:
395
+ return {"ok": False, "output": "", "error": str(e)}
396
+
397
+
398
+ async def uia_click(params: dict[str, Any]) -> dict[str, Any]:
399
+ """Click a control by ref ID."""
400
+ try:
401
+ uia = _get_uia()
402
+ except ImportError:
403
+ return _uia_not_available("uia_click")
404
+ ref = str(params.get("ref", "")).strip()
405
+ if not ref:
406
+ return {"ok": False, "output": "", "error": "ref is required"}
407
+ try:
408
+ result = uia.click(ref)
409
+ return {"ok": result.get("ok", False),
410
+ "output": f"Clicked {ref}" if result.get("ok") else "",
411
+ "error": result.get("error", "")}
412
+ except KeyError as e:
413
+ return {"ok": False, "output": "", "error": str(e)}
414
+
415
+
416
+ async def uia_type(params: dict[str, Any]) -> dict[str, Any]:
417
+ """Type text into an Edit control by ref ID."""
418
+ try:
419
+ uia = _get_uia()
420
+ except ImportError:
421
+ return _uia_not_available("uia_type")
422
+ ref = str(params.get("ref", "")).strip()
423
+ text = str(params.get("text", ""))
424
+ if not ref:
425
+ return {"ok": False, "output": "", "error": "ref is required"}
426
+ if not text:
427
+ return {"ok": False, "output": "", "error": "text is required"}
428
+ clear_first = bool(params.get("clear_first", False))
429
+ try:
430
+ result = uia.type_text(ref, text, clear_first=clear_first)
431
+ return {"ok": result.get("ok", False),
432
+ "output": f"Typed {len(text)} chars into {ref}" if result.get("ok") else "",
433
+ "error": result.get("error", "")}
434
+ except KeyError as e:
435
+ return {"ok": False, "output": "", "error": str(e)}
436
+
437
+
438
+ async def uia_get_text(params: dict[str, Any]) -> dict[str, Any]:
439
+ """Read text from a control by ref ID."""
440
+ try:
441
+ uia = _get_uia()
442
+ except ImportError:
443
+ return _uia_not_available("uia_get_text")
444
+ ref = str(params.get("ref", "")).strip()
445
+ if not ref:
446
+ return {"ok": False, "output": "", "error": "ref is required"}
447
+ try:
448
+ text = uia.get_text(ref)
449
+ return {"ok": True, "output": text, "error": ""}
450
+ except KeyError as e:
451
+ return {"ok": False, "output": "", "error": str(e)}
452
+
453
+
454
+ async def uia_find(params: dict[str, Any]) -> dict[str, Any]:
455
+ """Search for controls by name or type."""
456
+ try:
457
+ uia = _get_uia()
458
+ except ImportError:
459
+ return _uia_not_available("uia_find")
460
+ name = params.get("name")
461
+ control_type = params.get("control_type")
462
+ window_title = params.get("window_title")
463
+ try:
464
+ elements = uia.find(
465
+ name=name, control_type=control_type,
466
+ window_title=window_title,
467
+ )
468
+ if not elements:
469
+ return {"ok": True, "output": "No matching controls found.", "error": ""}
470
+ lines = [e.to_text() for e in elements]
471
+ return {"ok": True, "output": "\n".join(lines), "error": ""}
472
+ except Exception as e:
473
+ return {"ok": False, "output": "", "error": str(e)}
474
+
475
+
476
+ async def uia_wait(params: dict[str, Any]) -> dict[str, Any]:
477
+ """Wait for a control to appear."""
478
+ try:
479
+ uia = _get_uia()
480
+ except ImportError:
481
+ return _uia_not_available("uia_wait")
482
+ name = params.get("name")
483
+ control_type = params.get("control_type")
484
+ window_title = params.get("window_title")
485
+ timeout = float(params.get("timeout", 10.0))
486
+ timeout = min(timeout, 30.0) # cap at 30s
487
+ try:
488
+ element = uia.wait_for(
489
+ name=name, control_type=control_type,
490
+ window_title=window_title, timeout=timeout,
491
+ )
492
+ if element is None:
493
+ return {"ok": False, "output": "", "error": f"Timeout ({timeout}s): control not found"}
494
+ return {"ok": True, "output": element.to_text(), "error": ""}
495
+ except Exception as e:
496
+ return {"ok": False, "output": "", "error": str(e)}
497
+
498
+
499
+ async def window_focus(params: dict[str, Any]) -> dict[str, Any]:
500
+ """Bring a window to the foreground."""
501
+ try:
502
+ uia = _get_uia()
503
+ except ImportError:
504
+ return _uia_not_available("window_focus")
505
+ title = params.get("title")
506
+ try:
507
+ result = uia.focus_window(window_title=title)
508
+ return {"ok": result.get("ok", False),
509
+ "output": f"Focused: {result.get('title', '')}" if result.get("ok") else "",
510
+ "error": result.get("error", "")}
511
+ except Exception as e:
512
+ return {"ok": False, "output": "", "error": str(e)}
513
+
514
+
515
+ async def clipboard_get(params: dict[str, Any]) -> dict[str, Any]:
516
+ """Read text from the system clipboard."""
517
+ try:
518
+ from pascal import clipboard as clipboard_utils
519
+ text = clipboard_utils.get_text()
520
+ return {"ok": True, "output": text[:10000], "error": ""}
521
+ except ImportError:
522
+ return {"ok": False, "output": "", "error": "clipboard_get requires pywin32 or pyperclip"}
523
+ except Exception as e:
524
+ return {"ok": False, "output": "", "error": str(e)}
525
+
526
+
527
+ async def clipboard_set(params: dict[str, Any]) -> dict[str, Any]:
528
+ """Write text to the system clipboard."""
529
+ if "text" not in params:
530
+ return {"ok": False, "output": "", "error": "text is required"}
531
+ text = str(params.get("text", ""))
532
+ try:
533
+ from pascal import clipboard as clipboard_utils
534
+ clipboard_utils.set_text(text)
535
+ return {"ok": True, "output": f"Copied {len(text)} chars to clipboard", "error": ""}
536
+ except ImportError:
537
+ return {"ok": False, "output": "", "error": "clipboard_set requires pywin32 or pyperclip"}
538
+ except Exception as e:
539
+ return {"ok": False, "output": "", "error": str(e)}
540
+
541
+
542
+ # ── App lifecycle tools ──────────────────────────────────────────
543
+
544
+ async def app_launch(params: dict[str, Any]) -> dict[str, Any]:
545
+ """Launch an application by name or path."""
546
+ import subprocess
547
+ target = params.get("name") or params.get("path") or ""
548
+ if not target:
549
+ return {"ok": False, "output": "", "error": "name or path is required"}
550
+ args = params.get("args", [])
551
+ if isinstance(args, str):
552
+ args = args.split()
553
+ try:
554
+ cmd = [target] + args
555
+ subprocess.Popen(cmd, shell=False)
556
+ return {"ok": True, "output": f"Launched: {target}", "error": ""}
557
+ except FileNotFoundError:
558
+ # Try via shell start (Windows)
559
+ try:
560
+ import os
561
+ os.startfile(target)
562
+ return {"ok": True, "output": f"Opened: {target}", "error": ""}
563
+ except Exception as e:
564
+ return {"ok": False, "output": "", "error": f"Cannot launch '{target}': {e}"}
565
+ except Exception as e:
566
+ return {"ok": False, "output": "", "error": str(e)}
567
+
568
+
569
+ async def app_list(params: dict[str, Any]) -> dict[str, Any]:
570
+ """List visible windows / running GUI apps."""
571
+ try:
572
+ from pascal.uia import UIAutomationAdapter
573
+ desktop = UIAutomationAdapter()._get_desktop()
574
+ lines = []
575
+ for win in desktop.windows():
576
+ try:
577
+ title = win.window_text() or ""
578
+ if title and win.is_visible():
579
+ lines.append(title)
580
+ except Exception:
581
+ continue
582
+ return {"ok": True, "output": "\n".join(lines[:50]) or "(no visible windows)", "error": ""}
583
+ except ImportError:
584
+ # Fallback: tasklist on Windows
585
+ import subprocess
586
+ try:
587
+ result = subprocess.run(
588
+ ["tasklist", "/FI", "STATUS eq RUNNING", "/FO", "CSV", "/NH"],
589
+ capture_output=True, text=True, timeout=5,
590
+ )
591
+ return {"ok": True, "output": result.stdout[:4000], "error": ""}
592
+ except Exception as e:
593
+ return {"ok": False, "output": "", "error": str(e)}
594
+
595
+
596
+ async def app_close(params: dict[str, Any]) -> dict[str, Any]:
597
+ """Close a window by title."""
598
+ title = params.get("title") or ""
599
+ if not title:
600
+ return {"ok": False, "output": "", "error": "title is required"}
601
+ try:
602
+ from pascal.uia import UIAutomationAdapter
603
+ uia = UIAutomationAdapter()
604
+ win = uia._find_window(window_title=title)
605
+ if win is None:
606
+ return {"ok": False, "output": "", "error": f"Window not found: {title}"}
607
+ win.close()
608
+ return {"ok": True, "output": f"Closed: {title}", "error": ""}
609
+ except Exception as e:
610
+ return {"ok": False, "output": "", "error": str(e)}
611
+
612
+
613
+ # ── Registry ────────────────────────────────────────────────────
614
+
615
+ TOOL_REGISTRY: dict[str, Any] = {
616
+ "read_file": read_file,
617
+ "write_file": write_file,
618
+ "list_dir": list_dir,
619
+ "screenshot": screenshot,
620
+ "click": click,
621
+ "type_text": type_text,
622
+ "hotkey": hotkey,
623
+ "scroll": scroll,
624
+ "skill": skill,
625
+ "channel_reply": channel_reply,
626
+ # UIA tools (Windows native app control)
627
+ "uia_snapshot": uia_snapshot,
628
+ "uia_click": uia_click,
629
+ "uia_type": uia_type,
630
+ "uia_get_text": uia_get_text,
631
+ "uia_find": uia_find,
632
+ "uia_wait": uia_wait,
633
+ "window_focus": window_focus,
634
+ "clipboard_get": clipboard_get,
635
+ "clipboard_set": clipboard_set,
636
+ # App lifecycle
637
+ "app_launch": app_launch,
638
+ "app_list": app_list,
639
+ "app_close": app_close,
640
+ }
641
+
642
+ # Effect levels for safety gate in loop.py
643
+ TOOL_EFFECTS: dict[str, str] = {
644
+ "read_file": "E0", "list_dir": "E0", "screenshot": "E0",
645
+ "click": "E1", "type_text": "E1", "hotkey": "E1", "scroll": "E1",
646
+ "write_file": "E2",
647
+ "skill": "E0",
648
+ "channel_reply": "E3",
649
+ # UIA tools
650
+ "uia_snapshot": "E0", "uia_get_text": "E0", "uia_find": "E0",
651
+ "uia_wait": "E0",
652
+ "uia_click": "E1", "window_focus": "E1",
653
+ "uia_type": "E2",
654
+ "clipboard_get": "E0",
655
+ "clipboard_set": "E1",
656
+ # App lifecycle
657
+ "app_launch": "E1", "app_list": "E0", "app_close": "E2",
658
+ }
659
+
660
+
661
+ async def execute_tool(name: str, params: dict[str, Any]) -> dict[str, Any]:
662
+ """Execute a registered built-in tool by name."""
663
+ tool_fn = TOOL_REGISTRY.get(name)
664
+ if tool_fn is None:
665
+ return {"ok": False, "output": "", "error": f"Unknown tool: {name}. Available: {', '.join(TOOL_REGISTRY)}"}
666
+ return await tool_fn(params)
667
+
668
+
669
+ async def cleanup_tools():
670
+ """Clean up persistent tool state."""
671
+ global _uia_adapter
672
+ _uia_adapter = None