ltcai 0.1.9 → 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 -313
- 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/ltcai_cli.py +2 -2
- package/package.json +1 -1
- package/server.py +997 -261
- 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_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_tools.py +194 -1
- package/tools.py +264 -4
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":
|