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
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""Filesystem tools confined to the agent workspace.
|
|
2
|
+
|
|
3
|
+
read/write/edit/grep/search, todo list, HTML inspection and web scaffolding.
|
|
4
|
+
Path resolution reads ``tools.AGENT_ROOT`` so tests can redirect the sandbox.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from html.parser import HTMLParser
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
import tools
|
|
20
|
+
from tools import (
|
|
21
|
+
ToolError,
|
|
22
|
+
ensure_agent_root,
|
|
23
|
+
_resolve_path,
|
|
24
|
+
_relative,
|
|
25
|
+
MAX_FILE_BYTES,
|
|
26
|
+
TEXT_EXTENSIONS,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_dir(path: str = ".") -> Dict[str, Any]:
|
|
31
|
+
target = _resolve_path(path)
|
|
32
|
+
if not target.exists():
|
|
33
|
+
raise ToolError("Directory does not exist.")
|
|
34
|
+
if not target.is_dir():
|
|
35
|
+
raise ToolError("Path is not a directory.")
|
|
36
|
+
|
|
37
|
+
items: List[Dict[str, Any]] = []
|
|
38
|
+
for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
39
|
+
items.append(
|
|
40
|
+
{
|
|
41
|
+
"name": child.name,
|
|
42
|
+
"path": _relative(child),
|
|
43
|
+
"type": "directory" if child.is_dir() else "file",
|
|
44
|
+
"size": child.stat().st_size if child.is_file() else None,
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
return {"root": str(tools.AGENT_ROOT), "path": _relative(target) if target != tools.AGENT_ROOT else ".", "items": items}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def workspace_tree(path: str = ".", max_depth: int = 3) -> Dict[str, Any]:
|
|
51
|
+
target = _resolve_path(path)
|
|
52
|
+
if not target.exists() or not target.is_dir():
|
|
53
|
+
raise ToolError("Path is not a directory.")
|
|
54
|
+
|
|
55
|
+
max_depth = max(1, min(int(max_depth), 8))
|
|
56
|
+
entries: List[Dict[str, Any]] = []
|
|
57
|
+
|
|
58
|
+
def walk(current: Path, depth: int) -> None:
|
|
59
|
+
if depth > max_depth:
|
|
60
|
+
return
|
|
61
|
+
for child in sorted(current.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
62
|
+
rel = _relative(child)
|
|
63
|
+
entries.append(
|
|
64
|
+
{
|
|
65
|
+
"path": rel,
|
|
66
|
+
"type": "directory" if child.is_dir() else "file",
|
|
67
|
+
"size": child.stat().st_size if child.is_file() else None,
|
|
68
|
+
"depth": depth,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
if child.is_dir():
|
|
72
|
+
walk(child, depth + 1)
|
|
73
|
+
|
|
74
|
+
walk(target, 1)
|
|
75
|
+
return {"root": str(tools.AGENT_ROOT), "path": _relative(target) if target != tools.AGENT_ROOT else ".", "entries": entries}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_file(path: str, offset: int = 0, limit: int = 0, line_numbers: bool = True) -> Dict[str, Any]:
|
|
79
|
+
"""Read a file from the agent workspace.
|
|
80
|
+
|
|
81
|
+
Returns content as plain text. When line_numbers is True (default), also
|
|
82
|
+
returns a numbered view (`numbered`) plus `total_lines` so the agent can
|
|
83
|
+
cite file:line locations precisely.
|
|
84
|
+
|
|
85
|
+
offset is 0-indexed (the first line is offset=0). limit=0 reads to the end.
|
|
86
|
+
"""
|
|
87
|
+
target = _resolve_path(path)
|
|
88
|
+
if not target.exists():
|
|
89
|
+
raise ToolError("File does not exist.")
|
|
90
|
+
if not target.is_file():
|
|
91
|
+
raise ToolError("Path is not a file.")
|
|
92
|
+
size = target.stat().st_size
|
|
93
|
+
if size > MAX_FILE_BYTES:
|
|
94
|
+
raise ToolError(f"File is too large to read ({size} bytes).")
|
|
95
|
+
text = target.read_text(encoding="utf-8")
|
|
96
|
+
all_lines = text.splitlines()
|
|
97
|
+
total_lines = len(all_lines)
|
|
98
|
+
|
|
99
|
+
offset = max(0, int(offset or 0))
|
|
100
|
+
limit = max(0, int(limit or 0))
|
|
101
|
+
end = total_lines if limit == 0 else min(total_lines, offset + limit)
|
|
102
|
+
sliced = all_lines[offset:end]
|
|
103
|
+
sliced_text = "\n".join(sliced)
|
|
104
|
+
if offset == 0 and limit == 0 and text.endswith("\n"):
|
|
105
|
+
sliced_text += "\n"
|
|
106
|
+
|
|
107
|
+
result: Dict[str, Any] = {
|
|
108
|
+
"path": _relative(target),
|
|
109
|
+
"content": sliced_text,
|
|
110
|
+
"total_lines": total_lines,
|
|
111
|
+
"start_line": offset + 1,
|
|
112
|
+
"end_line": end,
|
|
113
|
+
}
|
|
114
|
+
if line_numbers:
|
|
115
|
+
width = max(4, len(str(end or total_lines)))
|
|
116
|
+
result["numbered"] = "\n".join(
|
|
117
|
+
f"{(offset + i + 1):>{width}}\t{line}" for i, line in enumerate(sliced)
|
|
118
|
+
)
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_file(path: str, content: str) -> Dict[str, Any]:
|
|
123
|
+
target = _resolve_path(path)
|
|
124
|
+
if len(content.encode("utf-8")) > MAX_FILE_BYTES:
|
|
125
|
+
raise ToolError("Content is too large to write.")
|
|
126
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
target.write_text(content, encoding="utf-8")
|
|
128
|
+
return {"path": _relative(target), "bytes": target.stat().st_size}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False) -> Dict[str, Any]:
|
|
132
|
+
"""Precise diff-style edit: replace `old_string` with `new_string` in `path`.
|
|
133
|
+
|
|
134
|
+
Fails when `old_string` is missing or appears more than once (unless
|
|
135
|
+
replace_all=True). This forces the caller to read the file first and pass
|
|
136
|
+
enough surrounding context to uniquely identify the edit site — the same
|
|
137
|
+
discipline Claude Code uses for safe edits.
|
|
138
|
+
"""
|
|
139
|
+
if old_string == new_string:
|
|
140
|
+
raise ToolError("old_string and new_string are identical; nothing to change.")
|
|
141
|
+
target = _resolve_path(path)
|
|
142
|
+
if not target.exists() or not target.is_file():
|
|
143
|
+
raise ToolError("File does not exist.")
|
|
144
|
+
if target.stat().st_size > MAX_FILE_BYTES:
|
|
145
|
+
raise ToolError("File is too large to edit.")
|
|
146
|
+
|
|
147
|
+
original = target.read_text(encoding="utf-8")
|
|
148
|
+
occurrences = original.count(old_string)
|
|
149
|
+
if occurrences == 0:
|
|
150
|
+
raise ToolError("old_string not found in file. Read the file first and copy the exact bytes (including whitespace).")
|
|
151
|
+
if occurrences > 1 and not replace_all:
|
|
152
|
+
raise ToolError(f"old_string is ambiguous: appears {occurrences} times. Add more context to make it unique, or pass replace_all=true.")
|
|
153
|
+
|
|
154
|
+
updated = original.replace(old_string, new_string) if replace_all else original.replace(old_string, new_string, 1)
|
|
155
|
+
if len(updated.encode("utf-8")) > MAX_FILE_BYTES:
|
|
156
|
+
raise ToolError("Resulting file would exceed the workspace size limit.")
|
|
157
|
+
target.write_text(updated, encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
edited_line = original[: original.find(old_string)].count("\n") + 1
|
|
160
|
+
return {
|
|
161
|
+
"path": _relative(target),
|
|
162
|
+
"replacements": occurrences if replace_all else 1,
|
|
163
|
+
"bytes": target.stat().st_size,
|
|
164
|
+
"first_edit_line": edited_line,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_GREP_BINARY_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".zip", ".tar",
|
|
169
|
+
".gz", ".bz2", ".xz", ".7z", ".mp3", ".mp4", ".mov", ".wav",
|
|
170
|
+
".woff", ".woff2", ".ttf", ".eot", ".ico", ".db", ".sqlite",
|
|
171
|
+
".pyc", ".pyo", ".o", ".so", ".dylib", ".dll", ".exe", ".bin"}
|
|
172
|
+
_GREP_BINARY_DIRS = {"node_modules", ".git", ".venv", "venv", "__pycache__",
|
|
173
|
+
".pytest_cache", "dist", "build", ".next", ".cache"}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def grep(
|
|
177
|
+
pattern: str,
|
|
178
|
+
path: str = ".",
|
|
179
|
+
glob: Optional[str] = None,
|
|
180
|
+
max_results: int = 50,
|
|
181
|
+
case_insensitive: bool = False,
|
|
182
|
+
context_lines: int = 0,
|
|
183
|
+
) -> Dict[str, Any]:
|
|
184
|
+
"""Regex search across the agent workspace.
|
|
185
|
+
|
|
186
|
+
Unlike `search_files` (single line, 9 extensions, substring only), this
|
|
187
|
+
walks all text files, supports regex, returns line numbers, and can
|
|
188
|
+
optionally include surrounding context lines. Skips obvious binary
|
|
189
|
+
files/directories.
|
|
190
|
+
"""
|
|
191
|
+
if not pattern:
|
|
192
|
+
raise ToolError("Pattern is required.")
|
|
193
|
+
try:
|
|
194
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
195
|
+
regex = re.compile(pattern, flags)
|
|
196
|
+
except re.error as exc:
|
|
197
|
+
raise ToolError(f"Invalid regex: {exc}") from exc
|
|
198
|
+
|
|
199
|
+
target = _resolve_path(path)
|
|
200
|
+
if not target.exists() or not target.is_dir():
|
|
201
|
+
raise ToolError("Path is not a directory.")
|
|
202
|
+
|
|
203
|
+
max_results = max(1, min(int(max_results), 500))
|
|
204
|
+
context_lines = max(0, min(int(context_lines), 8))
|
|
205
|
+
matches: List[Dict[str, Any]] = []
|
|
206
|
+
files_scanned = 0
|
|
207
|
+
files_with_matches = 0
|
|
208
|
+
|
|
209
|
+
iterator = target.rglob(glob) if glob else target.rglob("*")
|
|
210
|
+
for file_path in iterator:
|
|
211
|
+
if len(matches) >= max_results:
|
|
212
|
+
break
|
|
213
|
+
if not file_path.is_file():
|
|
214
|
+
continue
|
|
215
|
+
if file_path.suffix.lower() in _GREP_BINARY_EXTS:
|
|
216
|
+
continue
|
|
217
|
+
if any(part in _GREP_BINARY_DIRS for part in file_path.parts):
|
|
218
|
+
continue
|
|
219
|
+
if file_path.stat().st_size > MAX_FILE_BYTES:
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
223
|
+
except (UnicodeDecodeError, OSError):
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
files_scanned += 1
|
|
227
|
+
file_had_match = False
|
|
228
|
+
for index, line in enumerate(lines, start=1):
|
|
229
|
+
if len(matches) >= max_results:
|
|
230
|
+
break
|
|
231
|
+
if not regex.search(line):
|
|
232
|
+
continue
|
|
233
|
+
file_had_match = True
|
|
234
|
+
entry: Dict[str, Any] = {
|
|
235
|
+
"path": _relative(file_path),
|
|
236
|
+
"line": index,
|
|
237
|
+
"match": line[:400],
|
|
238
|
+
}
|
|
239
|
+
if context_lines:
|
|
240
|
+
lo = max(0, index - 1 - context_lines)
|
|
241
|
+
hi = min(len(lines), index + context_lines)
|
|
242
|
+
entry["context"] = [
|
|
243
|
+
{"line": lo + i + 1, "text": lines[lo + i][:200]}
|
|
244
|
+
for i in range(hi - lo)
|
|
245
|
+
]
|
|
246
|
+
matches.append(entry)
|
|
247
|
+
if file_had_match:
|
|
248
|
+
files_with_matches += 1
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"pattern": pattern,
|
|
252
|
+
"matches": matches,
|
|
253
|
+
"files_scanned": files_scanned,
|
|
254
|
+
"files_with_matches": files_with_matches,
|
|
255
|
+
"truncated": len(matches) >= max_results,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_TODO_REL_PATH = ".lattice/todos.json"
|
|
260
|
+
_TODO_ALLOWED_STATUS = {"pending", "in_progress", "completed"}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _todo_file() -> Path:
|
|
264
|
+
ensure_agent_root()
|
|
265
|
+
target = tools.AGENT_ROOT / _TODO_REL_PATH
|
|
266
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
return target
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def todo_read() -> Dict[str, Any]:
|
|
271
|
+
"""Read the agent's persistent TODO list (per-workspace)."""
|
|
272
|
+
target = _todo_file()
|
|
273
|
+
if not target.exists():
|
|
274
|
+
return {"todos": [], "path": _TODO_REL_PATH}
|
|
275
|
+
try:
|
|
276
|
+
todos = json.loads(target.read_text(encoding="utf-8"))
|
|
277
|
+
except (json.JSONDecodeError, OSError):
|
|
278
|
+
todos = []
|
|
279
|
+
if not isinstance(todos, list):
|
|
280
|
+
todos = []
|
|
281
|
+
return {"todos": todos, "path": _TODO_REL_PATH}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def todo_write(todos: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
285
|
+
"""Replace the agent's TODO list. Each todo: {id, content, status}.
|
|
286
|
+
|
|
287
|
+
Status must be one of: pending, in_progress, completed.
|
|
288
|
+
At most one todo should be in_progress at any time — the agent enforces
|
|
289
|
+
this convention; the tool only warns if violated.
|
|
290
|
+
"""
|
|
291
|
+
if not isinstance(todos, list):
|
|
292
|
+
raise ToolError("todos must be a list.")
|
|
293
|
+
if len(todos) > 50:
|
|
294
|
+
raise ToolError("Too many todos (max 50). Split into smaller batches.")
|
|
295
|
+
|
|
296
|
+
cleaned: List[Dict[str, Any]] = []
|
|
297
|
+
in_progress_count = 0
|
|
298
|
+
for idx, raw in enumerate(todos, start=1):
|
|
299
|
+
if not isinstance(raw, dict):
|
|
300
|
+
raise ToolError(f"Todo #{idx} is not an object.")
|
|
301
|
+
content = str(raw.get("content") or "").strip()
|
|
302
|
+
if not content:
|
|
303
|
+
raise ToolError(f"Todo #{idx} is missing 'content'.")
|
|
304
|
+
status = str(raw.get("status") or "pending").strip().lower()
|
|
305
|
+
if status not in _TODO_ALLOWED_STATUS:
|
|
306
|
+
raise ToolError(f"Todo #{idx} has invalid status '{status}'. Use one of {sorted(_TODO_ALLOWED_STATUS)}.")
|
|
307
|
+
if status == "in_progress":
|
|
308
|
+
in_progress_count += 1
|
|
309
|
+
cleaned.append({
|
|
310
|
+
"id": str(raw.get("id") or idx),
|
|
311
|
+
"content": content[:240],
|
|
312
|
+
"status": status,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
target = _todo_file()
|
|
316
|
+
target.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
317
|
+
return {
|
|
318
|
+
"todos": cleaned,
|
|
319
|
+
"path": _TODO_REL_PATH,
|
|
320
|
+
"warning": "More than one todo is in_progress; keep only one active at a time." if in_progress_count > 1 else None,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def search_files(query: str, path: str = ".", max_results: int = 20) -> Dict[str, Any]:
|
|
325
|
+
if not query:
|
|
326
|
+
raise ToolError("Query is required.")
|
|
327
|
+
target = _resolve_path(path)
|
|
328
|
+
if not target.exists() or not target.is_dir():
|
|
329
|
+
raise ToolError("Path is not a directory.")
|
|
330
|
+
|
|
331
|
+
max_results = max(1, min(int(max_results), 100))
|
|
332
|
+
matches: List[Dict[str, Any]] = []
|
|
333
|
+
query_lower = query.lower()
|
|
334
|
+
|
|
335
|
+
for file_path in target.rglob("*"):
|
|
336
|
+
if len(matches) >= max_results:
|
|
337
|
+
break
|
|
338
|
+
if not file_path.is_file() or file_path.stat().st_size > MAX_FILE_BYTES:
|
|
339
|
+
continue
|
|
340
|
+
if file_path.suffix.lower() not in TEXT_EXTENSIONS:
|
|
341
|
+
continue
|
|
342
|
+
try:
|
|
343
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
344
|
+
except UnicodeDecodeError:
|
|
345
|
+
continue
|
|
346
|
+
for index, line in enumerate(lines, start=1):
|
|
347
|
+
if query_lower in line.lower():
|
|
348
|
+
matches.append({"path": _relative(file_path), "line": index, "preview": line[:240]})
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
return {"query": query, "matches": matches}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class _HTMLInspector(HTMLParser):
|
|
355
|
+
def __init__(self):
|
|
356
|
+
super().__init__()
|
|
357
|
+
self.title = ""
|
|
358
|
+
self._in_title = False
|
|
359
|
+
self.links: List[str] = []
|
|
360
|
+
self.scripts: List[str] = []
|
|
361
|
+
self.stylesheets: List[str] = []
|
|
362
|
+
self.images: List[str] = []
|
|
363
|
+
self.forms = 0
|
|
364
|
+
self.headings: List[Dict[str, str]] = []
|
|
365
|
+
|
|
366
|
+
def handle_starttag(self, tag: str, attrs: List[tuple]) -> None:
|
|
367
|
+
attr = dict(attrs)
|
|
368
|
+
if tag == "title":
|
|
369
|
+
self._in_title = True
|
|
370
|
+
elif tag == "a" and attr.get("href"):
|
|
371
|
+
self.links.append(attr["href"])
|
|
372
|
+
elif tag == "script" and attr.get("src"):
|
|
373
|
+
self.scripts.append(attr["src"])
|
|
374
|
+
elif tag == "link" and attr.get("rel") and "stylesheet" in " ".join(attr.get("rel", [])):
|
|
375
|
+
if attr.get("href"):
|
|
376
|
+
self.stylesheets.append(attr["href"])
|
|
377
|
+
elif tag == "img" and attr.get("src"):
|
|
378
|
+
self.images.append(attr["src"])
|
|
379
|
+
elif tag == "form":
|
|
380
|
+
self.forms += 1
|
|
381
|
+
elif tag in {"h1", "h2", "h3"}:
|
|
382
|
+
self.headings.append({"level": tag, "text": ""})
|
|
383
|
+
|
|
384
|
+
def handle_endtag(self, tag: str) -> None:
|
|
385
|
+
if tag == "title":
|
|
386
|
+
self._in_title = False
|
|
387
|
+
|
|
388
|
+
def handle_data(self, data: str) -> None:
|
|
389
|
+
text = data.strip()
|
|
390
|
+
if not text:
|
|
391
|
+
return
|
|
392
|
+
if self._in_title:
|
|
393
|
+
self.title += text
|
|
394
|
+
elif self.headings and not self.headings[-1]["text"]:
|
|
395
|
+
self.headings[-1]["text"] = text[:120]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def inspect_html(path: str) -> Dict[str, Any]:
|
|
399
|
+
target = _resolve_path(path)
|
|
400
|
+
if not target.exists() or not target.is_file():
|
|
401
|
+
raise ToolError("HTML file does not exist.")
|
|
402
|
+
if target.suffix.lower() not in {".html", ".htm"}:
|
|
403
|
+
raise ToolError("Path is not an HTML file.")
|
|
404
|
+
if target.stat().st_size > MAX_FILE_BYTES:
|
|
405
|
+
raise ToolError("HTML file is too large to inspect.")
|
|
406
|
+
|
|
407
|
+
parser = _HTMLInspector()
|
|
408
|
+
parser.feed(target.read_text(encoding="utf-8"))
|
|
409
|
+
return {
|
|
410
|
+
"path": _relative(target),
|
|
411
|
+
"title": parser.title,
|
|
412
|
+
"links": parser.links[:50],
|
|
413
|
+
"scripts": parser.scripts[:50],
|
|
414
|
+
"stylesheets": parser.stylesheets[:50],
|
|
415
|
+
"images": parser.images[:50],
|
|
416
|
+
"forms": parser.forms,
|
|
417
|
+
"headings": [h for h in parser.headings if h["text"]][:30],
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def preview_url(path: str = "index.html") -> Dict[str, Any]:
|
|
422
|
+
target = _resolve_path(path)
|
|
423
|
+
if not target.exists() or not target.is_file():
|
|
424
|
+
raise ToolError("Preview file does not exist.")
|
|
425
|
+
rel = _relative(target)
|
|
426
|
+
return {
|
|
427
|
+
"path": rel,
|
|
428
|
+
"local_url": f"http://127.0.0.1:4825/agent-files/{rel}",
|
|
429
|
+
"note": "Use the server host or /web Telegram link host instead of 127.0.0.1 from a phone.",
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def create_web_project(path: str, framework: str = "react", template: str = "vite") -> Dict[str, Any]:
|
|
434
|
+
framework = str(framework or "").strip().lower()
|
|
435
|
+
template = str(template or "").strip().lower()
|
|
436
|
+
if framework != "react" or template != "vite":
|
|
437
|
+
raise ToolError("Only React + Vite template is currently supported.")
|
|
438
|
+
if not path:
|
|
439
|
+
raise ToolError("Project path is required.")
|
|
440
|
+
|
|
441
|
+
root = _resolve_path(path)
|
|
442
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
443
|
+
|
|
444
|
+
files = {
|
|
445
|
+
"package.json": json.dumps(
|
|
446
|
+
{
|
|
447
|
+
"name": Path(path).name.replace(" ", "-").lower() or "vite-react-app",
|
|
448
|
+
"private": True,
|
|
449
|
+
"version": "0.0.0",
|
|
450
|
+
"type": "module",
|
|
451
|
+
"scripts": {
|
|
452
|
+
"dev": "vite",
|
|
453
|
+
"build": "vite build",
|
|
454
|
+
"preview": "vite preview",
|
|
455
|
+
},
|
|
456
|
+
"dependencies": {
|
|
457
|
+
"react": "^18.3.1",
|
|
458
|
+
"react-dom": "^18.3.1",
|
|
459
|
+
},
|
|
460
|
+
"devDependencies": {
|
|
461
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
462
|
+
"vite": "^5.4.0",
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
ensure_ascii=False,
|
|
466
|
+
indent=2,
|
|
467
|
+
) + "\n",
|
|
468
|
+
"index.html": """<!doctype html>
|
|
469
|
+
<html lang="en">
|
|
470
|
+
<head>
|
|
471
|
+
<meta charset="UTF-8" />
|
|
472
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
473
|
+
<title>Vite React App</title>
|
|
474
|
+
</head>
|
|
475
|
+
<body>
|
|
476
|
+
<div id="root"></div>
|
|
477
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
478
|
+
</body>
|
|
479
|
+
</html>
|
|
480
|
+
""",
|
|
481
|
+
"vite.config.js": """import { defineConfig } from 'vite'
|
|
482
|
+
import react from '@vitejs/plugin-react'
|
|
483
|
+
|
|
484
|
+
export default defineConfig({
|
|
485
|
+
plugins: [react()],
|
|
486
|
+
})
|
|
487
|
+
""",
|
|
488
|
+
"README.md": """# Vite React App
|
|
489
|
+
|
|
490
|
+
## Run
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
npm install
|
|
494
|
+
npm run dev
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## Build
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
npm run build
|
|
501
|
+
npm run preview
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
## Lattice AI Notes
|
|
505
|
+
|
|
506
|
+
- Inspect `package.json` and existing config files before adding new libraries.
|
|
507
|
+
- If you add Tailwind CSS, framer-motion, TypeScript, or other tooling, add the required config files too.
|
|
508
|
+
- Do not report the app as complete until `npm run build` succeeds.
|
|
509
|
+
""",
|
|
510
|
+
"src/main.jsx": """import React from 'react'
|
|
511
|
+
import ReactDOM from 'react-dom/client'
|
|
512
|
+
import App from './App.jsx'
|
|
513
|
+
import './index.css'
|
|
514
|
+
|
|
515
|
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
516
|
+
<React.StrictMode>
|
|
517
|
+
<App />
|
|
518
|
+
</React.StrictMode>,
|
|
519
|
+
)
|
|
520
|
+
""",
|
|
521
|
+
"src/App.jsx": """import { useState } from 'react'
|
|
522
|
+
|
|
523
|
+
export default function App() {
|
|
524
|
+
const [count, setCount] = useState(0)
|
|
525
|
+
return (
|
|
526
|
+
<main style={{ maxWidth: 760, margin: '48px auto', padding: '0 20px', fontFamily: 'system-ui, sans-serif' }}>
|
|
527
|
+
<h1>Vite + React</h1>
|
|
528
|
+
<p>Starter generated by Lattice AI agent.</p>
|
|
529
|
+
<p style={{ color: '#555', lineHeight: 1.6 }}>
|
|
530
|
+
Inspect the current setup before adding new UI libraries, then verify
|
|
531
|
+
changes with <code>npm run build</code>.
|
|
532
|
+
</p>
|
|
533
|
+
<button onClick={() => setCount((c) => c + 1)}>count is {count}</button>
|
|
534
|
+
</main>
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
""",
|
|
538
|
+
"src/index.css": """* { box-sizing: border-box; }
|
|
539
|
+
body { margin: 0; background: #f6f7fb; color: #111; }
|
|
540
|
+
button { padding: 10px 14px; border-radius: 10px; border: 1px solid #d6d6d6; background: #fff; cursor: pointer; }
|
|
541
|
+
""",
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
created: List[str] = []
|
|
545
|
+
total_bytes = 0
|
|
546
|
+
for rel_path, content in files.items():
|
|
547
|
+
target = (root / rel_path).resolve()
|
|
548
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
target.write_text(content, encoding="utf-8")
|
|
550
|
+
created.append(_relative(target))
|
|
551
|
+
total_bytes += target.stat().st_size
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
"path": _relative(root),
|
|
555
|
+
"framework": framework,
|
|
556
|
+
"template": template,
|
|
557
|
+
"created_files": created,
|
|
558
|
+
"file_count": len(created),
|
|
559
|
+
"bytes": total_bytes,
|
|
560
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Knowledge-base / Obsidian vault tools over the local brain directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from p_reinforce import BRAIN_DIR, STRUCTURE
|
|
10
|
+
from tools import ToolError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _safe_brain_folder(folder: str) -> str:
|
|
14
|
+
if folder not in STRUCTURE:
|
|
15
|
+
raise ToolError(f"Unknown knowledge folder: {folder}")
|
|
16
|
+
return folder
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def knowledge_save(content: str, folder: str = "00_Raw", title: Optional[str] = None) -> Dict[str, Any]:
|
|
20
|
+
folder = _safe_brain_folder(folder)
|
|
21
|
+
if not content:
|
|
22
|
+
raise ToolError("Knowledge content is required.")
|
|
23
|
+
if len(content.encode("utf-8")) > MAX_FILE_BYTES:
|
|
24
|
+
raise ToolError("Knowledge content is too large.")
|
|
25
|
+
|
|
26
|
+
target_dir = BRAIN_DIR / folder
|
|
27
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
safe_title = title or content.strip().splitlines()[0][:60] or "note"
|
|
29
|
+
safe_title = "".join(ch if ch.isalnum() or ch in (" ", "-", "_") else "" for ch in safe_title).strip()
|
|
30
|
+
safe_title = "_".join(safe_title.split()) or "note"
|
|
31
|
+
filename = f"{safe_title}.md"
|
|
32
|
+
target = target_dir / filename
|
|
33
|
+
counter = 2
|
|
34
|
+
while target.exists():
|
|
35
|
+
target = target_dir / f"{safe_title}_{counter}.md"
|
|
36
|
+
counter += 1
|
|
37
|
+
target.write_text(content, encoding="utf-8")
|
|
38
|
+
return {"folder": folder, "filename": target.name, "path": str(target)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def knowledge_search(query: str, max_results: int = 5) -> Dict[str, Any]:
|
|
42
|
+
if not query:
|
|
43
|
+
raise ToolError("Query is required.")
|
|
44
|
+
max_results = max(1, min(int(max_results), 20))
|
|
45
|
+
query_lower = query.lower()
|
|
46
|
+
results: List[Dict[str, Any]] = []
|
|
47
|
+
|
|
48
|
+
for file_path in BRAIN_DIR.rglob("*.md"):
|
|
49
|
+
if len(results) >= max_results:
|
|
50
|
+
break
|
|
51
|
+
try:
|
|
52
|
+
content = file_path.read_text(encoding="utf-8")
|
|
53
|
+
except UnicodeDecodeError:
|
|
54
|
+
continue
|
|
55
|
+
if query_lower in content.lower() or query_lower in file_path.name.lower():
|
|
56
|
+
results.append(
|
|
57
|
+
{
|
|
58
|
+
"path": str(file_path),
|
|
59
|
+
"relative_path": str(file_path.relative_to(BRAIN_DIR)),
|
|
60
|
+
"preview": content[:500],
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return {"query": query, "results": results}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def knowledge_tree() -> Dict[str, Any]:
|
|
68
|
+
entries: List[Dict[str, Any]] = []
|
|
69
|
+
for folder in STRUCTURE:
|
|
70
|
+
root = BRAIN_DIR / folder
|
|
71
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
for file_path in sorted(root.rglob("*.md")):
|
|
73
|
+
entries.append(
|
|
74
|
+
{
|
|
75
|
+
"folder": folder,
|
|
76
|
+
"relative_path": str(file_path.relative_to(BRAIN_DIR)),
|
|
77
|
+
"size": file_path.stat().st_size,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
return {"root": str(BRAIN_DIR), "entries": entries}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def obsidian_save(content: str, folder: str = "00_Raw", title: Optional[str] = None) -> Dict[str, Any]:
|
|
84
|
+
result = knowledge_save(content, folder, title)
|
|
85
|
+
result["vault_root"] = str(BRAIN_DIR)
|
|
86
|
+
result["obsidian_uri_hint"] = f"obsidian://open?path={result['path']}"
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def obsidian_search(query: str, max_results: int = 5) -> Dict[str, Any]:
|
|
91
|
+
result = knowledge_search(query, max_results)
|
|
92
|
+
result["vault_root"] = str(BRAIN_DIR)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def obsidian_tree() -> Dict[str, Any]:
|
|
97
|
+
return knowledge_tree()
|