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.
Files changed (39) hide show
  1. package/README.md +141 -289
  2. package/docs/CHANGELOG.md +227 -0
  3. package/docs/architecture.md +121 -0
  4. package/docs/mcp-tools.md +116 -0
  5. package/docs/privacy.md +74 -0
  6. package/docs/public-deploy.md +137 -0
  7. package/docs/security-model.md +121 -0
  8. package/knowledge_graph.py +18 -5
  9. package/ltcai_cli.py +2 -2
  10. package/package.json +1 -1
  11. package/server.py +1140 -280
  12. package/skills/SKILL_TEMPLATE.md +61 -29
  13. package/skills/code_review/SKILL.md +28 -0
  14. package/skills/code_review/examples.md +59 -0
  15. package/skills/code_review/risk.json +9 -0
  16. package/skills/code_review/schema.json +65 -0
  17. package/skills/data_analysis/SKILL.md +28 -0
  18. package/skills/data_analysis/examples.md +62 -0
  19. package/skills/data_analysis/risk.json +9 -0
  20. package/skills/data_analysis/schema.json +61 -0
  21. package/skills/file_edit/SKILL.md +33 -0
  22. package/skills/file_edit/examples.md +45 -0
  23. package/skills/file_edit/risk.json +9 -0
  24. package/skills/file_edit/schema.json +60 -0
  25. package/skills/summarize_document/SKILL.md +68 -0
  26. package/skills/summarize_document/examples.md +65 -0
  27. package/skills/summarize_document/risk.json +9 -0
  28. package/skills/summarize_document/schema.json +71 -0
  29. package/skills/web_search/SKILL.md +28 -0
  30. package/skills/web_search/examples.md +61 -0
  31. package/skills/web_search/risk.json +9 -0
  32. package/skills/web_search/schema.json +62 -0
  33. package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
  34. package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
  35. package/tests/unit/__pycache__/test_security.cpython-314-pytest-9.0.3.pyc +0 -0
  36. package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
  37. package/tests/unit/test_security.py +125 -0
  38. package/tests/unit/test_tools.py +194 -1
  39. package/tools.py +264 -4
@@ -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
- from tools import local_list, local_read, local_write, read_document, ToolError
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
- return {"path": _relative(target), "content": target.read_text(encoding="utf-8")}
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: 680, margin: '48px auto', fontFamily: 'system-ui, sans-serif' }}>
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(args["path"])
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":