ltcai 3.4.1 → 3.6.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.
Files changed (60) hide show
  1. package/README.md +206 -247
  2. package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
  3. package/docs/CHANGELOG.md +32 -0
  4. package/docs/HANDOVER_v3.6.0.md +46 -0
  5. package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
  6. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
  7. package/docs/architecture.md +13 -12
  8. package/docs/kg-schema.md +55 -0
  9. package/docs/privacy.md +18 -2
  10. package/docs/security-model.md +17 -0
  11. package/kg_schema.py +46 -0
  12. package/knowledge_graph.py +520 -1
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/auth.py +37 -9
  15. package/latticeai/api/browser.py +217 -0
  16. package/latticeai/api/chat.py +4 -1
  17. package/latticeai/api/computer_use.py +21 -8
  18. package/latticeai/api/portability.py +93 -0
  19. package/latticeai/api/tools.py +29 -26
  20. package/latticeai/core/config.py +3 -0
  21. package/latticeai/core/marketplace.py +1 -1
  22. package/latticeai/core/multi_agent.py +1 -1
  23. package/latticeai/core/oidc.py +205 -0
  24. package/latticeai/core/security.py +59 -5
  25. package/latticeai/core/workspace_os.py +1 -1
  26. package/latticeai/server_app.py +39 -0
  27. package/latticeai/services/ingestion.py +271 -0
  28. package/latticeai/services/kg_portability.py +177 -0
  29. package/package.json +5 -4
  30. package/requirements.txt +1 -0
  31. package/scripts/build_vsix.mjs +72 -0
  32. package/scripts/check_python.py +87 -0
  33. package/static/css/reference/account.css +1 -1
  34. package/static/css/reference/admin.css +1 -1
  35. package/static/css/reference/base.css +8 -5
  36. package/static/css/reference/chat.css +8 -8
  37. package/static/css/reference/graph.css +2 -2
  38. package/static/css/responsive.css +2 -2
  39. package/static/v3/asset-manifest.json +9 -9
  40. package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
  41. package/static/v3/css/lattice.shell.css +2 -1
  42. package/static/v3/js/{app.d086489d.js → app.c541f955.js} +1 -1
  43. package/static/v3/js/core/{api.12b568ad.js → api.33d6320e.js} +38 -0
  44. package/static/v3/js/core/api.js +38 -0
  45. package/static/v3/js/core/{routes.d214b399.js → routes.2ce3815a.js} +1 -1
  46. package/static/v3/js/core/routes.js +1 -1
  47. package/static/v3/js/core/{shell.d05266f5.js → shell.8c163e0e.js} +2 -2
  48. package/static/v3/js/views/knowledge-graph.a96040a5.js +513 -0
  49. package/static/v3/js/views/knowledge-graph.js +293 -17
  50. package/static/workspace.css +1 -1
  51. package/tools/__init__.py +276 -0
  52. package/tools/commands.py +188 -0
  53. package/tools/computer.py +185 -0
  54. package/tools/documents.py +243 -0
  55. package/tools/filesystem.py +560 -0
  56. package/tools/knowledge.py +97 -0
  57. package/tools/local_files.py +69 -0
  58. package/tools/network.py +66 -0
  59. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
  60. 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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)