ltcai 0.1.0

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.
package/tools.py ADDED
@@ -0,0 +1,1136 @@
1
+ """
2
+ Safe local tools for Lattice AI agent mode.
3
+
4
+ All filesystem operations are confined to LATTICEAI_AGENT_ROOT, defaulting to
5
+ ./agent_workspace. Command execution runs without a shell and from inside that
6
+ workspace.
7
+ """
8
+
9
+ import base64
10
+ import os
11
+ import re
12
+ import shlex
13
+ import socket
14
+ import subprocess
15
+ import tempfile
16
+ import json
17
+ from html.parser import HTMLParser
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from p_reinforce import BRAIN_DIR, STRUCTURE
22
+
23
+ # ── Computer Use ──────────────────────────────────────────────────────────────
24
+ _CU_AVAILABLE = False
25
+ _pyautogui = None
26
+
27
+ def _init_computer_use():
28
+ global _CU_AVAILABLE, _pyautogui
29
+ try:
30
+ import pyautogui as _pag
31
+ _pag.FAILSAFE = True # 마우스를 좌상단 코너로 이동하면 중단
32
+ _pag.PAUSE = 0.25
33
+ _pyautogui = _pag
34
+ _CU_AVAILABLE = True
35
+ except Exception:
36
+ pass
37
+
38
+ _init_computer_use()
39
+
40
+
41
+ def computer_screenshot() -> Dict[str, Any]:
42
+ """현재 화면 전체를 캡처하여 base64 PNG로 반환합니다."""
43
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file:
44
+ tmp = tmp_file.name
45
+ try:
46
+ r = subprocess.run(
47
+ ["screencapture", "-x", "-t", "png", tmp],
48
+ capture_output=True, timeout=10, check=False,
49
+ )
50
+ if r.returncode != 0:
51
+ raise ToolError(f"screencapture 실패: {r.stderr.decode()}")
52
+ with open(tmp, "rb") as f:
53
+ b64 = base64.b64encode(f.read()).decode()
54
+ size = os.path.getsize(tmp)
55
+ w, h = (_pyautogui.size() if _CU_AVAILABLE else (0, 0))
56
+ return {
57
+ "screenshot_b64": b64,
58
+ "format": "png",
59
+ "bytes": size,
60
+ "screen_width": int(w),
61
+ "screen_height": int(h),
62
+ }
63
+ finally:
64
+ try:
65
+ if os.path.exists(tmp):
66
+ os.unlink(tmp)
67
+ except OSError:
68
+ pass
69
+
70
+
71
+ def computer_open_app(app: str = "Google Chrome") -> Dict[str, Any]:
72
+ """macOS 앱을 실행하거나 앞으로 가져옵니다."""
73
+ app = str(app or "Google Chrome").strip()
74
+ if not app:
75
+ raise ToolError("앱 이름이 필요합니다.")
76
+ r = subprocess.run(["open", "-a", app], capture_output=True, timeout=10, check=False)
77
+ if r.returncode != 0:
78
+ err = r.stderr.decode("utf-8", errors="replace").strip()
79
+ raise ToolError(f"앱 열기 실패: {err or app}")
80
+ return {"action": "open_app", "app": app}
81
+
82
+
83
+ def computer_open_url(url: str, app: str = "Google Chrome") -> Dict[str, Any]:
84
+ """URL을 지정한 macOS 앱으로 엽니다."""
85
+ url = str(url or "").strip()
86
+ app = str(app or "Google Chrome").strip()
87
+ if not url:
88
+ raise ToolError("URL이 필요합니다.")
89
+ if "://" not in url and not url.startswith(("localhost", "127.0.0.1")):
90
+ url = "https://" + url
91
+ r = subprocess.run(["open", "-a", app, url], capture_output=True, timeout=10, check=False)
92
+ if r.returncode != 0:
93
+ err = r.stderr.decode("utf-8", errors="replace").strip()
94
+ raise ToolError(f"URL 열기 실패: {err or url}")
95
+ return {"action": "open_url", "app": app, "url": url}
96
+
97
+
98
+ def computer_click(x: int, y: int, button: str = "left", double: bool = False) -> Dict[str, Any]:
99
+ """화면 좌표 (x, y)를 클릭합니다."""
100
+ if not _CU_AVAILABLE:
101
+ raise ToolError("pyautogui를 사용할 수 없습니다.")
102
+ x, y = int(x), int(y)
103
+ if double:
104
+ _pyautogui.doubleClick(x, y)
105
+ elif button == "right":
106
+ _pyautogui.rightClick(x, y)
107
+ elif button == "middle":
108
+ _pyautogui.middleClick(x, y)
109
+ else:
110
+ _pyautogui.click(x, y)
111
+ return {"action": "click", "x": x, "y": y, "button": button, "double": double}
112
+
113
+
114
+ def computer_type(text: str, interval: float = 0.04) -> Dict[str, Any]:
115
+ """현재 포커스된 위치에 텍스트를 입력합니다."""
116
+ if not _CU_AVAILABLE:
117
+ raise ToolError("pyautogui를 사용할 수 없습니다.")
118
+ _pyautogui.write(str(text), interval=float(interval))
119
+ return {"action": "type", "text": (text[:60] + "...") if len(text) > 60 else text, "chars": len(text)}
120
+
121
+
122
+ def computer_key(key: str) -> Dict[str, Any]:
123
+ """키보드 키를 누릅니다. 예: 'return', 'escape', 'command+c', 'tab'"""
124
+ if not _CU_AVAILABLE:
125
+ raise ToolError("pyautogui를 사용할 수 없습니다.")
126
+ key = str(key)
127
+ if "+" in key:
128
+ _pyautogui.hotkey(*key.split("+"))
129
+ else:
130
+ _pyautogui.press(key)
131
+ return {"action": "key", "key": key}
132
+
133
+
134
+ def computer_scroll(x: int, y: int, direction: str = "down", clicks: int = 3) -> Dict[str, Any]:
135
+ """화면 좌표에서 스크롤합니다."""
136
+ if not _CU_AVAILABLE:
137
+ raise ToolError("pyautogui를 사용할 수 없습니다.")
138
+ x, y, clicks = int(x), int(y), int(clicks)
139
+ _pyautogui.moveTo(x, y)
140
+ amount = -clicks if direction == "down" else clicks
141
+ _pyautogui.scroll(amount)
142
+ return {"action": "scroll", "x": x, "y": y, "direction": direction, "clicks": clicks}
143
+
144
+
145
+ def computer_move(x: int, y: int) -> Dict[str, Any]:
146
+ """마우스를 좌표로 이동합니다 (클릭 없음)."""
147
+ if not _CU_AVAILABLE:
148
+ raise ToolError("pyautogui를 사용할 수 없습니다.")
149
+ _pyautogui.moveTo(int(x), int(y), duration=0.2)
150
+ return {"action": "move", "x": int(x), "y": int(y)}
151
+
152
+
153
+ def computer_drag(x1: int, y1: int, x2: int, y2: int) -> Dict[str, Any]:
154
+ """(x1,y1)에서 (x2,y2)로 드래그합니다."""
155
+ if not _CU_AVAILABLE:
156
+ raise ToolError("pyautogui를 사용할 수 없습니다.")
157
+ _pyautogui.moveTo(int(x1), int(y1))
158
+ _pyautogui.dragTo(int(x2), int(y2), duration=0.35, button="left")
159
+ return {"action": "drag", "from": [int(x1), int(y1)], "to": [int(x2), int(y2)]}
160
+
161
+
162
+ def computer_status() -> Dict[str, Any]:
163
+ """Computer Use 기능 사용 가능 여부를 확인합니다."""
164
+ if not _CU_AVAILABLE:
165
+ return {"available": False, "reason": "pyautogui not installed"}
166
+ w, h = _pyautogui.size()
167
+ return {
168
+ "available": True,
169
+ "screen_size": {"width": int(w), "height": int(h)},
170
+ "failsafe": _pyautogui.FAILSAFE,
171
+ "note": "macOS Accessibility 권한이 필요합니다 (시스템 설정 > 개인 정보 보호 > 손쉬운 사용)",
172
+ }
173
+
174
+
175
+ AGENT_ROOT = Path(os.getenv("LATTICEAI_AGENT_ROOT") or "agent_workspace").resolve()
176
+ MAX_FILE_BYTES = 512_000
177
+ MAX_COMMAND_SECONDS = 30
178
+ MAX_BUILD_SECONDS = 180
179
+ MAX_DEPLOY_SECONDS = 300
180
+ MAX_COMMAND_OUTPUT = 12_000
181
+
182
+ BLOCKED_COMMANDS = {
183
+ "rm",
184
+ "rmdir",
185
+ "sudo",
186
+ "su",
187
+ "chmod",
188
+ "chown",
189
+ "curl",
190
+ "wget",
191
+ "ssh",
192
+ "scp",
193
+ "rsync",
194
+ "dd",
195
+ "mkfs",
196
+ "diskutil",
197
+ "launchctl",
198
+ }
199
+
200
+ ALLOWED_COMMANDS = {
201
+ "pwd",
202
+ "ls",
203
+ "find",
204
+ "cat",
205
+ "sed",
206
+ "head",
207
+ "tail",
208
+ "wc",
209
+ "rg",
210
+ "python",
211
+ "python3",
212
+ "node",
213
+ "npm",
214
+ "npx",
215
+ "git",
216
+ }
217
+
218
+ BUILD_SCRIPT_NAMES = {"build", "compile", "typecheck", "test"}
219
+ DEPLOY_SCRIPT_NAMES = {"deploy", "preview", "release"}
220
+
221
+ ALLOWED_GIT_SUBCOMMANDS = {"status", "diff", "log", "show"}
222
+
223
+ TEXT_EXTENSIONS = {
224
+ ".css",
225
+ ".csv",
226
+ ".html",
227
+ ".js",
228
+ ".json",
229
+ ".jsx",
230
+ ".md",
231
+ ".py",
232
+ ".ts",
233
+ ".tsx",
234
+ ".txt",
235
+ ".xml",
236
+ ".yaml",
237
+ ".yml",
238
+ }
239
+
240
+ DOCUMENT_OUTPUT_DIR = "generated_documents"
241
+ PRESENTATION_OUTPUT_DIR = "generated_presentations"
242
+ SPREADSHEET_OUTPUT_DIR = "generated_spreadsheets"
243
+
244
+
245
+ class ToolError(ValueError):
246
+ pass
247
+
248
+
249
+ def ensure_agent_root() -> Path:
250
+ AGENT_ROOT.mkdir(parents=True, exist_ok=True)
251
+ return AGENT_ROOT
252
+
253
+
254
+ def _resolve_path(path: str = "") -> Path:
255
+ ensure_agent_root()
256
+ if not path:
257
+ return AGENT_ROOT
258
+ candidate = (AGENT_ROOT / path).resolve()
259
+ if candidate != AGENT_ROOT and AGENT_ROOT not in candidate.parents:
260
+ raise ToolError("Path escapes the agent workspace.")
261
+ return candidate
262
+
263
+
264
+ def _relative(path: Path) -> str:
265
+ return str(path.relative_to(AGENT_ROOT))
266
+
267
+
268
+ def list_dir(path: str = ".") -> Dict[str, Any]:
269
+ target = _resolve_path(path)
270
+ if not target.exists():
271
+ raise ToolError("Directory does not exist.")
272
+ if not target.is_dir():
273
+ raise ToolError("Path is not a directory.")
274
+
275
+ items: List[Dict[str, Any]] = []
276
+ for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
277
+ items.append(
278
+ {
279
+ "name": child.name,
280
+ "path": _relative(child),
281
+ "type": "directory" if child.is_dir() else "file",
282
+ "size": child.stat().st_size if child.is_file() else None,
283
+ }
284
+ )
285
+ return {"root": str(AGENT_ROOT), "path": _relative(target) if target != AGENT_ROOT else ".", "items": items}
286
+
287
+
288
+ def workspace_tree(path: str = ".", max_depth: int = 3) -> Dict[str, Any]:
289
+ target = _resolve_path(path)
290
+ if not target.exists() or not target.is_dir():
291
+ raise ToolError("Path is not a directory.")
292
+
293
+ max_depth = max(1, min(int(max_depth), 8))
294
+ entries: List[Dict[str, Any]] = []
295
+
296
+ def walk(current: Path, depth: int) -> None:
297
+ if depth > max_depth:
298
+ return
299
+ for child in sorted(current.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
300
+ rel = _relative(child)
301
+ entries.append(
302
+ {
303
+ "path": rel,
304
+ "type": "directory" if child.is_dir() else "file",
305
+ "size": child.stat().st_size if child.is_file() else None,
306
+ "depth": depth,
307
+ }
308
+ )
309
+ if child.is_dir():
310
+ walk(child, depth + 1)
311
+
312
+ walk(target, 1)
313
+ return {"root": str(AGENT_ROOT), "path": _relative(target) if target != AGENT_ROOT else ".", "entries": entries}
314
+
315
+
316
+ def read_file(path: str) -> Dict[str, Any]:
317
+ target = _resolve_path(path)
318
+ if not target.exists():
319
+ raise ToolError("File does not exist.")
320
+ if not target.is_file():
321
+ raise ToolError("Path is not a file.")
322
+ size = target.stat().st_size
323
+ if size > MAX_FILE_BYTES:
324
+ raise ToolError(f"File is too large to read ({size} bytes).")
325
+ return {"path": _relative(target), "content": target.read_text(encoding="utf-8")}
326
+
327
+
328
+ def write_file(path: str, content: str) -> Dict[str, Any]:
329
+ target = _resolve_path(path)
330
+ if len(content.encode("utf-8")) > MAX_FILE_BYTES:
331
+ raise ToolError("Content is too large to write.")
332
+ target.parent.mkdir(parents=True, exist_ok=True)
333
+ target.write_text(content, encoding="utf-8")
334
+ return {"path": _relative(target), "bytes": target.stat().st_size}
335
+
336
+
337
+ def search_files(query: str, path: str = ".", max_results: int = 20) -> Dict[str, Any]:
338
+ if not query:
339
+ raise ToolError("Query is required.")
340
+ target = _resolve_path(path)
341
+ if not target.exists() or not target.is_dir():
342
+ raise ToolError("Path is not a directory.")
343
+
344
+ max_results = max(1, min(int(max_results), 100))
345
+ matches: List[Dict[str, Any]] = []
346
+ query_lower = query.lower()
347
+
348
+ for file_path in target.rglob("*"):
349
+ if len(matches) >= max_results:
350
+ break
351
+ if not file_path.is_file() or file_path.stat().st_size > MAX_FILE_BYTES:
352
+ continue
353
+ if file_path.suffix.lower() not in TEXT_EXTENSIONS:
354
+ continue
355
+ try:
356
+ lines = file_path.read_text(encoding="utf-8").splitlines()
357
+ except UnicodeDecodeError:
358
+ continue
359
+ for index, line in enumerate(lines, start=1):
360
+ if query_lower in line.lower():
361
+ matches.append({"path": _relative(file_path), "line": index, "preview": line[:240]})
362
+ break
363
+
364
+ return {"query": query, "matches": matches}
365
+
366
+
367
+ class _HTMLInspector(HTMLParser):
368
+ def __init__(self):
369
+ super().__init__()
370
+ self.title = ""
371
+ self._in_title = False
372
+ self.links: List[str] = []
373
+ self.scripts: List[str] = []
374
+ self.stylesheets: List[str] = []
375
+ self.images: List[str] = []
376
+ self.forms = 0
377
+ self.headings: List[Dict[str, str]] = []
378
+
379
+ def handle_starttag(self, tag: str, attrs: List[tuple]) -> None:
380
+ attr = dict(attrs)
381
+ if tag == "title":
382
+ self._in_title = True
383
+ elif tag == "a" and attr.get("href"):
384
+ self.links.append(attr["href"])
385
+ elif tag == "script" and attr.get("src"):
386
+ self.scripts.append(attr["src"])
387
+ elif tag == "link" and attr.get("rel") and "stylesheet" in " ".join(attr.get("rel", [])):
388
+ if attr.get("href"):
389
+ self.stylesheets.append(attr["href"])
390
+ elif tag == "img" and attr.get("src"):
391
+ self.images.append(attr["src"])
392
+ elif tag == "form":
393
+ self.forms += 1
394
+ elif tag in {"h1", "h2", "h3"}:
395
+ self.headings.append({"level": tag, "text": ""})
396
+
397
+ def handle_endtag(self, tag: str) -> None:
398
+ if tag == "title":
399
+ self._in_title = False
400
+
401
+ def handle_data(self, data: str) -> None:
402
+ text = data.strip()
403
+ if not text:
404
+ return
405
+ if self._in_title:
406
+ self.title += text
407
+ elif self.headings and not self.headings[-1]["text"]:
408
+ self.headings[-1]["text"] = text[:120]
409
+
410
+
411
+ def inspect_html(path: str) -> Dict[str, Any]:
412
+ target = _resolve_path(path)
413
+ if not target.exists() or not target.is_file():
414
+ raise ToolError("HTML file does not exist.")
415
+ if target.suffix.lower() not in {".html", ".htm"}:
416
+ raise ToolError("Path is not an HTML file.")
417
+ if target.stat().st_size > MAX_FILE_BYTES:
418
+ raise ToolError("HTML file is too large to inspect.")
419
+
420
+ parser = _HTMLInspector()
421
+ parser.feed(target.read_text(encoding="utf-8"))
422
+ return {
423
+ "path": _relative(target),
424
+ "title": parser.title,
425
+ "links": parser.links[:50],
426
+ "scripts": parser.scripts[:50],
427
+ "stylesheets": parser.stylesheets[:50],
428
+ "images": parser.images[:50],
429
+ "forms": parser.forms,
430
+ "headings": [h for h in parser.headings if h["text"]][:30],
431
+ }
432
+
433
+
434
+ def preview_url(path: str = "index.html") -> Dict[str, Any]:
435
+ target = _resolve_path(path)
436
+ if not target.exists() or not target.is_file():
437
+ raise ToolError("Preview file does not exist.")
438
+ rel = _relative(target)
439
+ return {
440
+ "path": rel,
441
+ "local_url": f"http://127.0.0.1:4825/agent-files/{rel}",
442
+ "note": "Use the server host or /web Telegram link host instead of 127.0.0.1 from a phone.",
443
+ }
444
+
445
+
446
+ def _safe_filename(name: str, suffix: str) -> str:
447
+ base = Path(name or f"artifact{suffix}").name
448
+ if not base.lower().endswith(suffix):
449
+ base += suffix
450
+ safe = "".join(ch if ch.isalnum() or ch in ("-", "_", ".", " ") else "_" for ch in base).strip()
451
+ return safe or f"artifact{suffix}"
452
+
453
+
454
+ def _body_to_str(body) -> str:
455
+ if isinstance(body, list):
456
+ return "\n\n".join(str(item) for item in body)
457
+ return str(body or "")
458
+
459
+
460
+ def create_docx(title: str, body, filename: str = "document.docx") -> Dict[str, Any]:
461
+ try:
462
+ from docx import Document
463
+ except Exception as exc:
464
+ raise ToolError("python-docx is not installed. Run `pip install -r requirements.txt`.") from exc
465
+
466
+ output_dir = _resolve_path(DOCUMENT_OUTPUT_DIR)
467
+ output_dir.mkdir(parents=True, exist_ok=True)
468
+ target = output_dir / _safe_filename(filename, ".docx")
469
+
470
+ document = Document()
471
+ if title:
472
+ document.add_heading(str(title), level=1)
473
+ for block in _body_to_str(body).split("\n\n"):
474
+ text = block.strip()
475
+ if text:
476
+ document.add_paragraph(text)
477
+ document.save(target)
478
+ return {"path": _relative(target), "bytes": target.stat().st_size}
479
+
480
+
481
+ def create_xlsx(rows: List[List[Any]], filename: str = "spreadsheet.xlsx", sheet_name: str = "Sheet1") -> Dict[str, Any]:
482
+ try:
483
+ from openpyxl import Workbook
484
+ except Exception as exc:
485
+ raise ToolError("openpyxl is not installed. Run `pip install -r requirements.txt`.") from exc
486
+
487
+ if not isinstance(rows, list) or not all(isinstance(row, list) for row in rows):
488
+ raise ToolError("Rows must be a list of lists.")
489
+
490
+ output_dir = _resolve_path(SPREADSHEET_OUTPUT_DIR)
491
+ output_dir.mkdir(parents=True, exist_ok=True)
492
+ target = output_dir / _safe_filename(filename, ".xlsx")
493
+
494
+ workbook = Workbook()
495
+ sheet = workbook.active
496
+ sheet.title = (sheet_name or "Sheet1")[:31]
497
+ for row in rows:
498
+ sheet.append(row)
499
+ workbook.save(target)
500
+ return {"path": _relative(target), "rows": len(rows), "bytes": target.stat().st_size}
501
+
502
+
503
+ def create_pptx(title: str, slides: List[Dict[str, Any]], filename: str = "presentation.pptx") -> Dict[str, Any]:
504
+ try:
505
+ from pptx import Presentation
506
+ except Exception as exc:
507
+ raise ToolError("python-pptx is not installed. Run `pip install -r requirements.txt`.") from exc
508
+
509
+ output_dir = _resolve_path(PRESENTATION_OUTPUT_DIR)
510
+ output_dir.mkdir(parents=True, exist_ok=True)
511
+ target = output_dir / _safe_filename(filename, ".pptx")
512
+
513
+ presentation = Presentation()
514
+ first_layout = presentation.slide_layouts[0]
515
+ first = presentation.slides.add_slide(first_layout)
516
+ first.shapes.title.text = title or "Presentation"
517
+ first.placeholders[1].text = ""
518
+
519
+ content_layout = presentation.slide_layouts[1]
520
+ for slide_data in slides or []:
521
+ slide = presentation.slides.add_slide(content_layout)
522
+ slide.shapes.title.text = str(slide_data.get("title") or "Slide")
523
+ body = slide.placeholders[1].text_frame
524
+ body.clear()
525
+ bullets = slide_data.get("bullets") or []
526
+ if isinstance(bullets, str):
527
+ bullets = [bullets]
528
+ for index, bullet in enumerate(bullets):
529
+ paragraph = body.paragraphs[0] if index == 0 else body.add_paragraph()
530
+ paragraph.text = str(bullet)
531
+ paragraph.level = 0
532
+
533
+ presentation.save(target)
534
+ return {"path": _relative(target), "slides": len(presentation.slides), "bytes": target.stat().st_size}
535
+
536
+
537
+ PDF_OUTPUT_DIR = "generated_pdfs"
538
+ LOCAL_MAX_FILE_BYTES = 2_000_000 # 2 MB cap for local reads
539
+
540
+
541
+ # CJK-capable fonts (Korean + Chinese + Japanese)
542
+ _CJK_FONT_CANDIDATES = [
543
+ "/System/Library/Fonts/AppleSDGothicNeo.ttc", # Korean (macOS)
544
+ "/System/Library/Fonts/STHeiti Light.ttc", # Chinese (macOS)
545
+ "/System/Library/Fonts/PingFang.ttc", # Chinese (macOS)
546
+ "/Library/Fonts/NanumGothic.ttf", # Korean
547
+ "/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
548
+ ]
549
+
550
+ _SUPPORTED_READ_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
551
+ DOCUMENT_MAX_READ_BYTES = 10_000_000 # 10 MB
552
+
553
+
554
+ def create_pdf(title: str, body, filename: str = "document.pdf") -> Dict[str, Any]:
555
+ try:
556
+ from reportlab.lib.pagesizes import A4
557
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
558
+ from reportlab.lib.units import mm
559
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
560
+ from reportlab.pdfbase import pdfmetrics
561
+ from reportlab.pdfbase.ttfonts import TTFont
562
+ except Exception as exc:
563
+ raise ToolError("reportlab is not installed. Run `pip install reportlab`.") from exc
564
+
565
+ output_dir = _resolve_path(PDF_OUTPUT_DIR)
566
+ output_dir.mkdir(parents=True, exist_ok=True)
567
+ target = output_dir / _safe_filename(filename, ".pdf")
568
+
569
+ # CJK 폰트 등록
570
+ font_name = "Helvetica"
571
+ for font_path in _CJK_FONT_CANDIDATES:
572
+ if Path(font_path).exists():
573
+ try:
574
+ pdfmetrics.registerFont(TTFont("KoreanFont", font_path))
575
+ font_name = "KoreanFont"
576
+ except Exception:
577
+ pass
578
+ break
579
+
580
+ styles = getSampleStyleSheet()
581
+ title_style = ParagraphStyle("Title", fontName=font_name, fontSize=18, spaceAfter=8, leading=24)
582
+ body_style = ParagraphStyle("Body", fontName=font_name, fontSize=11, spaceAfter=6, leading=16)
583
+
584
+ story = []
585
+ if title:
586
+ story.append(Paragraph(str(title), title_style))
587
+ story.append(Spacer(1, 4 * mm))
588
+
589
+ for block in _body_to_str(body).split("\n\n"):
590
+ text = block.strip()
591
+ if text:
592
+ safe_text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
593
+ story.append(Paragraph(safe_text, body_style))
594
+ story.append(Spacer(1, 2 * mm))
595
+
596
+ doc = SimpleDocTemplate(str(target), pagesize=A4,
597
+ leftMargin=20*mm, rightMargin=20*mm,
598
+ topMargin=20*mm, bottomMargin=20*mm)
599
+ doc.build(story)
600
+ return {"path": _relative(target), "bytes": target.stat().st_size}
601
+
602
+
603
+ def local_list(path: str) -> Dict[str, Any]:
604
+ """List any directory on the local filesystem (requires user approval via UI)."""
605
+ target = Path(path).expanduser().resolve()
606
+ if not target.exists():
607
+ raise ToolError(f"경로가 존재하지 않습니다: {path}")
608
+ if not target.is_dir():
609
+ raise ToolError(f"폴더가 아닙니다: {path}")
610
+ items = []
611
+ try:
612
+ for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
613
+ stat = child.stat()
614
+ items.append({
615
+ "name": child.name,
616
+ "path": str(child),
617
+ "type": "directory" if child.is_dir() else "file",
618
+ "size": stat.st_size if child.is_file() else None,
619
+ })
620
+ except PermissionError as exc:
621
+ raise ToolError(f"접근 권한 없음: {exc}") from exc
622
+ return {"path": str(target), "items": items}
623
+
624
+
625
+ def local_read(path: str) -> Dict[str, Any]:
626
+ """Read any file on the local filesystem (requires user approval via UI)."""
627
+ target = Path(path).expanduser().resolve()
628
+ if not target.exists():
629
+ raise ToolError(f"파일이 존재하지 않습니다: {path}")
630
+ if not target.is_file():
631
+ raise ToolError(f"파일이 아닙니다: {path}")
632
+ size = target.stat().st_size
633
+ if size > LOCAL_MAX_FILE_BYTES:
634
+ raise ToolError(f"파일이 너무 큽니다 ({size:,} bytes). 최대 {LOCAL_MAX_FILE_BYTES:,} bytes.")
635
+ try:
636
+ content = target.read_text(encoding="utf-8", errors="replace")
637
+ except Exception as exc:
638
+ raise ToolError(f"파일 읽기 실패: {exc}") from exc
639
+ return {"path": str(target), "size": size, "content": content}
640
+
641
+
642
+ def local_write(path: str, content: str) -> Dict[str, Any]:
643
+ """Write content to any path on the local filesystem (requires user approval via UI)."""
644
+ target = Path(path).expanduser().resolve()
645
+ if len(content.encode("utf-8")) > LOCAL_MAX_FILE_BYTES:
646
+ raise ToolError("내용이 너무 큽니다.")
647
+ try:
648
+ target.parent.mkdir(parents=True, exist_ok=True)
649
+ target.write_text(content, encoding="utf-8")
650
+ except PermissionError as exc:
651
+ raise ToolError(f"쓰기 권한 없음: {exc}") from exc
652
+ return {"path": str(target), "bytes": target.stat().st_size}
653
+
654
+
655
+ def read_document(path: str) -> Dict[str, Any]:
656
+ """Extract text from PDF, DOCX, XLSX, PPTX, TXT, MD, CSV files."""
657
+ target = Path(path).expanduser().resolve()
658
+ if not target.exists():
659
+ raise ToolError(f"파일이 없습니다: {path}")
660
+ if not target.is_file():
661
+ raise ToolError(f"파일이 아닙니다: {path}")
662
+ if target.stat().st_size > DOCUMENT_MAX_READ_BYTES:
663
+ raise ToolError(f"파일이 너무 큽니다 ({target.stat().st_size:,} bytes).")
664
+
665
+ ext = target.suffix.lower()
666
+ if ext not in _SUPPORTED_READ_EXTENSIONS:
667
+ raise ToolError(f"지원하지 않는 형식입니다: {ext}. 지원: {', '.join(_SUPPORTED_READ_EXTENSIONS)}")
668
+
669
+ text = ""
670
+ meta: Dict[str, Any] = {"path": str(target), "ext": ext}
671
+
672
+ if ext == ".pdf":
673
+ try:
674
+ import pdfplumber
675
+ with pdfplumber.open(str(target)) as pdf:
676
+ meta["pages"] = len(pdf.pages)
677
+ text = "\n\n".join(
678
+ (p.extract_text() or "") for p in pdf.pages
679
+ ).strip()
680
+ except Exception as exc:
681
+ raise ToolError(f"PDF 읽기 실패: {exc}") from exc
682
+
683
+ elif ext == ".docx":
684
+ try:
685
+ from docx import Document
686
+ doc = Document(str(target))
687
+ paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
688
+ text = "\n\n".join(paragraphs)
689
+ meta["paragraphs"] = len(paragraphs)
690
+ except Exception as exc:
691
+ raise ToolError(f"DOCX 읽기 실패: {exc}") from exc
692
+
693
+ elif ext == ".xlsx":
694
+ try:
695
+ from openpyxl import load_workbook
696
+ wb = load_workbook(str(target), data_only=True)
697
+ rows_all = []
698
+ for ws in wb.worksheets:
699
+ rows_all.append(f"[Sheet: {ws.title}]")
700
+ for row in ws.iter_rows(values_only=True):
701
+ cells = [str(c) if c is not None else "" for c in row]
702
+ rows_all.append("\t".join(cells))
703
+ text = "\n".join(rows_all)
704
+ meta["sheets"] = len(wb.worksheets)
705
+ except Exception as exc:
706
+ raise ToolError(f"XLSX 읽기 실패: {exc}") from exc
707
+
708
+ elif ext == ".pptx":
709
+ try:
710
+ from pptx import Presentation
711
+ prs = Presentation(str(target))
712
+ slides_text = []
713
+ for i, slide in enumerate(prs.slides, 1):
714
+ parts = []
715
+ for shape in slide.shapes:
716
+ if shape.has_text_frame:
717
+ parts.append(shape.text_frame.text)
718
+ slides_text.append(f"[Slide {i}]\n" + "\n".join(parts))
719
+ text = "\n\n".join(slides_text)
720
+ meta["slides"] = len(prs.slides)
721
+ except Exception as exc:
722
+ raise ToolError(f"PPTX 읽기 실패: {exc}") from exc
723
+
724
+ elif ext in {".txt", ".md", ".csv"}:
725
+ try:
726
+ text = target.read_text(encoding="utf-8", errors="replace")
727
+ except Exception as exc:
728
+ raise ToolError(f"파일 읽기 실패: {exc}") from exc
729
+
730
+ meta["chars"] = len(text)
731
+ meta["preview"] = text[:500]
732
+ meta["content"] = text[:50_000] # 50K char cap for context
733
+ return meta
734
+
735
+
736
+ def desktop_bridge_status() -> Dict[str, Any]:
737
+ return {
738
+ "status": "requires_desktop_bridge",
739
+ "available_in_codex": True,
740
+ "note": "Chrome and Mac UI control require the Codex desktop Computer Use/Chrome bridge, not a headless FastAPI worker.",
741
+ }
742
+
743
+
744
+ def _safe_brain_folder(folder: str) -> str:
745
+ if folder not in STRUCTURE:
746
+ raise ToolError(f"Unknown knowledge folder: {folder}")
747
+ return folder
748
+
749
+
750
+ def knowledge_save(content: str, folder: str = "00_Raw", title: Optional[str] = None) -> Dict[str, Any]:
751
+ folder = _safe_brain_folder(folder)
752
+ if not content:
753
+ raise ToolError("Knowledge content is required.")
754
+ if len(content.encode("utf-8")) > MAX_FILE_BYTES:
755
+ raise ToolError("Knowledge content is too large.")
756
+
757
+ target_dir = BRAIN_DIR / folder
758
+ target_dir.mkdir(parents=True, exist_ok=True)
759
+ safe_title = title or content.strip().splitlines()[0][:60] or "note"
760
+ safe_title = "".join(ch if ch.isalnum() or ch in (" ", "-", "_") else "" for ch in safe_title).strip()
761
+ safe_title = "_".join(safe_title.split()) or "note"
762
+ filename = f"{safe_title}.md"
763
+ target = target_dir / filename
764
+ counter = 2
765
+ while target.exists():
766
+ target = target_dir / f"{safe_title}_{counter}.md"
767
+ counter += 1
768
+ target.write_text(content, encoding="utf-8")
769
+ return {"folder": folder, "filename": target.name, "path": str(target)}
770
+
771
+
772
+ def knowledge_search(query: str, max_results: int = 5) -> Dict[str, Any]:
773
+ if not query:
774
+ raise ToolError("Query is required.")
775
+ max_results = max(1, min(int(max_results), 20))
776
+ query_lower = query.lower()
777
+ results: List[Dict[str, Any]] = []
778
+
779
+ for file_path in BRAIN_DIR.rglob("*.md"):
780
+ if len(results) >= max_results:
781
+ break
782
+ try:
783
+ content = file_path.read_text(encoding="utf-8")
784
+ except UnicodeDecodeError:
785
+ continue
786
+ if query_lower in content.lower() or query_lower in file_path.name.lower():
787
+ results.append(
788
+ {
789
+ "path": str(file_path),
790
+ "relative_path": str(file_path.relative_to(BRAIN_DIR)),
791
+ "preview": content[:500],
792
+ }
793
+ )
794
+
795
+ return {"query": query, "results": results}
796
+
797
+
798
+ def knowledge_tree() -> Dict[str, Any]:
799
+ entries: List[Dict[str, Any]] = []
800
+ for folder in STRUCTURE:
801
+ root = BRAIN_DIR / folder
802
+ root.mkdir(parents=True, exist_ok=True)
803
+ for file_path in sorted(root.rglob("*.md")):
804
+ entries.append(
805
+ {
806
+ "folder": folder,
807
+ "relative_path": str(file_path.relative_to(BRAIN_DIR)),
808
+ "size": file_path.stat().st_size,
809
+ }
810
+ )
811
+ return {"root": str(BRAIN_DIR), "entries": entries}
812
+
813
+
814
+ def obsidian_save(content: str, folder: str = "00_Raw", title: Optional[str] = None) -> Dict[str, Any]:
815
+ result = knowledge_save(content, folder, title)
816
+ result["vault_root"] = str(BRAIN_DIR)
817
+ result["obsidian_uri_hint"] = f"obsidian://open?path={result['path']}"
818
+ return result
819
+
820
+
821
+ def obsidian_search(query: str, max_results: int = 5) -> Dict[str, Any]:
822
+ result = knowledge_search(query, max_results)
823
+ result["vault_root"] = str(BRAIN_DIR)
824
+ return result
825
+
826
+
827
+ def obsidian_tree() -> Dict[str, Any]:
828
+ return knowledge_tree()
829
+
830
+
831
+ def _run_network_command(parts: List[str], timeout: int = 5) -> str:
832
+ try:
833
+ completed = subprocess.run(parts, capture_output=True, text=True, timeout=timeout, check=False)
834
+ if completed.returncode != 0:
835
+ return ""
836
+ return completed.stdout.strip()
837
+ except Exception:
838
+ return ""
839
+
840
+
841
+ def network_status() -> Dict[str, Any]:
842
+ """현재 Mac의 내부 IP, 외부 IP, 주요 네트워크 정보를 반환합니다."""
843
+ local_ips: Dict[str, str] = {}
844
+ for interface in ["en0", "en1", "bridge100"]:
845
+ value = _run_network_command(["ipconfig", "getifaddr", interface])
846
+ if value:
847
+ local_ips[interface] = value
848
+
849
+ ifconfig_text = _run_network_command(["ifconfig"])
850
+ current_interface = ""
851
+ for line in ifconfig_text.splitlines():
852
+ if line and not line.startswith(("\t", " ")):
853
+ current_interface = line.split(":", 1)[0]
854
+ continue
855
+ match = re.search(r"\binet\s+(\d+\.\d+\.\d+\.\d+)\b", line)
856
+ if match and current_interface and match.group(1) != "127.0.0.1":
857
+ local_ips.setdefault(current_interface, match.group(1))
858
+
859
+ hostname = socket.gethostname()
860
+ guessed_ip = ""
861
+ try:
862
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
863
+ sock.connect(("8.8.8.8", 80))
864
+ guessed_ip = sock.getsockname()[0]
865
+ except Exception:
866
+ pass
867
+ if guessed_ip and guessed_ip not in local_ips.values():
868
+ local_ips["default_route"] = guessed_ip
869
+
870
+ public_ip = _run_network_command(["curl", "-sS", "--max-time", "3", "https://api.ipify.org"])
871
+ wifi_info = _run_network_command(["networksetup", "-getinfo", "Wi-Fi"])
872
+
873
+ primary_local_ip = local_ips.get("en0") or local_ips.get("en1") or guessed_ip or ""
874
+ return {
875
+ "hostname": hostname,
876
+ "local_ip": primary_local_ip,
877
+ "local_ips": local_ips,
878
+ "public_ip": public_ip,
879
+ "wifi_info": wifi_info,
880
+ "ifconfig_available": bool(ifconfig_text),
881
+ "note": "local_ip은 같은 네트워크 안에서 보이는 내부 IP이고, public_ip는 인터넷에서 보이는 외부 IP입니다.",
882
+ }
883
+
884
+
885
+ _BLOCKED_FIND_FLAGS = {"-exec", "-execdir", "-delete", "-ok", "-okdir"}
886
+
887
+ def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
888
+ ensure_agent_root()
889
+ parts = shlex.split(command)
890
+ if not parts:
891
+ raise ToolError("Command is empty.")
892
+
893
+ executable = Path(parts[0]).name
894
+ if executable in BLOCKED_COMMANDS or executable not in ALLOWED_COMMANDS:
895
+ raise ToolError(f"Command is not allowed: {executable}")
896
+ if executable == "git":
897
+ raise ToolError("Use the read-only git_status, git_diff, git_log, or git_show tools.")
898
+ if any(token in command for token in ["|", "&&", "||", ";", ">", "<", "$(", "`"]):
899
+ raise ToolError("Shell operators are not allowed.")
900
+ if executable == "find":
901
+ blocked = [f for f in parts[1:] if f in _BLOCKED_FIND_FLAGS]
902
+ if blocked:
903
+ raise ToolError(f"find flags are not allowed: {', '.join(blocked)}")
904
+ abs_args = [a for a in parts[1:] if a.startswith("/") and a not in ("/dev/null",)]
905
+ if abs_args:
906
+ raise ToolError(f"Absolute paths in command arguments are not allowed: {abs_args[0]}")
907
+
908
+ workdir = _resolve_path(cwd or ".")
909
+ if not workdir.exists() or not workdir.is_dir():
910
+ raise ToolError("Working directory does not exist.")
911
+
912
+ try:
913
+ completed = subprocess.run(
914
+ parts,
915
+ cwd=workdir,
916
+ capture_output=True,
917
+ text=True,
918
+ timeout=MAX_COMMAND_SECONDS,
919
+ check=False,
920
+ )
921
+ except subprocess.TimeoutExpired:
922
+ raise ToolError(f"Command timed out after {MAX_COMMAND_SECONDS} seconds.")
923
+
924
+ stdout = completed.stdout[-MAX_COMMAND_OUTPUT:]
925
+ stderr = completed.stderr[-MAX_COMMAND_OUTPUT:]
926
+ return {
927
+ "command": command,
928
+ "cwd": _relative(workdir) if workdir != AGENT_ROOT else ".",
929
+ "returncode": completed.returncode,
930
+ "stdout": stdout,
931
+ "stderr": stderr,
932
+ }
933
+
934
+
935
+ def _load_package_scripts(workdir: Path) -> Dict[str, str]:
936
+ package_json = workdir / "package.json"
937
+ if not package_json.exists():
938
+ return {}
939
+ try:
940
+ import json
941
+ data = json.loads(package_json.read_text(encoding="utf-8"))
942
+ except Exception as exc:
943
+ raise ToolError(f"Could not parse package.json: {exc}") from exc
944
+ scripts = data.get("scripts") or {}
945
+ if not isinstance(scripts, dict):
946
+ return {}
947
+ return {str(key): str(value) for key, value in scripts.items()}
948
+
949
+
950
+ def _run_script(script: str, cwd: Optional[str], allowed: set[str], timeout: int) -> Dict[str, Any]:
951
+ ensure_agent_root()
952
+ if script not in allowed:
953
+ raise ToolError(f"Script is not allowed here: {script}")
954
+ workdir = _resolve_path(cwd or ".")
955
+ if not workdir.exists() or not workdir.is_dir():
956
+ raise ToolError("Working directory does not exist.")
957
+
958
+ scripts = _load_package_scripts(workdir)
959
+ if script not in scripts:
960
+ raise ToolError(f"package.json does not define a '{script}' script.")
961
+
962
+ try:
963
+ completed = subprocess.run(
964
+ ["npm", "run", script],
965
+ cwd=workdir,
966
+ capture_output=True,
967
+ text=True,
968
+ timeout=timeout,
969
+ check=False,
970
+ )
971
+ except subprocess.TimeoutExpired:
972
+ raise ToolError(f"npm run {script} timed out after {timeout} seconds.")
973
+
974
+ return {
975
+ "command": f"npm run {script}",
976
+ "cwd": _relative(workdir) if workdir != AGENT_ROOT else ".",
977
+ "script_body": scripts[script],
978
+ "returncode": completed.returncode,
979
+ "stdout": completed.stdout[-MAX_COMMAND_OUTPUT:],
980
+ "stderr": completed.stderr[-MAX_COMMAND_OUTPUT:],
981
+ }
982
+
983
+
984
+ def build_project(cwd: Optional[str] = None, script: str = "build") -> Dict[str, Any]:
985
+ return _run_script(script, cwd, BUILD_SCRIPT_NAMES, MAX_BUILD_SECONDS)
986
+
987
+
988
+ def deploy_project(cwd: Optional[str] = None, script: str = "deploy") -> Dict[str, Any]:
989
+ return _run_script(script, cwd, DEPLOY_SCRIPT_NAMES, MAX_DEPLOY_SECONDS)
990
+
991
+
992
+ def _run_git(args: List[str], cwd: Optional[str] = None) -> Dict[str, Any]:
993
+ if not args:
994
+ raise ToolError("Git subcommand is required.")
995
+ subcommand = args[0]
996
+ if subcommand not in ALLOWED_GIT_SUBCOMMANDS:
997
+ raise ToolError(f"Git subcommand is not allowed: {subcommand}")
998
+ if any(arg.startswith(("git@", "http://", "https://", "ssh://")) for arg in args):
999
+ raise ToolError("Remote git targets are not allowed.")
1000
+
1001
+ workdir = _resolve_path(cwd or ".")
1002
+ if not workdir.exists() or not workdir.is_dir():
1003
+ raise ToolError("Working directory does not exist.")
1004
+
1005
+ try:
1006
+ completed = subprocess.run(
1007
+ ["git", *args],
1008
+ cwd=workdir,
1009
+ capture_output=True,
1010
+ text=True,
1011
+ timeout=MAX_COMMAND_SECONDS,
1012
+ check=False,
1013
+ )
1014
+ except subprocess.TimeoutExpired:
1015
+ raise ToolError(f"Git command timed out after {MAX_COMMAND_SECONDS} seconds.")
1016
+
1017
+ return {
1018
+ "command": "git " + " ".join(args),
1019
+ "cwd": _relative(workdir) if workdir != AGENT_ROOT else ".",
1020
+ "returncode": completed.returncode,
1021
+ "stdout": completed.stdout[-MAX_COMMAND_OUTPUT:],
1022
+ "stderr": completed.stderr[-MAX_COMMAND_OUTPUT:],
1023
+ }
1024
+
1025
+
1026
+ def git_status(cwd: Optional[str] = None) -> Dict[str, Any]:
1027
+ return _run_git(["status", "--short"], cwd)
1028
+
1029
+
1030
+ def git_diff(path: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
1031
+ args = ["diff", "--"]
1032
+ if path:
1033
+ target = _resolve_path(path)
1034
+ args.append(_relative(target))
1035
+ return _run_git(args, cwd)
1036
+
1037
+
1038
+ def git_log(max_count: int = 5, cwd: Optional[str] = None) -> Dict[str, Any]:
1039
+ max_count = max(1, min(int(max_count), 20))
1040
+ return _run_git(["log", f"--max-count={max_count}", "--oneline", "--decorate"], cwd)
1041
+
1042
+
1043
+ def git_show(revision: str = "HEAD", cwd: Optional[str] = None) -> Dict[str, Any]:
1044
+ if revision.startswith("-") or any(token in revision for token in ["..", ":", "/", "\\"]):
1045
+ raise ToolError("Revision is not allowed.")
1046
+ return _run_git(["show", "--stat", "--oneline", "--decorate", revision], cwd)
1047
+
1048
+
1049
+ def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
1050
+ if action == "list_dir":
1051
+ return list_dir(args.get("path", "."))
1052
+ if action == "workspace_tree":
1053
+ return workspace_tree(args.get("path", "."), args.get("max_depth", 3))
1054
+ if action == "read_file":
1055
+ return read_file(args["path"])
1056
+ if action == "write_file":
1057
+ return write_file(args["path"], args.get("content", ""))
1058
+ if action == "search_files":
1059
+ return search_files(args["query"], args.get("path", "."), args.get("max_results", 20))
1060
+ if action == "inspect_html":
1061
+ return inspect_html(args["path"])
1062
+ if action == "preview_url":
1063
+ return preview_url(args.get("path", "index.html"))
1064
+ if action == "create_docx":
1065
+ return create_docx(args.get("title", ""), args.get("body", ""), args.get("filename", "document.docx"))
1066
+ if action == "create_xlsx":
1067
+ rows = args.get("rows", [])
1068
+ if isinstance(rows, str):
1069
+ rows = json.loads(rows)
1070
+ return create_xlsx(rows, args.get("filename", "spreadsheet.xlsx"), args.get("sheet_name", "Sheet1"))
1071
+ if action == "create_pptx":
1072
+ slides = args.get("slides", [])
1073
+ if isinstance(slides, str):
1074
+ slides = json.loads(slides)
1075
+ return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
1076
+ if action == "create_pdf":
1077
+ return create_pdf(args.get("title", ""), args.get("body", ""), args.get("filename", "document.pdf"))
1078
+ if action == "local_list":
1079
+ return local_list(args["path"])
1080
+ if action == "local_read":
1081
+ return local_read(args["path"])
1082
+ if action == "local_write":
1083
+ return local_write(args["path"], args.get("content", ""))
1084
+ if action == "read_document":
1085
+ return read_document(args["path"])
1086
+ if action == "network_status":
1087
+ return network_status()
1088
+ if action == "computer_screenshot":
1089
+ return computer_screenshot()
1090
+ if action == "computer_open_app":
1091
+ return computer_open_app(args.get("app", "Google Chrome"))
1092
+ if action == "computer_open_url":
1093
+ return computer_open_url(args["url"], args.get("app", "Google Chrome"))
1094
+ if action == "computer_click":
1095
+ return computer_click(args.get("x", 0), args.get("y", 0), args.get("button", "left"), args.get("double", False))
1096
+ if action == "computer_type":
1097
+ return computer_type(args["text"], args.get("interval", 0.04))
1098
+ if action == "computer_key":
1099
+ return computer_key(args["key"])
1100
+ if action == "computer_scroll":
1101
+ return computer_scroll(args.get("x", 0), args.get("y", 0), args.get("direction", "down"), args.get("clicks", 3))
1102
+ if action == "computer_move":
1103
+ return computer_move(args.get("x", 0), args.get("y", 0))
1104
+ if action == "computer_drag":
1105
+ return computer_drag(args.get("x1", 0), args.get("y1", 0), args.get("x2", 0), args.get("y2", 0))
1106
+ if action == "computer_status":
1107
+ return computer_status()
1108
+ if action in {"chrome_status", "computer_use_status"}:
1109
+ return desktop_bridge_status()
1110
+ if action == "knowledge_save":
1111
+ return knowledge_save(args["content"], args.get("folder", "00_Raw"), args.get("title"))
1112
+ if action == "knowledge_search":
1113
+ return knowledge_search(args["query"], args.get("max_results", 5))
1114
+ if action == "knowledge_tree":
1115
+ return knowledge_tree()
1116
+ if action == "obsidian_save":
1117
+ return obsidian_save(args["content"], args.get("folder", "00_Raw"), args.get("title"))
1118
+ if action == "obsidian_search":
1119
+ return obsidian_search(args["query"], args.get("max_results", 5))
1120
+ if action == "obsidian_tree":
1121
+ return obsidian_tree()
1122
+ if action == "git_status":
1123
+ return git_status(args.get("cwd"))
1124
+ if action == "git_diff":
1125
+ return git_diff(args.get("path"), args.get("cwd"))
1126
+ if action == "git_log":
1127
+ return git_log(args.get("max_count", 5), args.get("cwd"))
1128
+ if action == "git_show":
1129
+ return git_show(args.get("revision", "HEAD"), args.get("cwd"))
1130
+ if action == "run_command":
1131
+ return run_command(args["command"], args.get("cwd"))
1132
+ if action == "build_project":
1133
+ return build_project(args.get("cwd"), args.get("script", "build"))
1134
+ if action == "deploy_project":
1135
+ return deploy_project(args.get("cwd"), args.get("script", "deploy"))
1136
+ raise ToolError(f"Unknown action: {action}")