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.
@@ -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()