ltcai 0.1.8 → 0.1.11
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 +141 -289
- package/docs/CHANGELOG.md +227 -0
- package/docs/architecture.md +121 -0
- package/docs/mcp-tools.md +116 -0
- package/docs/privacy.md +74 -0
- package/docs/public-deploy.md +137 -0
- package/docs/security-model.md +121 -0
- package/knowledge_graph.py +18 -5
- package/ltcai_cli.py +2 -2
- package/package.json +1 -1
- package/server.py +1140 -280
- package/skills/SKILL_TEMPLATE.md +61 -29
- package/skills/code_review/SKILL.md +28 -0
- package/skills/code_review/examples.md +59 -0
- package/skills/code_review/risk.json +9 -0
- package/skills/code_review/schema.json +65 -0
- package/skills/data_analysis/SKILL.md +28 -0
- package/skills/data_analysis/examples.md +62 -0
- package/skills/data_analysis/risk.json +9 -0
- package/skills/data_analysis/schema.json +61 -0
- package/skills/file_edit/SKILL.md +33 -0
- package/skills/file_edit/examples.md +45 -0
- package/skills/file_edit/risk.json +9 -0
- package/skills/file_edit/schema.json +60 -0
- package/skills/summarize_document/SKILL.md +68 -0
- package/skills/summarize_document/examples.md +65 -0
- package/skills/summarize_document/risk.json +9 -0
- package/skills/summarize_document/schema.json +71 -0
- package/skills/web_search/SKILL.md +28 -0
- package/skills/web_search/examples.md +61 -0
- package/skills/web_search/risk.json +9 -0
- package/skills/web_search/schema.json +62 -0
- package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/__pycache__/test_security.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_security.py +125 -0
- package/tests/unit/test_tools.py +194 -1
- package/tools.py +264 -4
package/tests/unit/test_tools.py
CHANGED
|
@@ -5,7 +5,28 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import tools as tools_module
|
|
9
|
+
from tools import (
|
|
10
|
+
ToolError,
|
|
11
|
+
edit_file,
|
|
12
|
+
grep,
|
|
13
|
+
local_list,
|
|
14
|
+
local_read,
|
|
15
|
+
local_write,
|
|
16
|
+
read_document,
|
|
17
|
+
read_file,
|
|
18
|
+
todo_read,
|
|
19
|
+
todo_write,
|
|
20
|
+
write_file,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def workspace(tmp_path, monkeypatch):
|
|
26
|
+
"""Redirect tools' AGENT_ROOT to a temp directory for the duration of a test."""
|
|
27
|
+
monkeypatch.setattr(tools_module, "AGENT_ROOT", tmp_path)
|
|
28
|
+
tools_module.ensure_agent_root()
|
|
29
|
+
return tmp_path
|
|
9
30
|
|
|
10
31
|
|
|
11
32
|
# ---------------------------------------------------------------------------
|
|
@@ -125,3 +146,175 @@ def test_read_document_csv(tmp_path):
|
|
|
125
146
|
result = read_document(str(f))
|
|
126
147
|
# should not raise; returns some text content
|
|
127
148
|
assert result is not None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# read_file (workspace, with line numbers and offset/limit)
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def test_read_file_returns_numbered_view(workspace):
|
|
156
|
+
(workspace / "a.txt").write_text("alpha\nbeta\ngamma\n")
|
|
157
|
+
result = read_file("a.txt")
|
|
158
|
+
assert result["total_lines"] == 3
|
|
159
|
+
assert result["start_line"] == 1
|
|
160
|
+
assert result["end_line"] == 3
|
|
161
|
+
assert "1\talpha" in result["numbered"]
|
|
162
|
+
assert "3\tgamma" in result["numbered"]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_read_file_offset_and_limit(workspace):
|
|
166
|
+
(workspace / "a.txt").write_text("\n".join(f"line{i}" for i in range(1, 11)))
|
|
167
|
+
result = read_file("a.txt", offset=3, limit=2)
|
|
168
|
+
assert result["start_line"] == 4
|
|
169
|
+
assert result["end_line"] == 5
|
|
170
|
+
assert result["content"].splitlines() == ["line4", "line5"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_read_file_disable_line_numbers(workspace):
|
|
174
|
+
(workspace / "a.txt").write_text("only\n")
|
|
175
|
+
result = read_file("a.txt", line_numbers=False)
|
|
176
|
+
assert "numbered" not in result
|
|
177
|
+
assert result["content"] == "only\n"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# edit_file
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def test_edit_file_replaces_unique_match(workspace):
|
|
185
|
+
(workspace / "code.py").write_text("def foo():\n return 1\n")
|
|
186
|
+
result = edit_file("code.py", "return 1", "return 42")
|
|
187
|
+
assert (workspace / "code.py").read_text() == "def foo():\n return 42\n"
|
|
188
|
+
assert result["replacements"] == 1
|
|
189
|
+
assert result["first_edit_line"] == 2
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_edit_file_missing_string_raises(workspace):
|
|
193
|
+
(workspace / "code.py").write_text("hello\n")
|
|
194
|
+
with pytest.raises(ToolError, match="not found"):
|
|
195
|
+
edit_file("code.py", "missing", "world")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_edit_file_ambiguous_raises_unless_replace_all(workspace):
|
|
199
|
+
(workspace / "code.py").write_text("x = 1\nx = 1\n")
|
|
200
|
+
with pytest.raises(ToolError, match="ambiguous"):
|
|
201
|
+
edit_file("code.py", "x = 1", "x = 2")
|
|
202
|
+
result = edit_file("code.py", "x = 1", "x = 2", replace_all=True)
|
|
203
|
+
assert result["replacements"] == 2
|
|
204
|
+
assert (workspace / "code.py").read_text() == "x = 2\nx = 2\n"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_edit_file_rejects_identical(workspace):
|
|
208
|
+
(workspace / "code.py").write_text("same\n")
|
|
209
|
+
with pytest.raises(ToolError, match="identical"):
|
|
210
|
+
edit_file("code.py", "same", "same")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# grep
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def test_grep_finds_regex_matches(workspace):
|
|
218
|
+
(workspace / "a.py").write_text("def foo():\n return 1\n\ndef bar():\n return 2\n")
|
|
219
|
+
(workspace / "b.py").write_text("x = 1\n")
|
|
220
|
+
result = grep(r"^def \w+", path=".")
|
|
221
|
+
paths = sorted({m["path"] for m in result["matches"]})
|
|
222
|
+
assert "a.py" in paths
|
|
223
|
+
assert result["files_with_matches"] == 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_grep_respects_glob(workspace):
|
|
227
|
+
(workspace / "a.py").write_text("needle\n")
|
|
228
|
+
(workspace / "a.txt").write_text("needle\n")
|
|
229
|
+
result = grep("needle", path=".", glob="*.py")
|
|
230
|
+
paths = [m["path"] for m in result["matches"]]
|
|
231
|
+
assert "a.py" in paths
|
|
232
|
+
assert "a.txt" not in paths
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_grep_case_insensitive(workspace):
|
|
236
|
+
(workspace / "a.txt").write_text("HELLO world\n")
|
|
237
|
+
result = grep("hello", path=".", case_insensitive=True)
|
|
238
|
+
assert any("HELLO" in m["match"] for m in result["matches"])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_grep_context_lines(workspace):
|
|
242
|
+
(workspace / "a.txt").write_text("before\nhit\nafter\n")
|
|
243
|
+
result = grep("hit", path=".", context_lines=1)
|
|
244
|
+
assert result["matches"]
|
|
245
|
+
ctx_lines = [c["text"] for c in result["matches"][0]["context"]]
|
|
246
|
+
assert "before" in ctx_lines and "after" in ctx_lines
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_grep_invalid_regex_raises(workspace):
|
|
250
|
+
(workspace / "a.txt").write_text("hello\n")
|
|
251
|
+
with pytest.raises(ToolError, match="regex"):
|
|
252
|
+
grep("[unterminated", path=".")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_grep_skips_binary_dirs(workspace):
|
|
256
|
+
(workspace / "node_modules").mkdir()
|
|
257
|
+
(workspace / "node_modules" / "x.js").write_text("needle\n")
|
|
258
|
+
(workspace / "src.py").write_text("needle\n")
|
|
259
|
+
result = grep("needle", path=".")
|
|
260
|
+
paths = [m["path"] for m in result["matches"]]
|
|
261
|
+
assert "src.py" in paths
|
|
262
|
+
assert all("node_modules" not in p for p in paths)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# todo_read / todo_write
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def test_todo_read_empty_when_unset(workspace):
|
|
270
|
+
result = todo_read()
|
|
271
|
+
assert result["todos"] == []
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_todo_write_round_trip(workspace):
|
|
275
|
+
todos = [
|
|
276
|
+
{"id": "1", "content": "design API", "status": "completed"},
|
|
277
|
+
{"id": "2", "content": "write tests", "status": "in_progress"},
|
|
278
|
+
{"id": "3", "content": "deploy", "status": "pending"},
|
|
279
|
+
]
|
|
280
|
+
todo_write(todos)
|
|
281
|
+
fresh = todo_read()
|
|
282
|
+
assert [t["content"] for t in fresh["todos"]] == ["design API", "write tests", "deploy"]
|
|
283
|
+
assert fresh["todos"][1]["status"] == "in_progress"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_todo_write_rejects_invalid_status(workspace):
|
|
287
|
+
with pytest.raises(ToolError, match="status"):
|
|
288
|
+
todo_write([{"id": "1", "content": "x", "status": "blocked"}])
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_todo_write_rejects_missing_content(workspace):
|
|
292
|
+
with pytest.raises(ToolError, match="content"):
|
|
293
|
+
todo_write([{"id": "1", "content": "", "status": "pending"}])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_todo_write_warns_multiple_in_progress(workspace):
|
|
297
|
+
result = todo_write([
|
|
298
|
+
{"id": "1", "content": "a", "status": "in_progress"},
|
|
299
|
+
{"id": "2", "content": "b", "status": "in_progress"},
|
|
300
|
+
])
|
|
301
|
+
assert result["warning"]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# Sandbox: workspace tools must not escape AGENT_ROOT
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def test_read_file_blocks_path_escape(workspace):
|
|
309
|
+
with pytest.raises(ToolError, match="escapes"):
|
|
310
|
+
read_file("../../etc/passwd")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_edit_file_blocks_path_escape(workspace):
|
|
314
|
+
with pytest.raises(ToolError, match="escapes"):
|
|
315
|
+
edit_file("../../etc/passwd", "a", "b")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_grep_blocks_path_escape(workspace):
|
|
319
|
+
with pytest.raises(ToolError, match="escapes"):
|
|
320
|
+
grep("x", path="../../etc")
|
package/tools.py
CHANGED
|
@@ -347,7 +347,15 @@ def workspace_tree(path: str = ".", max_depth: int = 3) -> Dict[str, Any]:
|
|
|
347
347
|
return {"root": str(AGENT_ROOT), "path": _relative(target) if target != AGENT_ROOT else ".", "entries": entries}
|
|
348
348
|
|
|
349
349
|
|
|
350
|
-
def read_file(path: str) -> Dict[str, Any]:
|
|
350
|
+
def read_file(path: str, offset: int = 0, limit: int = 0, line_numbers: bool = True) -> Dict[str, Any]:
|
|
351
|
+
"""Read a file from the agent workspace.
|
|
352
|
+
|
|
353
|
+
Returns content as plain text. When line_numbers is True (default), also
|
|
354
|
+
returns a numbered view (`numbered`) plus `total_lines` so the agent can
|
|
355
|
+
cite file:line locations precisely.
|
|
356
|
+
|
|
357
|
+
offset is 0-indexed (the first line is offset=0). limit=0 reads to the end.
|
|
358
|
+
"""
|
|
351
359
|
target = _resolve_path(path)
|
|
352
360
|
if not target.exists():
|
|
353
361
|
raise ToolError("File does not exist.")
|
|
@@ -356,7 +364,31 @@ def read_file(path: str) -> Dict[str, Any]:
|
|
|
356
364
|
size = target.stat().st_size
|
|
357
365
|
if size > MAX_FILE_BYTES:
|
|
358
366
|
raise ToolError(f"File is too large to read ({size} bytes).")
|
|
359
|
-
|
|
367
|
+
text = target.read_text(encoding="utf-8")
|
|
368
|
+
all_lines = text.splitlines()
|
|
369
|
+
total_lines = len(all_lines)
|
|
370
|
+
|
|
371
|
+
offset = max(0, int(offset or 0))
|
|
372
|
+
limit = max(0, int(limit or 0))
|
|
373
|
+
end = total_lines if limit == 0 else min(total_lines, offset + limit)
|
|
374
|
+
sliced = all_lines[offset:end]
|
|
375
|
+
sliced_text = "\n".join(sliced)
|
|
376
|
+
if offset == 0 and limit == 0 and text.endswith("\n"):
|
|
377
|
+
sliced_text += "\n"
|
|
378
|
+
|
|
379
|
+
result: Dict[str, Any] = {
|
|
380
|
+
"path": _relative(target),
|
|
381
|
+
"content": sliced_text,
|
|
382
|
+
"total_lines": total_lines,
|
|
383
|
+
"start_line": offset + 1,
|
|
384
|
+
"end_line": end,
|
|
385
|
+
}
|
|
386
|
+
if line_numbers:
|
|
387
|
+
width = max(4, len(str(end or total_lines)))
|
|
388
|
+
result["numbered"] = "\n".join(
|
|
389
|
+
f"{(offset + i + 1):>{width}}\t{line}" for i, line in enumerate(sliced)
|
|
390
|
+
)
|
|
391
|
+
return result
|
|
360
392
|
|
|
361
393
|
|
|
362
394
|
def write_file(path: str, content: str) -> Dict[str, Any]:
|
|
@@ -368,6 +400,199 @@ def write_file(path: str, content: str) -> Dict[str, Any]:
|
|
|
368
400
|
return {"path": _relative(target), "bytes": target.stat().st_size}
|
|
369
401
|
|
|
370
402
|
|
|
403
|
+
def edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False) -> Dict[str, Any]:
|
|
404
|
+
"""Precise diff-style edit: replace `old_string` with `new_string` in `path`.
|
|
405
|
+
|
|
406
|
+
Fails when `old_string` is missing or appears more than once (unless
|
|
407
|
+
replace_all=True). This forces the caller to read the file first and pass
|
|
408
|
+
enough surrounding context to uniquely identify the edit site — the same
|
|
409
|
+
discipline Claude Code uses for safe edits.
|
|
410
|
+
"""
|
|
411
|
+
if old_string == new_string:
|
|
412
|
+
raise ToolError("old_string and new_string are identical; nothing to change.")
|
|
413
|
+
target = _resolve_path(path)
|
|
414
|
+
if not target.exists() or not target.is_file():
|
|
415
|
+
raise ToolError("File does not exist.")
|
|
416
|
+
if target.stat().st_size > MAX_FILE_BYTES:
|
|
417
|
+
raise ToolError("File is too large to edit.")
|
|
418
|
+
|
|
419
|
+
original = target.read_text(encoding="utf-8")
|
|
420
|
+
occurrences = original.count(old_string)
|
|
421
|
+
if occurrences == 0:
|
|
422
|
+
raise ToolError("old_string not found in file. Read the file first and copy the exact bytes (including whitespace).")
|
|
423
|
+
if occurrences > 1 and not replace_all:
|
|
424
|
+
raise ToolError(f"old_string is ambiguous: appears {occurrences} times. Add more context to make it unique, or pass replace_all=true.")
|
|
425
|
+
|
|
426
|
+
updated = original.replace(old_string, new_string) if replace_all else original.replace(old_string, new_string, 1)
|
|
427
|
+
if len(updated.encode("utf-8")) > MAX_FILE_BYTES:
|
|
428
|
+
raise ToolError("Resulting file would exceed the workspace size limit.")
|
|
429
|
+
target.write_text(updated, encoding="utf-8")
|
|
430
|
+
|
|
431
|
+
edited_line = original[: original.find(old_string)].count("\n") + 1
|
|
432
|
+
return {
|
|
433
|
+
"path": _relative(target),
|
|
434
|
+
"replacements": occurrences if replace_all else 1,
|
|
435
|
+
"bytes": target.stat().st_size,
|
|
436
|
+
"first_edit_line": edited_line,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
_GREP_BINARY_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".zip", ".tar",
|
|
441
|
+
".gz", ".bz2", ".xz", ".7z", ".mp3", ".mp4", ".mov", ".wav",
|
|
442
|
+
".woff", ".woff2", ".ttf", ".eot", ".ico", ".db", ".sqlite",
|
|
443
|
+
".pyc", ".pyo", ".o", ".so", ".dylib", ".dll", ".exe", ".bin"}
|
|
444
|
+
_GREP_BINARY_DIRS = {"node_modules", ".git", ".venv", "venv", "__pycache__",
|
|
445
|
+
".pytest_cache", "dist", "build", ".next", ".cache"}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def grep(
|
|
449
|
+
pattern: str,
|
|
450
|
+
path: str = ".",
|
|
451
|
+
glob: Optional[str] = None,
|
|
452
|
+
max_results: int = 50,
|
|
453
|
+
case_insensitive: bool = False,
|
|
454
|
+
context_lines: int = 0,
|
|
455
|
+
) -> Dict[str, Any]:
|
|
456
|
+
"""Regex search across the agent workspace.
|
|
457
|
+
|
|
458
|
+
Unlike `search_files` (single line, 9 extensions, substring only), this
|
|
459
|
+
walks all text files, supports regex, returns line numbers, and can
|
|
460
|
+
optionally include surrounding context lines. Skips obvious binary
|
|
461
|
+
files/directories.
|
|
462
|
+
"""
|
|
463
|
+
if not pattern:
|
|
464
|
+
raise ToolError("Pattern is required.")
|
|
465
|
+
try:
|
|
466
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
467
|
+
regex = re.compile(pattern, flags)
|
|
468
|
+
except re.error as exc:
|
|
469
|
+
raise ToolError(f"Invalid regex: {exc}") from exc
|
|
470
|
+
|
|
471
|
+
target = _resolve_path(path)
|
|
472
|
+
if not target.exists() or not target.is_dir():
|
|
473
|
+
raise ToolError("Path is not a directory.")
|
|
474
|
+
|
|
475
|
+
max_results = max(1, min(int(max_results), 500))
|
|
476
|
+
context_lines = max(0, min(int(context_lines), 8))
|
|
477
|
+
matches: List[Dict[str, Any]] = []
|
|
478
|
+
files_scanned = 0
|
|
479
|
+
files_with_matches = 0
|
|
480
|
+
|
|
481
|
+
iterator = target.rglob(glob) if glob else target.rglob("*")
|
|
482
|
+
for file_path in iterator:
|
|
483
|
+
if len(matches) >= max_results:
|
|
484
|
+
break
|
|
485
|
+
if not file_path.is_file():
|
|
486
|
+
continue
|
|
487
|
+
if file_path.suffix.lower() in _GREP_BINARY_EXTS:
|
|
488
|
+
continue
|
|
489
|
+
if any(part in _GREP_BINARY_DIRS for part in file_path.parts):
|
|
490
|
+
continue
|
|
491
|
+
if file_path.stat().st_size > MAX_FILE_BYTES:
|
|
492
|
+
continue
|
|
493
|
+
try:
|
|
494
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
495
|
+
except (UnicodeDecodeError, OSError):
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
files_scanned += 1
|
|
499
|
+
file_had_match = False
|
|
500
|
+
for index, line in enumerate(lines, start=1):
|
|
501
|
+
if len(matches) >= max_results:
|
|
502
|
+
break
|
|
503
|
+
if not regex.search(line):
|
|
504
|
+
continue
|
|
505
|
+
file_had_match = True
|
|
506
|
+
entry: Dict[str, Any] = {
|
|
507
|
+
"path": _relative(file_path),
|
|
508
|
+
"line": index,
|
|
509
|
+
"match": line[:400],
|
|
510
|
+
}
|
|
511
|
+
if context_lines:
|
|
512
|
+
lo = max(0, index - 1 - context_lines)
|
|
513
|
+
hi = min(len(lines), index + context_lines)
|
|
514
|
+
entry["context"] = [
|
|
515
|
+
{"line": lo + i + 1, "text": lines[lo + i][:200]}
|
|
516
|
+
for i in range(hi - lo)
|
|
517
|
+
]
|
|
518
|
+
matches.append(entry)
|
|
519
|
+
if file_had_match:
|
|
520
|
+
files_with_matches += 1
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
"pattern": pattern,
|
|
524
|
+
"matches": matches,
|
|
525
|
+
"files_scanned": files_scanned,
|
|
526
|
+
"files_with_matches": files_with_matches,
|
|
527
|
+
"truncated": len(matches) >= max_results,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
_TODO_REL_PATH = ".lattice/todos.json"
|
|
532
|
+
_TODO_ALLOWED_STATUS = {"pending", "in_progress", "completed"}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _todo_file() -> Path:
|
|
536
|
+
ensure_agent_root()
|
|
537
|
+
target = AGENT_ROOT / _TODO_REL_PATH
|
|
538
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
539
|
+
return target
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def todo_read() -> Dict[str, Any]:
|
|
543
|
+
"""Read the agent's persistent TODO list (per-workspace)."""
|
|
544
|
+
target = _todo_file()
|
|
545
|
+
if not target.exists():
|
|
546
|
+
return {"todos": [], "path": _TODO_REL_PATH}
|
|
547
|
+
try:
|
|
548
|
+
todos = json.loads(target.read_text(encoding="utf-8"))
|
|
549
|
+
except (json.JSONDecodeError, OSError):
|
|
550
|
+
todos = []
|
|
551
|
+
if not isinstance(todos, list):
|
|
552
|
+
todos = []
|
|
553
|
+
return {"todos": todos, "path": _TODO_REL_PATH}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def todo_write(todos: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
557
|
+
"""Replace the agent's TODO list. Each todo: {id, content, status}.
|
|
558
|
+
|
|
559
|
+
Status must be one of: pending, in_progress, completed.
|
|
560
|
+
At most one todo should be in_progress at any time — the agent enforces
|
|
561
|
+
this convention; the tool only warns if violated.
|
|
562
|
+
"""
|
|
563
|
+
if not isinstance(todos, list):
|
|
564
|
+
raise ToolError("todos must be a list.")
|
|
565
|
+
if len(todos) > 50:
|
|
566
|
+
raise ToolError("Too many todos (max 50). Split into smaller batches.")
|
|
567
|
+
|
|
568
|
+
cleaned: List[Dict[str, Any]] = []
|
|
569
|
+
in_progress_count = 0
|
|
570
|
+
for idx, raw in enumerate(todos, start=1):
|
|
571
|
+
if not isinstance(raw, dict):
|
|
572
|
+
raise ToolError(f"Todo #{idx} is not an object.")
|
|
573
|
+
content = str(raw.get("content") or "").strip()
|
|
574
|
+
if not content:
|
|
575
|
+
raise ToolError(f"Todo #{idx} is missing 'content'.")
|
|
576
|
+
status = str(raw.get("status") or "pending").strip().lower()
|
|
577
|
+
if status not in _TODO_ALLOWED_STATUS:
|
|
578
|
+
raise ToolError(f"Todo #{idx} has invalid status '{status}'. Use one of {sorted(_TODO_ALLOWED_STATUS)}.")
|
|
579
|
+
if status == "in_progress":
|
|
580
|
+
in_progress_count += 1
|
|
581
|
+
cleaned.append({
|
|
582
|
+
"id": str(raw.get("id") or idx),
|
|
583
|
+
"content": content[:240],
|
|
584
|
+
"status": status,
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
target = _todo_file()
|
|
588
|
+
target.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
589
|
+
return {
|
|
590
|
+
"todos": cleaned,
|
|
591
|
+
"path": _TODO_REL_PATH,
|
|
592
|
+
"warning": "More than one todo is in_progress; keep only one active at a time." if in_progress_count > 1 else None,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
371
596
|
def search_files(query: str, path: str = ".", max_results: int = 20) -> Dict[str, Any]:
|
|
372
597
|
if not query:
|
|
373
598
|
raise ToolError("Query is required.")
|
|
@@ -547,6 +772,12 @@ npm run dev
|
|
|
547
772
|
npm run build
|
|
548
773
|
npm run preview
|
|
549
774
|
```
|
|
775
|
+
|
|
776
|
+
## Lattice AI Notes
|
|
777
|
+
|
|
778
|
+
- Inspect `package.json` and existing config files before adding new libraries.
|
|
779
|
+
- If you add Tailwind CSS, framer-motion, TypeScript, or other tooling, add the required config files too.
|
|
780
|
+
- Do not report the app as complete until `npm run build` succeeds.
|
|
550
781
|
""",
|
|
551
782
|
"src/main.jsx": """import React from 'react'
|
|
552
783
|
import ReactDOM from 'react-dom/client'
|
|
@@ -564,9 +795,13 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|
|
564
795
|
export default function App() {
|
|
565
796
|
const [count, setCount] = useState(0)
|
|
566
797
|
return (
|
|
567
|
-
<main style={{ maxWidth:
|
|
798
|
+
<main style={{ maxWidth: 760, margin: '48px auto', padding: '0 20px', fontFamily: 'system-ui, sans-serif' }}>
|
|
568
799
|
<h1>Vite + React</h1>
|
|
569
800
|
<p>Starter generated by Lattice AI agent.</p>
|
|
801
|
+
<p style={{ color: '#555', lineHeight: 1.6 }}>
|
|
802
|
+
Inspect the current setup before adding new UI libraries, then verify
|
|
803
|
+
changes with <code>npm run build</code>.
|
|
804
|
+
</p>
|
|
570
805
|
<button onClick={() => setCount((c) => c + 1)}>count is {count}</button>
|
|
571
806
|
</main>
|
|
572
807
|
)
|
|
@@ -1206,11 +1441,36 @@ def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1206
1441
|
if action == "workspace_tree":
|
|
1207
1442
|
return workspace_tree(args.get("path", "."), args.get("max_depth", 3))
|
|
1208
1443
|
if action == "read_file":
|
|
1209
|
-
return read_file(
|
|
1444
|
+
return read_file(
|
|
1445
|
+
args["path"],
|
|
1446
|
+
offset=args.get("offset", 0),
|
|
1447
|
+
limit=args.get("limit", 0),
|
|
1448
|
+
line_numbers=args.get("line_numbers", True),
|
|
1449
|
+
)
|
|
1210
1450
|
if action == "write_file":
|
|
1211
1451
|
return write_file(args["path"], args.get("content", ""))
|
|
1452
|
+
if action == "edit_file":
|
|
1453
|
+
return edit_file(
|
|
1454
|
+
args["path"],
|
|
1455
|
+
args["old_string"],
|
|
1456
|
+
args["new_string"],
|
|
1457
|
+
replace_all=bool(args.get("replace_all", False)),
|
|
1458
|
+
)
|
|
1459
|
+
if action == "grep":
|
|
1460
|
+
return grep(
|
|
1461
|
+
args["pattern"],
|
|
1462
|
+
path=args.get("path", "."),
|
|
1463
|
+
glob=args.get("glob"),
|
|
1464
|
+
max_results=args.get("max_results", 50),
|
|
1465
|
+
case_insensitive=bool(args.get("case_insensitive", False)),
|
|
1466
|
+
context_lines=args.get("context_lines", 0),
|
|
1467
|
+
)
|
|
1212
1468
|
if action == "search_files":
|
|
1213
1469
|
return search_files(args["query"], args.get("path", "."), args.get("max_results", 20))
|
|
1470
|
+
if action == "todo_read":
|
|
1471
|
+
return todo_read()
|
|
1472
|
+
if action == "todo_write":
|
|
1473
|
+
return todo_write(args.get("todos") or [])
|
|
1214
1474
|
if action == "inspect_html":
|
|
1215
1475
|
return inspect_html(args["path"])
|
|
1216
1476
|
if action == "preview_url":
|