ltcai 3.4.1 → 3.5.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/README.md +173 -248
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/auth.py +37 -9
- package/latticeai/api/chat.py +4 -1
- package/latticeai/api/computer_use.py +21 -8
- package/latticeai/api/tools.py +29 -26
- package/latticeai/core/config.py +3 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/oidc.py +205 -0
- package/latticeai/core/security.py +59 -5
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +7 -0
- package/package.json +3 -3
- package/requirements.txt +1 -0
- package/scripts/check_python.py +87 -0
- package/static/css/reference/account.css +1 -1
- package/static/css/reference/admin.css +1 -1
- package/static/css/reference/base.css +8 -5
- package/static/css/reference/chat.css +8 -8
- package/static/css/reference/graph.css +2 -2
- package/static/css/responsive.css +2 -2
- package/static/v3/asset-manifest.json +3 -3
- package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
- package/static/v3/css/lattice.shell.css +2 -1
- package/static/workspace.css +1 -1
- package/tools/__init__.py +276 -0
- package/tools/commands.py +188 -0
- package/tools/computer.py +185 -0
- package/tools/documents.py +243 -0
- package/tools/filesystem.py +560 -0
- package/tools/knowledge.py +97 -0
- package/tools/local_files.py +69 -0
- package/tools/network.py +66 -0
- package/tools.py +0 -1525
package/tools.py
DELETED
|
@@ -1,1525 +0,0 @@
|
|
|
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 platform
|
|
12
|
-
import re
|
|
13
|
-
import shlex
|
|
14
|
-
import socket
|
|
15
|
-
import subprocess
|
|
16
|
-
import tempfile
|
|
17
|
-
import json
|
|
18
|
-
from html.parser import HTMLParser
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
from typing import Any, Callable, Dict, List, Optional
|
|
21
|
-
|
|
22
|
-
_PLATFORM = platform.system() # "Darwin" | "Windows" | "Linux"
|
|
23
|
-
|
|
24
|
-
from latticeai.core.tool_registry import ToolRegistry
|
|
25
|
-
from p_reinforce import BRAIN_DIR, STRUCTURE
|
|
26
|
-
|
|
27
|
-
# ── Computer Use ──────────────────────────────────────────────────────────────
|
|
28
|
-
_CU_AVAILABLE = False
|
|
29
|
-
_pyautogui = None
|
|
30
|
-
|
|
31
|
-
def _init_computer_use():
|
|
32
|
-
global _CU_AVAILABLE, _pyautogui
|
|
33
|
-
try:
|
|
34
|
-
import pyautogui as _pag
|
|
35
|
-
_pag.FAILSAFE = True # 마우스를 좌상단 코너로 이동하면 중단
|
|
36
|
-
_pag.PAUSE = 0.25
|
|
37
|
-
_pyautogui = _pag
|
|
38
|
-
_CU_AVAILABLE = True
|
|
39
|
-
except Exception:
|
|
40
|
-
pass
|
|
41
|
-
|
|
42
|
-
_init_computer_use()
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def computer_screenshot() -> Dict[str, Any]:
|
|
46
|
-
"""현재 화면 전체를 캡처하여 base64 PNG로 반환합니다."""
|
|
47
|
-
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file:
|
|
48
|
-
tmp = tmp_file.name
|
|
49
|
-
try:
|
|
50
|
-
if _PLATFORM == "Darwin":
|
|
51
|
-
r = subprocess.run(
|
|
52
|
-
["screencapture", "-x", "-t", "png", tmp],
|
|
53
|
-
capture_output=True, timeout=10, check=False,
|
|
54
|
-
)
|
|
55
|
-
if r.returncode != 0:
|
|
56
|
-
raise ToolError(f"screencapture 실패: {r.stderr.decode()}")
|
|
57
|
-
elif _CU_AVAILABLE:
|
|
58
|
-
# Windows / Linux: use pyautogui screenshot
|
|
59
|
-
screenshot = _pyautogui.screenshot()
|
|
60
|
-
screenshot.save(tmp)
|
|
61
|
-
else:
|
|
62
|
-
raise ToolError("스크린샷 불가: macOS 전용 screencapture 또는 pyautogui 필요")
|
|
63
|
-
with open(tmp, "rb") as f:
|
|
64
|
-
b64 = base64.b64encode(f.read()).decode()
|
|
65
|
-
size = os.path.getsize(tmp)
|
|
66
|
-
w, h = (_pyautogui.size() if _CU_AVAILABLE else (0, 0))
|
|
67
|
-
return {
|
|
68
|
-
"screenshot_b64": b64,
|
|
69
|
-
"format": "png",
|
|
70
|
-
"bytes": size,
|
|
71
|
-
"screen_width": int(w),
|
|
72
|
-
"screen_height": int(h),
|
|
73
|
-
}
|
|
74
|
-
finally:
|
|
75
|
-
try:
|
|
76
|
-
if os.path.exists(tmp):
|
|
77
|
-
os.unlink(tmp)
|
|
78
|
-
except OSError:
|
|
79
|
-
pass
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def computer_open_app(app: str = "Google Chrome") -> Dict[str, Any]:
|
|
83
|
-
"""앱을 실행하거나 앞으로 가져옵니다 (macOS/Windows/Linux)."""
|
|
84
|
-
app = str(app or "Google Chrome").strip()
|
|
85
|
-
if not app:
|
|
86
|
-
raise ToolError("앱 이름이 필요합니다.")
|
|
87
|
-
if _PLATFORM == "Darwin":
|
|
88
|
-
cmd = ["open", "-a", app]
|
|
89
|
-
elif _PLATFORM == "Windows":
|
|
90
|
-
cmd = ["cmd", "/c", "start", "", app]
|
|
91
|
-
else:
|
|
92
|
-
cmd = ["xdg-open", app]
|
|
93
|
-
r = subprocess.run(cmd, capture_output=True, timeout=10, check=False)
|
|
94
|
-
if r.returncode != 0:
|
|
95
|
-
err = r.stderr.decode("utf-8", errors="replace").strip()
|
|
96
|
-
raise ToolError(f"앱 열기 실패: {err or app}")
|
|
97
|
-
return {"action": "open_app", "app": app}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def computer_open_url(url: str, app: str = "Google Chrome") -> Dict[str, Any]:
|
|
101
|
-
"""URL을 브라우저로 엽니다 (macOS/Windows/Linux)."""
|
|
102
|
-
url = str(url or "").strip()
|
|
103
|
-
app = str(app or "").strip()
|
|
104
|
-
if not url:
|
|
105
|
-
raise ToolError("URL이 필요합니다.")
|
|
106
|
-
if "://" not in url and not url.startswith(("localhost", "127.0.0.1")):
|
|
107
|
-
url = "https://" + url
|
|
108
|
-
if _PLATFORM == "Darwin":
|
|
109
|
-
cmd = ["open", "-a", app, url] if app else ["open", url]
|
|
110
|
-
elif _PLATFORM == "Windows":
|
|
111
|
-
cmd = ["cmd", "/c", "start", "", url]
|
|
112
|
-
else:
|
|
113
|
-
cmd = ["xdg-open", url]
|
|
114
|
-
r = subprocess.run(cmd, capture_output=True, timeout=10, check=False)
|
|
115
|
-
if r.returncode != 0:
|
|
116
|
-
err = r.stderr.decode("utf-8", errors="replace").strip()
|
|
117
|
-
raise ToolError(f"URL 열기 실패: {err or url}")
|
|
118
|
-
return {"action": "open_url", "app": app or "default", "url": url}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def computer_click(x: int, y: int, button: str = "left", double: bool = False) -> Dict[str, Any]:
|
|
122
|
-
"""화면 좌표 (x, y)를 클릭합니다."""
|
|
123
|
-
if not _CU_AVAILABLE:
|
|
124
|
-
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
125
|
-
x, y = int(x), int(y)
|
|
126
|
-
if double:
|
|
127
|
-
_pyautogui.doubleClick(x, y)
|
|
128
|
-
elif button == "right":
|
|
129
|
-
_pyautogui.rightClick(x, y)
|
|
130
|
-
elif button == "middle":
|
|
131
|
-
_pyautogui.middleClick(x, y)
|
|
132
|
-
else:
|
|
133
|
-
_pyautogui.click(x, y)
|
|
134
|
-
return {"action": "click", "x": x, "y": y, "button": button, "double": double}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def computer_type(text: str, interval: float = 0.04) -> Dict[str, Any]:
|
|
138
|
-
"""현재 포커스된 위치에 텍스트를 입력합니다."""
|
|
139
|
-
if not _CU_AVAILABLE:
|
|
140
|
-
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
141
|
-
_pyautogui.write(str(text), interval=float(interval))
|
|
142
|
-
return {"action": "type", "text": (text[:60] + "...") if len(text) > 60 else text, "chars": len(text)}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def computer_key(key: str) -> Dict[str, Any]:
|
|
146
|
-
"""키보드 키를 누릅니다. 예: 'return', 'escape', 'command+c', 'tab'"""
|
|
147
|
-
if not _CU_AVAILABLE:
|
|
148
|
-
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
149
|
-
key = str(key)
|
|
150
|
-
if "+" in key:
|
|
151
|
-
_pyautogui.hotkey(*key.split("+"))
|
|
152
|
-
else:
|
|
153
|
-
_pyautogui.press(key)
|
|
154
|
-
return {"action": "key", "key": key}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def computer_scroll(x: int, y: int, direction: str = "down", clicks: int = 3) -> Dict[str, Any]:
|
|
158
|
-
"""화면 좌표에서 스크롤합니다."""
|
|
159
|
-
if not _CU_AVAILABLE:
|
|
160
|
-
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
161
|
-
x, y, clicks = int(x), int(y), int(clicks)
|
|
162
|
-
_pyautogui.moveTo(x, y)
|
|
163
|
-
amount = -clicks if direction == "down" else clicks
|
|
164
|
-
_pyautogui.scroll(amount)
|
|
165
|
-
return {"action": "scroll", "x": x, "y": y, "direction": direction, "clicks": clicks}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def computer_move(x: int, y: int) -> Dict[str, Any]:
|
|
169
|
-
"""마우스를 좌표로 이동합니다 (클릭 없음)."""
|
|
170
|
-
if not _CU_AVAILABLE:
|
|
171
|
-
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
172
|
-
_pyautogui.moveTo(int(x), int(y), duration=0.2)
|
|
173
|
-
return {"action": "move", "x": int(x), "y": int(y)}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def computer_drag(x1: int, y1: int, x2: int, y2: int) -> Dict[str, Any]:
|
|
177
|
-
"""(x1,y1)에서 (x2,y2)로 드래그합니다."""
|
|
178
|
-
if not _CU_AVAILABLE:
|
|
179
|
-
raise ToolError("pyautogui를 사용할 수 없습니다.")
|
|
180
|
-
_pyautogui.moveTo(int(x1), int(y1))
|
|
181
|
-
_pyautogui.dragTo(int(x2), int(y2), duration=0.35, button="left")
|
|
182
|
-
return {"action": "drag", "from": [int(x1), int(y1)], "to": [int(x2), int(y2)]}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def computer_status() -> Dict[str, Any]:
|
|
186
|
-
"""Computer Use 기능 사용 가능 여부를 확인합니다."""
|
|
187
|
-
if not _CU_AVAILABLE:
|
|
188
|
-
return {"available": False, "reason": "pyautogui not installed"}
|
|
189
|
-
w, h = _pyautogui.size()
|
|
190
|
-
return {
|
|
191
|
-
"available": True,
|
|
192
|
-
"screen_size": {"width": int(w), "height": int(h)},
|
|
193
|
-
"failsafe": _pyautogui.FAILSAFE,
|
|
194
|
-
"note": "macOS Accessibility 권한이 필요합니다 (시스템 설정 > 개인 정보 보호 > 손쉬운 사용)",
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
AGENT_ROOT = Path(os.getenv("LATTICEAI_AGENT_ROOT") or "agent_workspace").resolve()
|
|
199
|
-
MAX_FILE_BYTES = 512_000
|
|
200
|
-
MAX_COMMAND_SECONDS = 30
|
|
201
|
-
MAX_BUILD_SECONDS = 180
|
|
202
|
-
MAX_DEPLOY_SECONDS = 300
|
|
203
|
-
MAX_COMMAND_OUTPUT = 12_000
|
|
204
|
-
|
|
205
|
-
BLOCKED_COMMANDS = {
|
|
206
|
-
"rm",
|
|
207
|
-
"rmdir",
|
|
208
|
-
"sudo",
|
|
209
|
-
"su",
|
|
210
|
-
"chmod",
|
|
211
|
-
"chown",
|
|
212
|
-
"curl",
|
|
213
|
-
"wget",
|
|
214
|
-
"ssh",
|
|
215
|
-
"scp",
|
|
216
|
-
"rsync",
|
|
217
|
-
"dd",
|
|
218
|
-
"mkfs",
|
|
219
|
-
"diskutil",
|
|
220
|
-
"launchctl",
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
ALLOWED_COMMANDS = {
|
|
224
|
-
"pwd",
|
|
225
|
-
"ls",
|
|
226
|
-
"find",
|
|
227
|
-
"cat",
|
|
228
|
-
"sed",
|
|
229
|
-
"head",
|
|
230
|
-
"tail",
|
|
231
|
-
"wc",
|
|
232
|
-
"rg",
|
|
233
|
-
"python",
|
|
234
|
-
"python3",
|
|
235
|
-
"node",
|
|
236
|
-
"npm",
|
|
237
|
-
"npx",
|
|
238
|
-
"git",
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
BUILD_SCRIPT_NAMES = {"build", "compile", "typecheck", "test"}
|
|
242
|
-
DEPLOY_SCRIPT_NAMES = {
|
|
243
|
-
"deploy",
|
|
244
|
-
"preview",
|
|
245
|
-
"release",
|
|
246
|
-
"package",
|
|
247
|
-
"dist",
|
|
248
|
-
"make",
|
|
249
|
-
"build:installer",
|
|
250
|
-
"build:pkg",
|
|
251
|
-
"build:exe",
|
|
252
|
-
"package:mac",
|
|
253
|
-
"package:win",
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
ALLOWED_GIT_SUBCOMMANDS = {"status", "diff", "log", "show"}
|
|
257
|
-
|
|
258
|
-
TEXT_EXTENSIONS = {
|
|
259
|
-
".css",
|
|
260
|
-
".csv",
|
|
261
|
-
".html",
|
|
262
|
-
".js",
|
|
263
|
-
".json",
|
|
264
|
-
".jsx",
|
|
265
|
-
".md",
|
|
266
|
-
".py",
|
|
267
|
-
".ts",
|
|
268
|
-
".tsx",
|
|
269
|
-
".txt",
|
|
270
|
-
".xml",
|
|
271
|
-
".yaml",
|
|
272
|
-
".yml",
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
DOCUMENT_OUTPUT_DIR = "generated_documents"
|
|
276
|
-
PRESENTATION_OUTPUT_DIR = "generated_presentations"
|
|
277
|
-
SPREADSHEET_OUTPUT_DIR = "generated_spreadsheets"
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
class ToolError(ValueError):
|
|
281
|
-
pass
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def ensure_agent_root() -> Path:
|
|
285
|
-
AGENT_ROOT.mkdir(parents=True, exist_ok=True)
|
|
286
|
-
return AGENT_ROOT
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def _resolve_path(path: str = "") -> Path:
|
|
290
|
-
ensure_agent_root()
|
|
291
|
-
if not path:
|
|
292
|
-
return AGENT_ROOT
|
|
293
|
-
candidate = (AGENT_ROOT / path).resolve()
|
|
294
|
-
if candidate != AGENT_ROOT and AGENT_ROOT not in candidate.parents:
|
|
295
|
-
raise ToolError("Path escapes the agent workspace.")
|
|
296
|
-
return candidate
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def _relative(path: Path) -> str:
|
|
300
|
-
return str(path.relative_to(AGENT_ROOT))
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def list_dir(path: str = ".") -> Dict[str, Any]:
|
|
304
|
-
target = _resolve_path(path)
|
|
305
|
-
if not target.exists():
|
|
306
|
-
raise ToolError("Directory does not exist.")
|
|
307
|
-
if not target.is_dir():
|
|
308
|
-
raise ToolError("Path is not a directory.")
|
|
309
|
-
|
|
310
|
-
items: List[Dict[str, Any]] = []
|
|
311
|
-
for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
312
|
-
items.append(
|
|
313
|
-
{
|
|
314
|
-
"name": child.name,
|
|
315
|
-
"path": _relative(child),
|
|
316
|
-
"type": "directory" if child.is_dir() else "file",
|
|
317
|
-
"size": child.stat().st_size if child.is_file() else None,
|
|
318
|
-
}
|
|
319
|
-
)
|
|
320
|
-
return {"root": str(AGENT_ROOT), "path": _relative(target) if target != AGENT_ROOT else ".", "items": items}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def workspace_tree(path: str = ".", max_depth: int = 3) -> Dict[str, Any]:
|
|
324
|
-
target = _resolve_path(path)
|
|
325
|
-
if not target.exists() or not target.is_dir():
|
|
326
|
-
raise ToolError("Path is not a directory.")
|
|
327
|
-
|
|
328
|
-
max_depth = max(1, min(int(max_depth), 8))
|
|
329
|
-
entries: List[Dict[str, Any]] = []
|
|
330
|
-
|
|
331
|
-
def walk(current: Path, depth: int) -> None:
|
|
332
|
-
if depth > max_depth:
|
|
333
|
-
return
|
|
334
|
-
for child in sorted(current.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
335
|
-
rel = _relative(child)
|
|
336
|
-
entries.append(
|
|
337
|
-
{
|
|
338
|
-
"path": rel,
|
|
339
|
-
"type": "directory" if child.is_dir() else "file",
|
|
340
|
-
"size": child.stat().st_size if child.is_file() else None,
|
|
341
|
-
"depth": depth,
|
|
342
|
-
}
|
|
343
|
-
)
|
|
344
|
-
if child.is_dir():
|
|
345
|
-
walk(child, depth + 1)
|
|
346
|
-
|
|
347
|
-
walk(target, 1)
|
|
348
|
-
return {"root": str(AGENT_ROOT), "path": _relative(target) if target != AGENT_ROOT else ".", "entries": entries}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def read_file(path: str, offset: int = 0, limit: int = 0, line_numbers: bool = True) -> Dict[str, Any]:
|
|
352
|
-
"""Read a file from the agent workspace.
|
|
353
|
-
|
|
354
|
-
Returns content as plain text. When line_numbers is True (default), also
|
|
355
|
-
returns a numbered view (`numbered`) plus `total_lines` so the agent can
|
|
356
|
-
cite file:line locations precisely.
|
|
357
|
-
|
|
358
|
-
offset is 0-indexed (the first line is offset=0). limit=0 reads to the end.
|
|
359
|
-
"""
|
|
360
|
-
target = _resolve_path(path)
|
|
361
|
-
if not target.exists():
|
|
362
|
-
raise ToolError("File does not exist.")
|
|
363
|
-
if not target.is_file():
|
|
364
|
-
raise ToolError("Path is not a file.")
|
|
365
|
-
size = target.stat().st_size
|
|
366
|
-
if size > MAX_FILE_BYTES:
|
|
367
|
-
raise ToolError(f"File is too large to read ({size} bytes).")
|
|
368
|
-
text = target.read_text(encoding="utf-8")
|
|
369
|
-
all_lines = text.splitlines()
|
|
370
|
-
total_lines = len(all_lines)
|
|
371
|
-
|
|
372
|
-
offset = max(0, int(offset or 0))
|
|
373
|
-
limit = max(0, int(limit or 0))
|
|
374
|
-
end = total_lines if limit == 0 else min(total_lines, offset + limit)
|
|
375
|
-
sliced = all_lines[offset:end]
|
|
376
|
-
sliced_text = "\n".join(sliced)
|
|
377
|
-
if offset == 0 and limit == 0 and text.endswith("\n"):
|
|
378
|
-
sliced_text += "\n"
|
|
379
|
-
|
|
380
|
-
result: Dict[str, Any] = {
|
|
381
|
-
"path": _relative(target),
|
|
382
|
-
"content": sliced_text,
|
|
383
|
-
"total_lines": total_lines,
|
|
384
|
-
"start_line": offset + 1,
|
|
385
|
-
"end_line": end,
|
|
386
|
-
}
|
|
387
|
-
if line_numbers:
|
|
388
|
-
width = max(4, len(str(end or total_lines)))
|
|
389
|
-
result["numbered"] = "\n".join(
|
|
390
|
-
f"{(offset + i + 1):>{width}}\t{line}" for i, line in enumerate(sliced)
|
|
391
|
-
)
|
|
392
|
-
return result
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
def write_file(path: str, content: str) -> Dict[str, Any]:
|
|
396
|
-
target = _resolve_path(path)
|
|
397
|
-
if len(content.encode("utf-8")) > MAX_FILE_BYTES:
|
|
398
|
-
raise ToolError("Content is too large to write.")
|
|
399
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
400
|
-
target.write_text(content, encoding="utf-8")
|
|
401
|
-
return {"path": _relative(target), "bytes": target.stat().st_size}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False) -> Dict[str, Any]:
|
|
405
|
-
"""Precise diff-style edit: replace `old_string` with `new_string` in `path`.
|
|
406
|
-
|
|
407
|
-
Fails when `old_string` is missing or appears more than once (unless
|
|
408
|
-
replace_all=True). This forces the caller to read the file first and pass
|
|
409
|
-
enough surrounding context to uniquely identify the edit site — the same
|
|
410
|
-
discipline Claude Code uses for safe edits.
|
|
411
|
-
"""
|
|
412
|
-
if old_string == new_string:
|
|
413
|
-
raise ToolError("old_string and new_string are identical; nothing to change.")
|
|
414
|
-
target = _resolve_path(path)
|
|
415
|
-
if not target.exists() or not target.is_file():
|
|
416
|
-
raise ToolError("File does not exist.")
|
|
417
|
-
if target.stat().st_size > MAX_FILE_BYTES:
|
|
418
|
-
raise ToolError("File is too large to edit.")
|
|
419
|
-
|
|
420
|
-
original = target.read_text(encoding="utf-8")
|
|
421
|
-
occurrences = original.count(old_string)
|
|
422
|
-
if occurrences == 0:
|
|
423
|
-
raise ToolError("old_string not found in file. Read the file first and copy the exact bytes (including whitespace).")
|
|
424
|
-
if occurrences > 1 and not replace_all:
|
|
425
|
-
raise ToolError(f"old_string is ambiguous: appears {occurrences} times. Add more context to make it unique, or pass replace_all=true.")
|
|
426
|
-
|
|
427
|
-
updated = original.replace(old_string, new_string) if replace_all else original.replace(old_string, new_string, 1)
|
|
428
|
-
if len(updated.encode("utf-8")) > MAX_FILE_BYTES:
|
|
429
|
-
raise ToolError("Resulting file would exceed the workspace size limit.")
|
|
430
|
-
target.write_text(updated, encoding="utf-8")
|
|
431
|
-
|
|
432
|
-
edited_line = original[: original.find(old_string)].count("\n") + 1
|
|
433
|
-
return {
|
|
434
|
-
"path": _relative(target),
|
|
435
|
-
"replacements": occurrences if replace_all else 1,
|
|
436
|
-
"bytes": target.stat().st_size,
|
|
437
|
-
"first_edit_line": edited_line,
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
_GREP_BINARY_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".zip", ".tar",
|
|
442
|
-
".gz", ".bz2", ".xz", ".7z", ".mp3", ".mp4", ".mov", ".wav",
|
|
443
|
-
".woff", ".woff2", ".ttf", ".eot", ".ico", ".db", ".sqlite",
|
|
444
|
-
".pyc", ".pyo", ".o", ".so", ".dylib", ".dll", ".exe", ".bin"}
|
|
445
|
-
_GREP_BINARY_DIRS = {"node_modules", ".git", ".venv", "venv", "__pycache__",
|
|
446
|
-
".pytest_cache", "dist", "build", ".next", ".cache"}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
def grep(
|
|
450
|
-
pattern: str,
|
|
451
|
-
path: str = ".",
|
|
452
|
-
glob: Optional[str] = None,
|
|
453
|
-
max_results: int = 50,
|
|
454
|
-
case_insensitive: bool = False,
|
|
455
|
-
context_lines: int = 0,
|
|
456
|
-
) -> Dict[str, Any]:
|
|
457
|
-
"""Regex search across the agent workspace.
|
|
458
|
-
|
|
459
|
-
Unlike `search_files` (single line, 9 extensions, substring only), this
|
|
460
|
-
walks all text files, supports regex, returns line numbers, and can
|
|
461
|
-
optionally include surrounding context lines. Skips obvious binary
|
|
462
|
-
files/directories.
|
|
463
|
-
"""
|
|
464
|
-
if not pattern:
|
|
465
|
-
raise ToolError("Pattern is required.")
|
|
466
|
-
try:
|
|
467
|
-
flags = re.IGNORECASE if case_insensitive else 0
|
|
468
|
-
regex = re.compile(pattern, flags)
|
|
469
|
-
except re.error as exc:
|
|
470
|
-
raise ToolError(f"Invalid regex: {exc}") from exc
|
|
471
|
-
|
|
472
|
-
target = _resolve_path(path)
|
|
473
|
-
if not target.exists() or not target.is_dir():
|
|
474
|
-
raise ToolError("Path is not a directory.")
|
|
475
|
-
|
|
476
|
-
max_results = max(1, min(int(max_results), 500))
|
|
477
|
-
context_lines = max(0, min(int(context_lines), 8))
|
|
478
|
-
matches: List[Dict[str, Any]] = []
|
|
479
|
-
files_scanned = 0
|
|
480
|
-
files_with_matches = 0
|
|
481
|
-
|
|
482
|
-
iterator = target.rglob(glob) if glob else target.rglob("*")
|
|
483
|
-
for file_path in iterator:
|
|
484
|
-
if len(matches) >= max_results:
|
|
485
|
-
break
|
|
486
|
-
if not file_path.is_file():
|
|
487
|
-
continue
|
|
488
|
-
if file_path.suffix.lower() in _GREP_BINARY_EXTS:
|
|
489
|
-
continue
|
|
490
|
-
if any(part in _GREP_BINARY_DIRS for part in file_path.parts):
|
|
491
|
-
continue
|
|
492
|
-
if file_path.stat().st_size > MAX_FILE_BYTES:
|
|
493
|
-
continue
|
|
494
|
-
try:
|
|
495
|
-
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
496
|
-
except (UnicodeDecodeError, OSError):
|
|
497
|
-
continue
|
|
498
|
-
|
|
499
|
-
files_scanned += 1
|
|
500
|
-
file_had_match = False
|
|
501
|
-
for index, line in enumerate(lines, start=1):
|
|
502
|
-
if len(matches) >= max_results:
|
|
503
|
-
break
|
|
504
|
-
if not regex.search(line):
|
|
505
|
-
continue
|
|
506
|
-
file_had_match = True
|
|
507
|
-
entry: Dict[str, Any] = {
|
|
508
|
-
"path": _relative(file_path),
|
|
509
|
-
"line": index,
|
|
510
|
-
"match": line[:400],
|
|
511
|
-
}
|
|
512
|
-
if context_lines:
|
|
513
|
-
lo = max(0, index - 1 - context_lines)
|
|
514
|
-
hi = min(len(lines), index + context_lines)
|
|
515
|
-
entry["context"] = [
|
|
516
|
-
{"line": lo + i + 1, "text": lines[lo + i][:200]}
|
|
517
|
-
for i in range(hi - lo)
|
|
518
|
-
]
|
|
519
|
-
matches.append(entry)
|
|
520
|
-
if file_had_match:
|
|
521
|
-
files_with_matches += 1
|
|
522
|
-
|
|
523
|
-
return {
|
|
524
|
-
"pattern": pattern,
|
|
525
|
-
"matches": matches,
|
|
526
|
-
"files_scanned": files_scanned,
|
|
527
|
-
"files_with_matches": files_with_matches,
|
|
528
|
-
"truncated": len(matches) >= max_results,
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
_TODO_REL_PATH = ".lattice/todos.json"
|
|
533
|
-
_TODO_ALLOWED_STATUS = {"pending", "in_progress", "completed"}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
def _todo_file() -> Path:
|
|
537
|
-
ensure_agent_root()
|
|
538
|
-
target = AGENT_ROOT / _TODO_REL_PATH
|
|
539
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
540
|
-
return target
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
def todo_read() -> Dict[str, Any]:
|
|
544
|
-
"""Read the agent's persistent TODO list (per-workspace)."""
|
|
545
|
-
target = _todo_file()
|
|
546
|
-
if not target.exists():
|
|
547
|
-
return {"todos": [], "path": _TODO_REL_PATH}
|
|
548
|
-
try:
|
|
549
|
-
todos = json.loads(target.read_text(encoding="utf-8"))
|
|
550
|
-
except (json.JSONDecodeError, OSError):
|
|
551
|
-
todos = []
|
|
552
|
-
if not isinstance(todos, list):
|
|
553
|
-
todos = []
|
|
554
|
-
return {"todos": todos, "path": _TODO_REL_PATH}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
def todo_write(todos: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
558
|
-
"""Replace the agent's TODO list. Each todo: {id, content, status}.
|
|
559
|
-
|
|
560
|
-
Status must be one of: pending, in_progress, completed.
|
|
561
|
-
At most one todo should be in_progress at any time — the agent enforces
|
|
562
|
-
this convention; the tool only warns if violated.
|
|
563
|
-
"""
|
|
564
|
-
if not isinstance(todos, list):
|
|
565
|
-
raise ToolError("todos must be a list.")
|
|
566
|
-
if len(todos) > 50:
|
|
567
|
-
raise ToolError("Too many todos (max 50). Split into smaller batches.")
|
|
568
|
-
|
|
569
|
-
cleaned: List[Dict[str, Any]] = []
|
|
570
|
-
in_progress_count = 0
|
|
571
|
-
for idx, raw in enumerate(todos, start=1):
|
|
572
|
-
if not isinstance(raw, dict):
|
|
573
|
-
raise ToolError(f"Todo #{idx} is not an object.")
|
|
574
|
-
content = str(raw.get("content") or "").strip()
|
|
575
|
-
if not content:
|
|
576
|
-
raise ToolError(f"Todo #{idx} is missing 'content'.")
|
|
577
|
-
status = str(raw.get("status") or "pending").strip().lower()
|
|
578
|
-
if status not in _TODO_ALLOWED_STATUS:
|
|
579
|
-
raise ToolError(f"Todo #{idx} has invalid status '{status}'. Use one of {sorted(_TODO_ALLOWED_STATUS)}.")
|
|
580
|
-
if status == "in_progress":
|
|
581
|
-
in_progress_count += 1
|
|
582
|
-
cleaned.append({
|
|
583
|
-
"id": str(raw.get("id") or idx),
|
|
584
|
-
"content": content[:240],
|
|
585
|
-
"status": status,
|
|
586
|
-
})
|
|
587
|
-
|
|
588
|
-
target = _todo_file()
|
|
589
|
-
target.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
590
|
-
return {
|
|
591
|
-
"todos": cleaned,
|
|
592
|
-
"path": _TODO_REL_PATH,
|
|
593
|
-
"warning": "More than one todo is in_progress; keep only one active at a time." if in_progress_count > 1 else None,
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
def search_files(query: str, path: str = ".", max_results: int = 20) -> Dict[str, Any]:
|
|
598
|
-
if not query:
|
|
599
|
-
raise ToolError("Query is required.")
|
|
600
|
-
target = _resolve_path(path)
|
|
601
|
-
if not target.exists() or not target.is_dir():
|
|
602
|
-
raise ToolError("Path is not a directory.")
|
|
603
|
-
|
|
604
|
-
max_results = max(1, min(int(max_results), 100))
|
|
605
|
-
matches: List[Dict[str, Any]] = []
|
|
606
|
-
query_lower = query.lower()
|
|
607
|
-
|
|
608
|
-
for file_path in target.rglob("*"):
|
|
609
|
-
if len(matches) >= max_results:
|
|
610
|
-
break
|
|
611
|
-
if not file_path.is_file() or file_path.stat().st_size > MAX_FILE_BYTES:
|
|
612
|
-
continue
|
|
613
|
-
if file_path.suffix.lower() not in TEXT_EXTENSIONS:
|
|
614
|
-
continue
|
|
615
|
-
try:
|
|
616
|
-
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
617
|
-
except UnicodeDecodeError:
|
|
618
|
-
continue
|
|
619
|
-
for index, line in enumerate(lines, start=1):
|
|
620
|
-
if query_lower in line.lower():
|
|
621
|
-
matches.append({"path": _relative(file_path), "line": index, "preview": line[:240]})
|
|
622
|
-
break
|
|
623
|
-
|
|
624
|
-
return {"query": query, "matches": matches}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
class _HTMLInspector(HTMLParser):
|
|
628
|
-
def __init__(self):
|
|
629
|
-
super().__init__()
|
|
630
|
-
self.title = ""
|
|
631
|
-
self._in_title = False
|
|
632
|
-
self.links: List[str] = []
|
|
633
|
-
self.scripts: List[str] = []
|
|
634
|
-
self.stylesheets: List[str] = []
|
|
635
|
-
self.images: List[str] = []
|
|
636
|
-
self.forms = 0
|
|
637
|
-
self.headings: List[Dict[str, str]] = []
|
|
638
|
-
|
|
639
|
-
def handle_starttag(self, tag: str, attrs: List[tuple]) -> None:
|
|
640
|
-
attr = dict(attrs)
|
|
641
|
-
if tag == "title":
|
|
642
|
-
self._in_title = True
|
|
643
|
-
elif tag == "a" and attr.get("href"):
|
|
644
|
-
self.links.append(attr["href"])
|
|
645
|
-
elif tag == "script" and attr.get("src"):
|
|
646
|
-
self.scripts.append(attr["src"])
|
|
647
|
-
elif tag == "link" and attr.get("rel") and "stylesheet" in " ".join(attr.get("rel", [])):
|
|
648
|
-
if attr.get("href"):
|
|
649
|
-
self.stylesheets.append(attr["href"])
|
|
650
|
-
elif tag == "img" and attr.get("src"):
|
|
651
|
-
self.images.append(attr["src"])
|
|
652
|
-
elif tag == "form":
|
|
653
|
-
self.forms += 1
|
|
654
|
-
elif tag in {"h1", "h2", "h3"}:
|
|
655
|
-
self.headings.append({"level": tag, "text": ""})
|
|
656
|
-
|
|
657
|
-
def handle_endtag(self, tag: str) -> None:
|
|
658
|
-
if tag == "title":
|
|
659
|
-
self._in_title = False
|
|
660
|
-
|
|
661
|
-
def handle_data(self, data: str) -> None:
|
|
662
|
-
text = data.strip()
|
|
663
|
-
if not text:
|
|
664
|
-
return
|
|
665
|
-
if self._in_title:
|
|
666
|
-
self.title += text
|
|
667
|
-
elif self.headings and not self.headings[-1]["text"]:
|
|
668
|
-
self.headings[-1]["text"] = text[:120]
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def inspect_html(path: str) -> Dict[str, Any]:
|
|
672
|
-
target = _resolve_path(path)
|
|
673
|
-
if not target.exists() or not target.is_file():
|
|
674
|
-
raise ToolError("HTML file does not exist.")
|
|
675
|
-
if target.suffix.lower() not in {".html", ".htm"}:
|
|
676
|
-
raise ToolError("Path is not an HTML file.")
|
|
677
|
-
if target.stat().st_size > MAX_FILE_BYTES:
|
|
678
|
-
raise ToolError("HTML file is too large to inspect.")
|
|
679
|
-
|
|
680
|
-
parser = _HTMLInspector()
|
|
681
|
-
parser.feed(target.read_text(encoding="utf-8"))
|
|
682
|
-
return {
|
|
683
|
-
"path": _relative(target),
|
|
684
|
-
"title": parser.title,
|
|
685
|
-
"links": parser.links[:50],
|
|
686
|
-
"scripts": parser.scripts[:50],
|
|
687
|
-
"stylesheets": parser.stylesheets[:50],
|
|
688
|
-
"images": parser.images[:50],
|
|
689
|
-
"forms": parser.forms,
|
|
690
|
-
"headings": [h for h in parser.headings if h["text"]][:30],
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
def preview_url(path: str = "index.html") -> Dict[str, Any]:
|
|
695
|
-
target = _resolve_path(path)
|
|
696
|
-
if not target.exists() or not target.is_file():
|
|
697
|
-
raise ToolError("Preview file does not exist.")
|
|
698
|
-
rel = _relative(target)
|
|
699
|
-
return {
|
|
700
|
-
"path": rel,
|
|
701
|
-
"local_url": f"http://127.0.0.1:4825/agent-files/{rel}",
|
|
702
|
-
"note": "Use the server host or /web Telegram link host instead of 127.0.0.1 from a phone.",
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
def create_web_project(path: str, framework: str = "react", template: str = "vite") -> Dict[str, Any]:
|
|
707
|
-
framework = str(framework or "").strip().lower()
|
|
708
|
-
template = str(template or "").strip().lower()
|
|
709
|
-
if framework != "react" or template != "vite":
|
|
710
|
-
raise ToolError("Only React + Vite template is currently supported.")
|
|
711
|
-
if not path:
|
|
712
|
-
raise ToolError("Project path is required.")
|
|
713
|
-
|
|
714
|
-
root = _resolve_path(path)
|
|
715
|
-
root.mkdir(parents=True, exist_ok=True)
|
|
716
|
-
|
|
717
|
-
files = {
|
|
718
|
-
"package.json": json.dumps(
|
|
719
|
-
{
|
|
720
|
-
"name": Path(path).name.replace(" ", "-").lower() or "vite-react-app",
|
|
721
|
-
"private": True,
|
|
722
|
-
"version": "0.0.0",
|
|
723
|
-
"type": "module",
|
|
724
|
-
"scripts": {
|
|
725
|
-
"dev": "vite",
|
|
726
|
-
"build": "vite build",
|
|
727
|
-
"preview": "vite preview",
|
|
728
|
-
},
|
|
729
|
-
"dependencies": {
|
|
730
|
-
"react": "^18.3.1",
|
|
731
|
-
"react-dom": "^18.3.1",
|
|
732
|
-
},
|
|
733
|
-
"devDependencies": {
|
|
734
|
-
"@vitejs/plugin-react": "^4.3.1",
|
|
735
|
-
"vite": "^5.4.0",
|
|
736
|
-
},
|
|
737
|
-
},
|
|
738
|
-
ensure_ascii=False,
|
|
739
|
-
indent=2,
|
|
740
|
-
) + "\n",
|
|
741
|
-
"index.html": """<!doctype html>
|
|
742
|
-
<html lang="en">
|
|
743
|
-
<head>
|
|
744
|
-
<meta charset="UTF-8" />
|
|
745
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
746
|
-
<title>Vite React App</title>
|
|
747
|
-
</head>
|
|
748
|
-
<body>
|
|
749
|
-
<div id="root"></div>
|
|
750
|
-
<script type="module" src="/src/main.jsx"></script>
|
|
751
|
-
</body>
|
|
752
|
-
</html>
|
|
753
|
-
""",
|
|
754
|
-
"vite.config.js": """import { defineConfig } from 'vite'
|
|
755
|
-
import react from '@vitejs/plugin-react'
|
|
756
|
-
|
|
757
|
-
export default defineConfig({
|
|
758
|
-
plugins: [react()],
|
|
759
|
-
})
|
|
760
|
-
""",
|
|
761
|
-
"README.md": """# Vite React App
|
|
762
|
-
|
|
763
|
-
## Run
|
|
764
|
-
|
|
765
|
-
```bash
|
|
766
|
-
npm install
|
|
767
|
-
npm run dev
|
|
768
|
-
```
|
|
769
|
-
|
|
770
|
-
## Build
|
|
771
|
-
|
|
772
|
-
```bash
|
|
773
|
-
npm run build
|
|
774
|
-
npm run preview
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
## Lattice AI Notes
|
|
778
|
-
|
|
779
|
-
- Inspect `package.json` and existing config files before adding new libraries.
|
|
780
|
-
- If you add Tailwind CSS, framer-motion, TypeScript, or other tooling, add the required config files too.
|
|
781
|
-
- Do not report the app as complete until `npm run build` succeeds.
|
|
782
|
-
""",
|
|
783
|
-
"src/main.jsx": """import React from 'react'
|
|
784
|
-
import ReactDOM from 'react-dom/client'
|
|
785
|
-
import App from './App.jsx'
|
|
786
|
-
import './index.css'
|
|
787
|
-
|
|
788
|
-
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
789
|
-
<React.StrictMode>
|
|
790
|
-
<App />
|
|
791
|
-
</React.StrictMode>,
|
|
792
|
-
)
|
|
793
|
-
""",
|
|
794
|
-
"src/App.jsx": """import { useState } from 'react'
|
|
795
|
-
|
|
796
|
-
export default function App() {
|
|
797
|
-
const [count, setCount] = useState(0)
|
|
798
|
-
return (
|
|
799
|
-
<main style={{ maxWidth: 760, margin: '48px auto', padding: '0 20px', fontFamily: 'system-ui, sans-serif' }}>
|
|
800
|
-
<h1>Vite + React</h1>
|
|
801
|
-
<p>Starter generated by Lattice AI agent.</p>
|
|
802
|
-
<p style={{ color: '#555', lineHeight: 1.6 }}>
|
|
803
|
-
Inspect the current setup before adding new UI libraries, then verify
|
|
804
|
-
changes with <code>npm run build</code>.
|
|
805
|
-
</p>
|
|
806
|
-
<button onClick={() => setCount((c) => c + 1)}>count is {count}</button>
|
|
807
|
-
</main>
|
|
808
|
-
)
|
|
809
|
-
}
|
|
810
|
-
""",
|
|
811
|
-
"src/index.css": """* { box-sizing: border-box; }
|
|
812
|
-
body { margin: 0; background: #f6f7fb; color: #111; }
|
|
813
|
-
button { padding: 10px 14px; border-radius: 10px; border: 1px solid #d6d6d6; background: #fff; cursor: pointer; }
|
|
814
|
-
""",
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
created: List[str] = []
|
|
818
|
-
total_bytes = 0
|
|
819
|
-
for rel_path, content in files.items():
|
|
820
|
-
target = (root / rel_path).resolve()
|
|
821
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
822
|
-
target.write_text(content, encoding="utf-8")
|
|
823
|
-
created.append(_relative(target))
|
|
824
|
-
total_bytes += target.stat().st_size
|
|
825
|
-
|
|
826
|
-
return {
|
|
827
|
-
"path": _relative(root),
|
|
828
|
-
"framework": framework,
|
|
829
|
-
"template": template,
|
|
830
|
-
"created_files": created,
|
|
831
|
-
"file_count": len(created),
|
|
832
|
-
"bytes": total_bytes,
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
def _safe_filename(name: str, suffix: str) -> str:
|
|
837
|
-
base = Path(name or f"artifact{suffix}").name
|
|
838
|
-
if not base.lower().endswith(suffix):
|
|
839
|
-
base += suffix
|
|
840
|
-
safe = "".join(ch if ch.isalnum() or ch in ("-", "_", ".", " ") else "_" for ch in base).strip()
|
|
841
|
-
return safe or f"artifact{suffix}"
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
def _body_to_str(body) -> str:
|
|
845
|
-
if isinstance(body, list):
|
|
846
|
-
return "\n\n".join(str(item) for item in body)
|
|
847
|
-
return str(body or "")
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
def create_docx(title: str, body, filename: str = "document.docx") -> Dict[str, Any]:
|
|
851
|
-
try:
|
|
852
|
-
from docx import Document
|
|
853
|
-
except Exception as exc:
|
|
854
|
-
raise ToolError("python-docx is not installed. Run `pip install -r requirements.txt`.") from exc
|
|
855
|
-
|
|
856
|
-
output_dir = _resolve_path(DOCUMENT_OUTPUT_DIR)
|
|
857
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
858
|
-
target = output_dir / _safe_filename(filename, ".docx")
|
|
859
|
-
|
|
860
|
-
document = Document()
|
|
861
|
-
if title:
|
|
862
|
-
document.add_heading(str(title), level=1)
|
|
863
|
-
for block in _body_to_str(body).split("\n\n"):
|
|
864
|
-
text = block.strip()
|
|
865
|
-
if text:
|
|
866
|
-
document.add_paragraph(text)
|
|
867
|
-
document.save(target)
|
|
868
|
-
return {"path": _relative(target), "bytes": target.stat().st_size}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
def create_xlsx(rows: List[List[Any]], filename: str = "spreadsheet.xlsx", sheet_name: str = "Sheet1") -> Dict[str, Any]:
|
|
872
|
-
try:
|
|
873
|
-
from openpyxl import Workbook
|
|
874
|
-
except Exception as exc:
|
|
875
|
-
raise ToolError("openpyxl is not installed. Run `pip install -r requirements.txt`.") from exc
|
|
876
|
-
|
|
877
|
-
if not isinstance(rows, list) or not all(isinstance(row, list) for row in rows):
|
|
878
|
-
raise ToolError("Rows must be a list of lists.")
|
|
879
|
-
|
|
880
|
-
output_dir = _resolve_path(SPREADSHEET_OUTPUT_DIR)
|
|
881
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
882
|
-
target = output_dir / _safe_filename(filename, ".xlsx")
|
|
883
|
-
|
|
884
|
-
workbook = Workbook()
|
|
885
|
-
sheet = workbook.active
|
|
886
|
-
sheet.title = (sheet_name or "Sheet1")[:31]
|
|
887
|
-
for row in rows:
|
|
888
|
-
sheet.append(row)
|
|
889
|
-
workbook.save(target)
|
|
890
|
-
return {"path": _relative(target), "rows": len(rows), "bytes": target.stat().st_size}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
def create_pptx(title: str, slides: List[Dict[str, Any]], filename: str = "presentation.pptx") -> Dict[str, Any]:
|
|
894
|
-
try:
|
|
895
|
-
from pptx import Presentation
|
|
896
|
-
except Exception as exc:
|
|
897
|
-
raise ToolError("python-pptx is not installed. Run `pip install -r requirements.txt`.") from exc
|
|
898
|
-
|
|
899
|
-
output_dir = _resolve_path(PRESENTATION_OUTPUT_DIR)
|
|
900
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
901
|
-
target = output_dir / _safe_filename(filename, ".pptx")
|
|
902
|
-
|
|
903
|
-
presentation = Presentation()
|
|
904
|
-
first_layout = presentation.slide_layouts[0]
|
|
905
|
-
first = presentation.slides.add_slide(first_layout)
|
|
906
|
-
first.shapes.title.text = title or "Presentation"
|
|
907
|
-
first.placeholders[1].text = ""
|
|
908
|
-
|
|
909
|
-
content_layout = presentation.slide_layouts[1]
|
|
910
|
-
for slide_data in slides or []:
|
|
911
|
-
slide = presentation.slides.add_slide(content_layout)
|
|
912
|
-
slide.shapes.title.text = str(slide_data.get("title") or "Slide")
|
|
913
|
-
body = slide.placeholders[1].text_frame
|
|
914
|
-
body.clear()
|
|
915
|
-
bullets = slide_data.get("bullets") or []
|
|
916
|
-
if isinstance(bullets, str):
|
|
917
|
-
bullets = [bullets]
|
|
918
|
-
for index, bullet in enumerate(bullets):
|
|
919
|
-
paragraph = body.paragraphs[0] if index == 0 else body.add_paragraph()
|
|
920
|
-
paragraph.text = str(bullet)
|
|
921
|
-
paragraph.level = 0
|
|
922
|
-
|
|
923
|
-
presentation.save(target)
|
|
924
|
-
return {"path": _relative(target), "slides": len(presentation.slides), "bytes": target.stat().st_size}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
PDF_OUTPUT_DIR = "generated_pdfs"
|
|
928
|
-
LOCAL_MAX_FILE_BYTES = 2_000_000 # 2 MB cap for local reads
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
# CJK-capable fonts (Korean + Chinese + Japanese)
|
|
932
|
-
_CJK_FONT_CANDIDATES = [
|
|
933
|
-
"/System/Library/Fonts/AppleSDGothicNeo.ttc", # Korean (macOS)
|
|
934
|
-
"/System/Library/Fonts/STHeiti Light.ttc", # Chinese (macOS)
|
|
935
|
-
"/System/Library/Fonts/PingFang.ttc", # Chinese (macOS)
|
|
936
|
-
"/Library/Fonts/NanumGothic.ttf", # Korean
|
|
937
|
-
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
|
|
938
|
-
]
|
|
939
|
-
|
|
940
|
-
_SUPPORTED_READ_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
|
|
941
|
-
DOCUMENT_MAX_READ_BYTES = 10_000_000 # 10 MB
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
def create_pdf(title: str, body, filename: str = "document.pdf") -> Dict[str, Any]:
|
|
945
|
-
try:
|
|
946
|
-
from reportlab.lib.pagesizes import A4
|
|
947
|
-
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
948
|
-
from reportlab.lib.units import mm
|
|
949
|
-
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
|
950
|
-
from reportlab.pdfbase import pdfmetrics
|
|
951
|
-
from reportlab.pdfbase.ttfonts import TTFont
|
|
952
|
-
except Exception as exc:
|
|
953
|
-
raise ToolError("reportlab is not installed. Run `pip install reportlab`.") from exc
|
|
954
|
-
|
|
955
|
-
output_dir = _resolve_path(PDF_OUTPUT_DIR)
|
|
956
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
957
|
-
target = output_dir / _safe_filename(filename, ".pdf")
|
|
958
|
-
|
|
959
|
-
# CJK 폰트 등록
|
|
960
|
-
font_name = "Helvetica"
|
|
961
|
-
for font_path in _CJK_FONT_CANDIDATES:
|
|
962
|
-
if Path(font_path).exists():
|
|
963
|
-
try:
|
|
964
|
-
pdfmetrics.registerFont(TTFont("KoreanFont", font_path))
|
|
965
|
-
font_name = "KoreanFont"
|
|
966
|
-
except Exception:
|
|
967
|
-
pass
|
|
968
|
-
break
|
|
969
|
-
|
|
970
|
-
styles = getSampleStyleSheet()
|
|
971
|
-
title_style = ParagraphStyle("Title", fontName=font_name, fontSize=18, spaceAfter=8, leading=24)
|
|
972
|
-
body_style = ParagraphStyle("Body", fontName=font_name, fontSize=11, spaceAfter=6, leading=16)
|
|
973
|
-
|
|
974
|
-
story = []
|
|
975
|
-
if title:
|
|
976
|
-
story.append(Paragraph(str(title), title_style))
|
|
977
|
-
story.append(Spacer(1, 4 * mm))
|
|
978
|
-
|
|
979
|
-
for block in _body_to_str(body).split("\n\n"):
|
|
980
|
-
text = block.strip()
|
|
981
|
-
if text:
|
|
982
|
-
safe_text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
983
|
-
story.append(Paragraph(safe_text, body_style))
|
|
984
|
-
story.append(Spacer(1, 2 * mm))
|
|
985
|
-
|
|
986
|
-
doc = SimpleDocTemplate(str(target), pagesize=A4,
|
|
987
|
-
leftMargin=20*mm, rightMargin=20*mm,
|
|
988
|
-
topMargin=20*mm, bottomMargin=20*mm)
|
|
989
|
-
doc.build(story)
|
|
990
|
-
return {"path": _relative(target), "bytes": target.stat().st_size}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
def local_list(path: str) -> Dict[str, Any]:
|
|
994
|
-
"""List any directory on the local filesystem (requires user approval via UI)."""
|
|
995
|
-
target = Path(path).expanduser().resolve()
|
|
996
|
-
if not target.exists():
|
|
997
|
-
raise ToolError(f"경로가 존재하지 않습니다: {path}")
|
|
998
|
-
if not target.is_dir():
|
|
999
|
-
raise ToolError(f"폴더가 아닙니다: {path}")
|
|
1000
|
-
items = []
|
|
1001
|
-
try:
|
|
1002
|
-
for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
1003
|
-
stat = child.stat()
|
|
1004
|
-
items.append({
|
|
1005
|
-
"name": child.name,
|
|
1006
|
-
"path": str(child),
|
|
1007
|
-
"type": "directory" if child.is_dir() else "file",
|
|
1008
|
-
"size": stat.st_size if child.is_file() else None,
|
|
1009
|
-
})
|
|
1010
|
-
except PermissionError as exc:
|
|
1011
|
-
raise ToolError(f"접근 권한 없음: {exc}") from exc
|
|
1012
|
-
return {"path": str(target), "items": items}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
def local_read(path: str) -> Dict[str, Any]:
|
|
1016
|
-
"""Read any file on the local filesystem (requires user approval via UI)."""
|
|
1017
|
-
target = Path(path).expanduser().resolve()
|
|
1018
|
-
if not target.exists():
|
|
1019
|
-
raise ToolError(f"파일이 존재하지 않습니다: {path}")
|
|
1020
|
-
if not target.is_file():
|
|
1021
|
-
raise ToolError(f"파일이 아닙니다: {path}")
|
|
1022
|
-
size = target.stat().st_size
|
|
1023
|
-
if size > LOCAL_MAX_FILE_BYTES:
|
|
1024
|
-
raise ToolError(f"파일이 너무 큽니다 ({size:,} bytes). 최대 {LOCAL_MAX_FILE_BYTES:,} bytes.")
|
|
1025
|
-
try:
|
|
1026
|
-
content = target.read_text(encoding="utf-8", errors="replace")
|
|
1027
|
-
except Exception as exc:
|
|
1028
|
-
raise ToolError(f"파일 읽기 실패: {exc}") from exc
|
|
1029
|
-
return {"path": str(target), "size": size, "content": content}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
def local_write(path: str, content: str) -> Dict[str, Any]:
|
|
1033
|
-
"""Write content to any path on the local filesystem (requires user approval via UI)."""
|
|
1034
|
-
target = Path(path).expanduser().resolve()
|
|
1035
|
-
if len(content.encode("utf-8")) > LOCAL_MAX_FILE_BYTES:
|
|
1036
|
-
raise ToolError("내용이 너무 큽니다.")
|
|
1037
|
-
try:
|
|
1038
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1039
|
-
target.write_text(content, encoding="utf-8")
|
|
1040
|
-
except PermissionError as exc:
|
|
1041
|
-
raise ToolError(f"쓰기 권한 없음: {exc}") from exc
|
|
1042
|
-
return {"path": str(target), "bytes": target.stat().st_size}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
def read_document(path: str) -> Dict[str, Any]:
|
|
1046
|
-
"""Extract text from PDF, DOCX, XLSX, PPTX, TXT, MD, CSV files."""
|
|
1047
|
-
target = Path(path).expanduser().resolve()
|
|
1048
|
-
if not target.exists():
|
|
1049
|
-
raise ToolError(f"파일이 없습니다: {path}")
|
|
1050
|
-
if not target.is_file():
|
|
1051
|
-
raise ToolError(f"파일이 아닙니다: {path}")
|
|
1052
|
-
if target.stat().st_size > DOCUMENT_MAX_READ_BYTES:
|
|
1053
|
-
raise ToolError(f"파일이 너무 큽니다 ({target.stat().st_size:,} bytes).")
|
|
1054
|
-
|
|
1055
|
-
ext = target.suffix.lower()
|
|
1056
|
-
if ext not in _SUPPORTED_READ_EXTENSIONS:
|
|
1057
|
-
raise ToolError(f"지원하지 않는 형식입니다: {ext}. 지원: {', '.join(_SUPPORTED_READ_EXTENSIONS)}")
|
|
1058
|
-
|
|
1059
|
-
text = ""
|
|
1060
|
-
meta: Dict[str, Any] = {"path": str(target), "ext": ext}
|
|
1061
|
-
|
|
1062
|
-
if ext == ".pdf":
|
|
1063
|
-
try:
|
|
1064
|
-
import pdfplumber
|
|
1065
|
-
with pdfplumber.open(str(target)) as pdf:
|
|
1066
|
-
meta["pages"] = len(pdf.pages)
|
|
1067
|
-
text = "\n\n".join(
|
|
1068
|
-
(p.extract_text() or "") for p in pdf.pages
|
|
1069
|
-
).strip()
|
|
1070
|
-
except Exception as exc:
|
|
1071
|
-
raise ToolError(f"PDF 읽기 실패: {exc}") from exc
|
|
1072
|
-
|
|
1073
|
-
elif ext == ".docx":
|
|
1074
|
-
try:
|
|
1075
|
-
from docx import Document
|
|
1076
|
-
doc = Document(str(target))
|
|
1077
|
-
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
|
1078
|
-
text = "\n\n".join(paragraphs)
|
|
1079
|
-
meta["paragraphs"] = len(paragraphs)
|
|
1080
|
-
except Exception as exc:
|
|
1081
|
-
raise ToolError(f"DOCX 읽기 실패: {exc}") from exc
|
|
1082
|
-
|
|
1083
|
-
elif ext == ".xlsx":
|
|
1084
|
-
try:
|
|
1085
|
-
from openpyxl import load_workbook
|
|
1086
|
-
wb = load_workbook(str(target), data_only=True)
|
|
1087
|
-
rows_all = []
|
|
1088
|
-
for ws in wb.worksheets:
|
|
1089
|
-
rows_all.append(f"[Sheet: {ws.title}]")
|
|
1090
|
-
for row in ws.iter_rows(values_only=True):
|
|
1091
|
-
cells = [str(c) if c is not None else "" for c in row]
|
|
1092
|
-
rows_all.append("\t".join(cells))
|
|
1093
|
-
text = "\n".join(rows_all)
|
|
1094
|
-
meta["sheets"] = len(wb.worksheets)
|
|
1095
|
-
except Exception as exc:
|
|
1096
|
-
raise ToolError(f"XLSX 읽기 실패: {exc}") from exc
|
|
1097
|
-
|
|
1098
|
-
elif ext == ".pptx":
|
|
1099
|
-
try:
|
|
1100
|
-
from pptx import Presentation
|
|
1101
|
-
prs = Presentation(str(target))
|
|
1102
|
-
slides_text = []
|
|
1103
|
-
for i, slide in enumerate(prs.slides, 1):
|
|
1104
|
-
parts = []
|
|
1105
|
-
for shape in slide.shapes:
|
|
1106
|
-
if shape.has_text_frame:
|
|
1107
|
-
parts.append(shape.text_frame.text)
|
|
1108
|
-
slides_text.append(f"[Slide {i}]\n" + "\n".join(parts))
|
|
1109
|
-
text = "\n\n".join(slides_text)
|
|
1110
|
-
meta["slides"] = len(prs.slides)
|
|
1111
|
-
except Exception as exc:
|
|
1112
|
-
raise ToolError(f"PPTX 읽기 실패: {exc}") from exc
|
|
1113
|
-
|
|
1114
|
-
elif ext in {".txt", ".md", ".csv"}:
|
|
1115
|
-
try:
|
|
1116
|
-
text = target.read_text(encoding="utf-8", errors="replace")
|
|
1117
|
-
except Exception as exc:
|
|
1118
|
-
raise ToolError(f"파일 읽기 실패: {exc}") from exc
|
|
1119
|
-
|
|
1120
|
-
meta["chars"] = len(text)
|
|
1121
|
-
meta["preview"] = text[:500]
|
|
1122
|
-
meta["content"] = text[:50_000] # 50K char cap for context
|
|
1123
|
-
return meta
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
def desktop_bridge_status() -> Dict[str, Any]:
|
|
1127
|
-
return {
|
|
1128
|
-
"status": "requires_desktop_bridge",
|
|
1129
|
-
"available_in_codex": True,
|
|
1130
|
-
"note": "Chrome and Mac UI control require the Codex desktop Computer Use/Chrome bridge, not a headless FastAPI worker.",
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
def _safe_brain_folder(folder: str) -> str:
|
|
1135
|
-
if folder not in STRUCTURE:
|
|
1136
|
-
raise ToolError(f"Unknown knowledge folder: {folder}")
|
|
1137
|
-
return folder
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
def knowledge_save(content: str, folder: str = "00_Raw", title: Optional[str] = None) -> Dict[str, Any]:
|
|
1141
|
-
folder = _safe_brain_folder(folder)
|
|
1142
|
-
if not content:
|
|
1143
|
-
raise ToolError("Knowledge content is required.")
|
|
1144
|
-
if len(content.encode("utf-8")) > MAX_FILE_BYTES:
|
|
1145
|
-
raise ToolError("Knowledge content is too large.")
|
|
1146
|
-
|
|
1147
|
-
target_dir = BRAIN_DIR / folder
|
|
1148
|
-
target_dir.mkdir(parents=True, exist_ok=True)
|
|
1149
|
-
safe_title = title or content.strip().splitlines()[0][:60] or "note"
|
|
1150
|
-
safe_title = "".join(ch if ch.isalnum() or ch in (" ", "-", "_") else "" for ch in safe_title).strip()
|
|
1151
|
-
safe_title = "_".join(safe_title.split()) or "note"
|
|
1152
|
-
filename = f"{safe_title}.md"
|
|
1153
|
-
target = target_dir / filename
|
|
1154
|
-
counter = 2
|
|
1155
|
-
while target.exists():
|
|
1156
|
-
target = target_dir / f"{safe_title}_{counter}.md"
|
|
1157
|
-
counter += 1
|
|
1158
|
-
target.write_text(content, encoding="utf-8")
|
|
1159
|
-
return {"folder": folder, "filename": target.name, "path": str(target)}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
def knowledge_search(query: str, max_results: int = 5) -> Dict[str, Any]:
|
|
1163
|
-
if not query:
|
|
1164
|
-
raise ToolError("Query is required.")
|
|
1165
|
-
max_results = max(1, min(int(max_results), 20))
|
|
1166
|
-
query_lower = query.lower()
|
|
1167
|
-
results: List[Dict[str, Any]] = []
|
|
1168
|
-
|
|
1169
|
-
for file_path in BRAIN_DIR.rglob("*.md"):
|
|
1170
|
-
if len(results) >= max_results:
|
|
1171
|
-
break
|
|
1172
|
-
try:
|
|
1173
|
-
content = file_path.read_text(encoding="utf-8")
|
|
1174
|
-
except UnicodeDecodeError:
|
|
1175
|
-
continue
|
|
1176
|
-
if query_lower in content.lower() or query_lower in file_path.name.lower():
|
|
1177
|
-
results.append(
|
|
1178
|
-
{
|
|
1179
|
-
"path": str(file_path),
|
|
1180
|
-
"relative_path": str(file_path.relative_to(BRAIN_DIR)),
|
|
1181
|
-
"preview": content[:500],
|
|
1182
|
-
}
|
|
1183
|
-
)
|
|
1184
|
-
|
|
1185
|
-
return {"query": query, "results": results}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
def knowledge_tree() -> Dict[str, Any]:
|
|
1189
|
-
entries: List[Dict[str, Any]] = []
|
|
1190
|
-
for folder in STRUCTURE:
|
|
1191
|
-
root = BRAIN_DIR / folder
|
|
1192
|
-
root.mkdir(parents=True, exist_ok=True)
|
|
1193
|
-
for file_path in sorted(root.rglob("*.md")):
|
|
1194
|
-
entries.append(
|
|
1195
|
-
{
|
|
1196
|
-
"folder": folder,
|
|
1197
|
-
"relative_path": str(file_path.relative_to(BRAIN_DIR)),
|
|
1198
|
-
"size": file_path.stat().st_size,
|
|
1199
|
-
}
|
|
1200
|
-
)
|
|
1201
|
-
return {"root": str(BRAIN_DIR), "entries": entries}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
def obsidian_save(content: str, folder: str = "00_Raw", title: Optional[str] = None) -> Dict[str, Any]:
|
|
1205
|
-
result = knowledge_save(content, folder, title)
|
|
1206
|
-
result["vault_root"] = str(BRAIN_DIR)
|
|
1207
|
-
result["obsidian_uri_hint"] = f"obsidian://open?path={result['path']}"
|
|
1208
|
-
return result
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
def obsidian_search(query: str, max_results: int = 5) -> Dict[str, Any]:
|
|
1212
|
-
result = knowledge_search(query, max_results)
|
|
1213
|
-
result["vault_root"] = str(BRAIN_DIR)
|
|
1214
|
-
return result
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
def obsidian_tree() -> Dict[str, Any]:
|
|
1218
|
-
return knowledge_tree()
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
def _run_network_command(parts: List[str], timeout: int = 5) -> str:
|
|
1222
|
-
try:
|
|
1223
|
-
completed = subprocess.run(parts, capture_output=True, text=True, timeout=timeout, check=False)
|
|
1224
|
-
if completed.returncode != 0:
|
|
1225
|
-
return ""
|
|
1226
|
-
return completed.stdout.strip()
|
|
1227
|
-
except Exception:
|
|
1228
|
-
return ""
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
def network_status() -> Dict[str, Any]:
|
|
1232
|
-
"""현재 Mac의 내부 IP, 외부 IP, 주요 네트워크 정보를 반환합니다."""
|
|
1233
|
-
local_ips: Dict[str, str] = {}
|
|
1234
|
-
for interface in ["en0", "en1", "bridge100"]:
|
|
1235
|
-
value = _run_network_command(["ipconfig", "getifaddr", interface])
|
|
1236
|
-
if value:
|
|
1237
|
-
local_ips[interface] = value
|
|
1238
|
-
|
|
1239
|
-
ifconfig_text = _run_network_command(["ifconfig"])
|
|
1240
|
-
current_interface = ""
|
|
1241
|
-
for line in ifconfig_text.splitlines():
|
|
1242
|
-
if line and not line.startswith(("\t", " ")):
|
|
1243
|
-
current_interface = line.split(":", 1)[0]
|
|
1244
|
-
continue
|
|
1245
|
-
match = re.search(r"\binet\s+(\d+\.\d+\.\d+\.\d+)\b", line)
|
|
1246
|
-
if match and current_interface and match.group(1) != "127.0.0.1":
|
|
1247
|
-
local_ips.setdefault(current_interface, match.group(1))
|
|
1248
|
-
|
|
1249
|
-
hostname = socket.gethostname()
|
|
1250
|
-
guessed_ip = ""
|
|
1251
|
-
try:
|
|
1252
|
-
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
1253
|
-
sock.connect(("8.8.8.8", 80))
|
|
1254
|
-
guessed_ip = sock.getsockname()[0]
|
|
1255
|
-
except Exception:
|
|
1256
|
-
pass
|
|
1257
|
-
if guessed_ip and guessed_ip not in local_ips.values():
|
|
1258
|
-
local_ips["default_route"] = guessed_ip
|
|
1259
|
-
|
|
1260
|
-
public_ip = _run_network_command(["curl", "-sS", "--max-time", "3", "https://api.ipify.org"])
|
|
1261
|
-
wifi_info = _run_network_command(["networksetup", "-getinfo", "Wi-Fi"])
|
|
1262
|
-
|
|
1263
|
-
primary_local_ip = local_ips.get("en0") or local_ips.get("en1") or guessed_ip or ""
|
|
1264
|
-
return {
|
|
1265
|
-
"hostname": hostname,
|
|
1266
|
-
"local_ip": primary_local_ip,
|
|
1267
|
-
"local_ips": local_ips,
|
|
1268
|
-
"public_ip": public_ip,
|
|
1269
|
-
"wifi_info": wifi_info,
|
|
1270
|
-
"ifconfig_available": bool(ifconfig_text),
|
|
1271
|
-
"note": "local_ip은 같은 네트워크 안에서 보이는 내부 IP이고, public_ip는 인터넷에서 보이는 외부 IP입니다.",
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
_BLOCKED_FIND_FLAGS = {"-exec", "-execdir", "-delete", "-ok", "-okdir"}
|
|
1276
|
-
|
|
1277
|
-
def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
1278
|
-
ensure_agent_root()
|
|
1279
|
-
parts = shlex.split(command)
|
|
1280
|
-
if not parts:
|
|
1281
|
-
raise ToolError("Command is empty.")
|
|
1282
|
-
|
|
1283
|
-
executable = Path(parts[0]).name
|
|
1284
|
-
if executable in BLOCKED_COMMANDS or executable not in ALLOWED_COMMANDS:
|
|
1285
|
-
raise ToolError(f"Command is not allowed: {executable}")
|
|
1286
|
-
if executable == "git":
|
|
1287
|
-
raise ToolError("Use the read-only git_status, git_diff, git_log, or git_show tools.")
|
|
1288
|
-
if any(token in command for token in ["|", "&&", "||", ";", ">", "<", "$(", "`"]):
|
|
1289
|
-
raise ToolError("Shell operators are not allowed.")
|
|
1290
|
-
if executable == "find":
|
|
1291
|
-
blocked = [f for f in parts[1:] if f in _BLOCKED_FIND_FLAGS]
|
|
1292
|
-
if blocked:
|
|
1293
|
-
raise ToolError(f"find flags are not allowed: {', '.join(blocked)}")
|
|
1294
|
-
abs_args = [a for a in parts[1:] if a.startswith("/") and a not in ("/dev/null",)]
|
|
1295
|
-
if abs_args:
|
|
1296
|
-
raise ToolError(f"Absolute paths in command arguments are not allowed: {abs_args[0]}")
|
|
1297
|
-
|
|
1298
|
-
workdir = _resolve_path(cwd or ".")
|
|
1299
|
-
if not workdir.exists() or not workdir.is_dir():
|
|
1300
|
-
raise ToolError("Working directory does not exist.")
|
|
1301
|
-
|
|
1302
|
-
try:
|
|
1303
|
-
completed = subprocess.run(
|
|
1304
|
-
parts,
|
|
1305
|
-
cwd=workdir,
|
|
1306
|
-
capture_output=True,
|
|
1307
|
-
text=True,
|
|
1308
|
-
timeout=MAX_COMMAND_SECONDS,
|
|
1309
|
-
check=False,
|
|
1310
|
-
)
|
|
1311
|
-
except subprocess.TimeoutExpired:
|
|
1312
|
-
raise ToolError(f"Command timed out after {MAX_COMMAND_SECONDS} seconds.")
|
|
1313
|
-
|
|
1314
|
-
stdout = completed.stdout[-MAX_COMMAND_OUTPUT:]
|
|
1315
|
-
stderr = completed.stderr[-MAX_COMMAND_OUTPUT:]
|
|
1316
|
-
return {
|
|
1317
|
-
"command": command,
|
|
1318
|
-
"cwd": _relative(workdir) if workdir != AGENT_ROOT else ".",
|
|
1319
|
-
"returncode": completed.returncode,
|
|
1320
|
-
"stdout": stdout,
|
|
1321
|
-
"stderr": stderr,
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
def _load_package_scripts(workdir: Path) -> Dict[str, str]:
|
|
1326
|
-
package_json = workdir / "package.json"
|
|
1327
|
-
if not package_json.exists():
|
|
1328
|
-
return {}
|
|
1329
|
-
try:
|
|
1330
|
-
import json
|
|
1331
|
-
data = json.loads(package_json.read_text(encoding="utf-8"))
|
|
1332
|
-
except Exception as exc:
|
|
1333
|
-
raise ToolError(f"Could not parse package.json: {exc}") from exc
|
|
1334
|
-
scripts = data.get("scripts") or {}
|
|
1335
|
-
if not isinstance(scripts, dict):
|
|
1336
|
-
return {}
|
|
1337
|
-
return {str(key): str(value) for key, value in scripts.items()}
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
def _run_script(script: str, cwd: Optional[str], allowed: set[str], timeout: int) -> Dict[str, Any]:
|
|
1341
|
-
ensure_agent_root()
|
|
1342
|
-
if script not in allowed:
|
|
1343
|
-
raise ToolError(f"Script is not allowed here: {script}")
|
|
1344
|
-
workdir = _resolve_path(cwd or ".")
|
|
1345
|
-
if not workdir.exists() or not workdir.is_dir():
|
|
1346
|
-
raise ToolError("Working directory does not exist.")
|
|
1347
|
-
|
|
1348
|
-
scripts = _load_package_scripts(workdir)
|
|
1349
|
-
if script not in scripts:
|
|
1350
|
-
raise ToolError(f"package.json does not define a '{script}' script.")
|
|
1351
|
-
|
|
1352
|
-
try:
|
|
1353
|
-
completed = subprocess.run(
|
|
1354
|
-
["npm", "run", script],
|
|
1355
|
-
cwd=workdir,
|
|
1356
|
-
capture_output=True,
|
|
1357
|
-
text=True,
|
|
1358
|
-
timeout=timeout,
|
|
1359
|
-
check=False,
|
|
1360
|
-
)
|
|
1361
|
-
except subprocess.TimeoutExpired:
|
|
1362
|
-
raise ToolError(f"npm run {script} timed out after {timeout} seconds.")
|
|
1363
|
-
|
|
1364
|
-
return {
|
|
1365
|
-
"command": f"npm run {script}",
|
|
1366
|
-
"cwd": _relative(workdir) if workdir != AGENT_ROOT else ".",
|
|
1367
|
-
"script_body": scripts[script],
|
|
1368
|
-
"returncode": completed.returncode,
|
|
1369
|
-
"stdout": completed.stdout[-MAX_COMMAND_OUTPUT:],
|
|
1370
|
-
"stderr": completed.stderr[-MAX_COMMAND_OUTPUT:],
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
def build_project(cwd: Optional[str] = None, script: str = "build") -> Dict[str, Any]:
|
|
1375
|
-
return _run_script(script, cwd, BUILD_SCRIPT_NAMES, MAX_BUILD_SECONDS)
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
def deploy_project(cwd: Optional[str] = None, script: str = "deploy") -> Dict[str, Any]:
|
|
1379
|
-
return _run_script(script, cwd, DEPLOY_SCRIPT_NAMES, MAX_DEPLOY_SECONDS)
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
def _run_git(args: List[str], cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
1383
|
-
if not args:
|
|
1384
|
-
raise ToolError("Git subcommand is required.")
|
|
1385
|
-
subcommand = args[0]
|
|
1386
|
-
if subcommand not in ALLOWED_GIT_SUBCOMMANDS:
|
|
1387
|
-
raise ToolError(f"Git subcommand is not allowed: {subcommand}")
|
|
1388
|
-
if any(arg.startswith(("git@", "http://", "https://", "ssh://")) for arg in args):
|
|
1389
|
-
raise ToolError("Remote git targets are not allowed.")
|
|
1390
|
-
|
|
1391
|
-
workdir = _resolve_path(cwd or ".")
|
|
1392
|
-
if not workdir.exists() or not workdir.is_dir():
|
|
1393
|
-
raise ToolError("Working directory does not exist.")
|
|
1394
|
-
|
|
1395
|
-
try:
|
|
1396
|
-
completed = subprocess.run(
|
|
1397
|
-
["git", *args],
|
|
1398
|
-
cwd=workdir,
|
|
1399
|
-
capture_output=True,
|
|
1400
|
-
text=True,
|
|
1401
|
-
timeout=MAX_COMMAND_SECONDS,
|
|
1402
|
-
check=False,
|
|
1403
|
-
)
|
|
1404
|
-
except subprocess.TimeoutExpired:
|
|
1405
|
-
raise ToolError(f"Git command timed out after {MAX_COMMAND_SECONDS} seconds.")
|
|
1406
|
-
|
|
1407
|
-
return {
|
|
1408
|
-
"command": "git " + " ".join(args),
|
|
1409
|
-
"cwd": _relative(workdir) if workdir != AGENT_ROOT else ".",
|
|
1410
|
-
"returncode": completed.returncode,
|
|
1411
|
-
"stdout": completed.stdout[-MAX_COMMAND_OUTPUT:],
|
|
1412
|
-
"stderr": completed.stderr[-MAX_COMMAND_OUTPUT:],
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
def git_status(cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
1417
|
-
return _run_git(["status", "--short"], cwd)
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
def git_diff(path: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
1421
|
-
args = ["diff", "--"]
|
|
1422
|
-
if path:
|
|
1423
|
-
target = _resolve_path(path)
|
|
1424
|
-
args.append(_relative(target))
|
|
1425
|
-
return _run_git(args, cwd)
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
def git_log(max_count: int = 5, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
1429
|
-
max_count = max(1, min(int(max_count), 20))
|
|
1430
|
-
return _run_git(["log", f"--max-count={max_count}", "--oneline", "--decorate"], cwd)
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
def git_show(revision: str = "HEAD", cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
1434
|
-
if revision.startswith("-") or any(token in revision for token in ["..", ":", "/", "\\"]):
|
|
1435
|
-
raise ToolError("Revision is not allowed.")
|
|
1436
|
-
return _run_git(["show", "--stat", "--oneline", "--decorate", revision], cwd)
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
def _h_create_xlsx(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1440
|
-
rows = args.get("rows", [])
|
|
1441
|
-
if isinstance(rows, str):
|
|
1442
|
-
rows = json.loads(rows)
|
|
1443
|
-
return create_xlsx(rows, args.get("filename", "spreadsheet.xlsx"), args.get("sheet_name", "Sheet1"))
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
def _h_create_pptx(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1447
|
-
slides = args.get("slides", [])
|
|
1448
|
-
if isinstance(slides, str):
|
|
1449
|
-
slides = json.loads(slides)
|
|
1450
|
-
return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
# ── Tool registry: the single source of truth for name → invocation ───────────
|
|
1454
|
-
# Each entry binds the args dict to a tool function. ``execute_tool`` is a
|
|
1455
|
-
# lookup over this table — adding a tool means adding one entry here, not
|
|
1456
|
-
# editing an if/elif chain. server.py's governance map and catalog brief are
|
|
1457
|
-
# checked against ``registered_tools()`` so the three never silently drift.
|
|
1458
|
-
TOOL_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
|
1459
|
-
# filesystem
|
|
1460
|
-
"list_dir": lambda a: list_dir(a.get("path", ".")),
|
|
1461
|
-
"workspace_tree": lambda a: workspace_tree(a.get("path", "."), a.get("max_depth", 3)),
|
|
1462
|
-
"read_file": lambda a: read_file(a["path"], offset=a.get("offset", 0), limit=a.get("limit", 0), line_numbers=a.get("line_numbers", True)),
|
|
1463
|
-
"write_file": lambda a: write_file(a["path"], a.get("content", "")),
|
|
1464
|
-
"edit_file": lambda a: edit_file(a["path"], a["old_string"], a["new_string"], replace_all=bool(a.get("replace_all", False))),
|
|
1465
|
-
"grep": lambda a: grep(a["pattern"], path=a.get("path", "."), glob=a.get("glob"), max_results=a.get("max_results", 50), case_insensitive=bool(a.get("case_insensitive", False)), context_lines=a.get("context_lines", 0)),
|
|
1466
|
-
"search_files": lambda a: search_files(a["query"], a.get("path", "."), a.get("max_results", 20)),
|
|
1467
|
-
"inspect_html": lambda a: inspect_html(a["path"]),
|
|
1468
|
-
"preview_url": lambda a: preview_url(a.get("path", "index.html")),
|
|
1469
|
-
# planning
|
|
1470
|
-
"todo_read": lambda a: todo_read(),
|
|
1471
|
-
"todo_write": lambda a: todo_write(a.get("todos") or []),
|
|
1472
|
-
# documents
|
|
1473
|
-
"create_docx": lambda a: create_docx(a.get("title", ""), a.get("body", ""), a.get("filename", "document.docx")),
|
|
1474
|
-
"create_xlsx": _h_create_xlsx,
|
|
1475
|
-
"create_pptx": _h_create_pptx,
|
|
1476
|
-
"create_pdf": lambda a: create_pdf(a.get("title", ""), a.get("body", ""), a.get("filename", "document.pdf")),
|
|
1477
|
-
"create_web_project": lambda a: create_web_project(a.get("path", ""), a.get("framework", "react"), a.get("template", "vite")),
|
|
1478
|
-
# local filesystem
|
|
1479
|
-
"local_list": lambda a: local_list(a["path"]),
|
|
1480
|
-
"local_read": lambda a: local_read(a["path"]),
|
|
1481
|
-
"local_write": lambda a: local_write(a["path"], a.get("content", "")),
|
|
1482
|
-
"read_document": lambda a: read_document(a["path"]),
|
|
1483
|
-
"network_status": lambda a: network_status(),
|
|
1484
|
-
# computer use
|
|
1485
|
-
"computer_screenshot": lambda a: computer_screenshot(),
|
|
1486
|
-
"computer_open_app": lambda a: computer_open_app(a.get("app", "Google Chrome")),
|
|
1487
|
-
"computer_open_url": lambda a: computer_open_url(a["url"], a.get("app", "Google Chrome")),
|
|
1488
|
-
"computer_click": lambda a: computer_click(a.get("x", 0), a.get("y", 0), a.get("button", "left"), a.get("double", False)),
|
|
1489
|
-
"computer_type": lambda a: computer_type(a["text"], a.get("interval", 0.04)),
|
|
1490
|
-
"computer_key": lambda a: computer_key(a["key"]),
|
|
1491
|
-
"computer_scroll": lambda a: computer_scroll(a.get("x", 0), a.get("y", 0), a.get("direction", "down"), a.get("clicks", 3)),
|
|
1492
|
-
"computer_move": lambda a: computer_move(a.get("x", 0), a.get("y", 0)),
|
|
1493
|
-
"computer_drag": lambda a: computer_drag(a.get("x1", 0), a.get("y1", 0), a.get("x2", 0), a.get("y2", 0)),
|
|
1494
|
-
"computer_status": lambda a: computer_status(),
|
|
1495
|
-
"chrome_status": lambda a: desktop_bridge_status(),
|
|
1496
|
-
"computer_use_status": lambda a: desktop_bridge_status(),
|
|
1497
|
-
# knowledge / obsidian
|
|
1498
|
-
"knowledge_save": lambda a: knowledge_save(a["content"], a.get("folder", "00_Raw"), a.get("title")),
|
|
1499
|
-
"knowledge_search": lambda a: knowledge_search(a["query"], a.get("max_results", 5)),
|
|
1500
|
-
"knowledge_tree": lambda a: knowledge_tree(),
|
|
1501
|
-
"obsidian_save": lambda a: obsidian_save(a["content"], a.get("folder", "00_Raw"), a.get("title")),
|
|
1502
|
-
"obsidian_search": lambda a: obsidian_search(a["query"], a.get("max_results", 5)),
|
|
1503
|
-
"obsidian_tree": lambda a: obsidian_tree(),
|
|
1504
|
-
# git (read-only)
|
|
1505
|
-
"git_status": lambda a: git_status(a.get("cwd")),
|
|
1506
|
-
"git_diff": lambda a: git_diff(a.get("path"), a.get("cwd")),
|
|
1507
|
-
"git_log": lambda a: git_log(a.get("max_count", 5), a.get("cwd")),
|
|
1508
|
-
"git_show": lambda a: git_show(a.get("revision", "HEAD"), a.get("cwd")),
|
|
1509
|
-
# exec
|
|
1510
|
-
"run_command": lambda a: run_command(a["command"], a.get("cwd")),
|
|
1511
|
-
"build_project": lambda a: build_project(a.get("cwd"), a.get("script", "build")),
|
|
1512
|
-
"deploy_project": lambda a: deploy_project(a.get("cwd"), a.get("script", "deploy")),
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
DEFAULT_TOOL_REGISTRY = ToolRegistry(TOOL_HANDLERS)
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
def registered_tools() -> frozenset:
|
|
1520
|
-
"""Names dispatchable through ``execute_tool`` — the seam other modules verify against."""
|
|
1521
|
-
return DEFAULT_TOOL_REGISTRY.registered_tools()
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1525
|
-
return DEFAULT_TOOL_REGISTRY.execute(action, args, error_cls=ToolError)
|