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/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/ltcai.js +74 -0
- package/codex_telegram_bot.py +191 -0
- package/llm_router.py +537 -0
- package/ltcai_cli.py +74 -0
- package/p_reinforce.py +148 -0
- package/package.json +44 -0
- package/requirements.txt +11 -0
- package/server.py +3215 -0
- package/static/admin.html +1013 -0
- package/static/index.html +270 -0
- package/static/indexd.html +5664 -0
- package/telegram_bot.py +430 -0
- package/tools.py +1136 -0
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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}")
|