Nymbo commited on
Commit
642ae3d
·
verified ·
1 Parent(s): 7017cfc

Create _core.py

Browse files
Files changed (1) hide show
  1. Modules/_core.py +861 -0
Modules/_core.py ADDED
@@ -0,0 +1,861 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core shared utilities for the Nymbo-Tools MCP server.
3
+
4
+ Consolidates three key areas:
5
+ 1. Sandboxed filesystem operations (path resolution, reading, writing, safe_open)
6
+ 2. Sandboxed Python execution (code interpreter, agent terminal)
7
+ 3. Hugging Face inference utilities (token, providers, error handling)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import json
14
+ import os
15
+ import re
16
+ import stat
17
+ import sys
18
+ from datetime import datetime
19
+ from io import StringIO
20
+ from typing import Any, Callable, Optional, TypeVar
21
+
22
+ import gradio as gr
23
+
24
+
25
+ # ===========================================================================
26
+ # Part 0: Tree Rendering Utilities
27
+ # ===========================================================================
28
+
29
+
30
+ def _fmt_size(num_bytes: int) -> str:
31
+ """Format byte size as human-readable string."""
32
+ units = ["B", "KB", "MB", "GB"]
33
+ size = float(num_bytes)
34
+ for unit in units:
35
+ if size < 1024.0:
36
+ return f"{size:.1f} {unit}"
37
+ size /= 1024.0
38
+ return f"{size:.1f} TB"
39
+
40
+
41
+ def build_tree(entries: list[tuple[str, dict]]) -> dict:
42
+ """
43
+ Build a nested tree structure from flat path entries.
44
+
45
+ Args:
46
+ entries: List of (path, metadata) tuples where path uses forward slashes.
47
+ Paths ending with '/' are treated as directories.
48
+
49
+ Returns:
50
+ Nested dict with "__files__" key for files at each level.
51
+ """
52
+ root: dict = {"__files__": []}
53
+
54
+ for path, metadata in entries:
55
+ parts = path.rstrip("/").split("/")
56
+ is_dir = path.endswith("/")
57
+
58
+ node = root
59
+ for i, part in enumerate(parts[:-1]):
60
+ if part not in node:
61
+ node[part] = {"__files__": []}
62
+ node = node[part]
63
+
64
+ final = parts[-1]
65
+ if is_dir:
66
+ if final not in node:
67
+ node[final] = {"__files__": []}
68
+ if metadata:
69
+ node[final]["__meta__"] = metadata
70
+ else:
71
+ node["__files__"].append((final, metadata))
72
+
73
+ return root
74
+
75
+
76
+ def render_tree(
77
+ node: dict,
78
+ prefix: str = "",
79
+ format_entry: Optional[Callable[[str, dict, bool], str]] = None,
80
+ ) -> list[str]:
81
+ """
82
+ Render a tree with line connectors.
83
+
84
+ Args:
85
+ node: Nested dict from build_tree()
86
+ prefix: Current line prefix for indentation
87
+ format_entry: Optional callback to format each entry.
88
+
89
+ Returns:
90
+ List of formatted lines.
91
+ """
92
+ result = []
93
+
94
+ def default_format(name: str, meta: dict, is_dir: bool) -> str:
95
+ if is_dir:
96
+ return f"{name}/"
97
+ size = meta.get("size")
98
+ if size is not None:
99
+ return f"{name} ({_fmt_size(size)})"
100
+ return name
101
+
102
+ fmt = format_entry or default_format
103
+
104
+ entries = []
105
+ subdirs = sorted(k for k in node.keys() if k not in ("__files__", "__meta__"))
106
+ files_here = sorted(node.get("__files__", []), key=lambda x: x[0])
107
+
108
+ for dirname in subdirs:
109
+ dir_meta = node[dirname].get("__meta__", {})
110
+ entries.append(("dir", dirname, node[dirname], dir_meta))
111
+ for fname, fmeta in files_here:
112
+ entries.append(("file", fname, None, fmeta))
113
+
114
+ for i, entry in enumerate(entries):
115
+ is_last = (i == len(entries) - 1)
116
+ connector = "└── " if is_last else "├── "
117
+ child_prefix = prefix + (" " if is_last else "│ ")
118
+
119
+ etype, name, subtree, meta = entry
120
+
121
+ if etype == "dir":
122
+ result.append(f"{prefix}{connector}{fmt(name, meta, True)}")
123
+ result.extend(render_tree(subtree, child_prefix, format_entry))
124
+ else:
125
+ result.append(f"{prefix}{connector}{fmt(name, meta, False)}")
126
+
127
+ return result
128
+
129
+
130
+ def walk_and_build_tree(
131
+ abs_path: str,
132
+ *,
133
+ show_hidden: bool = False,
134
+ recursive: bool = False,
135
+ max_entries: int = 100,
136
+ ) -> tuple[dict, int, bool]:
137
+ """
138
+ Walk a directory and build a tree structure.
139
+
140
+ Returns:
141
+ (tree, total_entries, truncated)
142
+ """
143
+ entries: list[tuple[str, dict]] = []
144
+ total = 0
145
+ truncated = False
146
+
147
+ for root, dirs, files in os.walk(abs_path):
148
+ if not show_hidden:
149
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
150
+ files = [f for f in files if not f.startswith('.')]
151
+
152
+ dirs.sort()
153
+ files.sort()
154
+
155
+ try:
156
+ rel_root = os.path.relpath(root, abs_path)
157
+ except Exception:
158
+ rel_root = ""
159
+ prefix = "" if rel_root == "." else rel_root.replace("\\", "/") + "/"
160
+
161
+ for d in dirs:
162
+ p = os.path.join(root, d)
163
+ try:
164
+ mtime = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M")
165
+ except Exception:
166
+ mtime = "?"
167
+ entries.append((f"{prefix}{d}/", {"mtime": mtime}))
168
+ total += 1
169
+ if total >= max_entries:
170
+ truncated = True
171
+ break
172
+
173
+ if truncated:
174
+ break
175
+
176
+ for f in files:
177
+ p = os.path.join(root, f)
178
+ try:
179
+ size = os.path.getsize(p)
180
+ mtime = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M")
181
+ except Exception:
182
+ size, mtime = 0, "?"
183
+ entries.append((f"{prefix}{f}", {"size": size, "mtime": mtime}))
184
+ total += 1
185
+ if total >= max_entries:
186
+ truncated = True
187
+ break
188
+
189
+ if truncated:
190
+ break
191
+
192
+ if not recursive:
193
+ break
194
+
195
+ return build_tree(entries), total, truncated
196
+
197
+
198
+ def format_dir_listing(
199
+ abs_path: str,
200
+ display_path: str,
201
+ *,
202
+ show_hidden: bool = False,
203
+ recursive: bool = False,
204
+ max_entries: int = 100,
205
+ fmt_size_fn: Optional[Callable[[int], str]] = None,
206
+ ) -> str:
207
+ """Format a directory listing as a visual tree."""
208
+ fmt_size = fmt_size_fn or _fmt_size
209
+
210
+ tree, total, truncated = walk_and_build_tree(
211
+ abs_path,
212
+ show_hidden=show_hidden,
213
+ recursive=recursive,
214
+ max_entries=max_entries,
215
+ )
216
+
217
+ def format_entry(name: str, meta: dict, is_dir: bool) -> str:
218
+ mtime = meta.get("mtime", "")
219
+ if is_dir:
220
+ return f"{name}/ ({mtime})"
221
+ size = meta.get("size", 0)
222
+ return f"{name} ({fmt_size(size)}, {mtime})"
223
+
224
+ tree_lines = render_tree(tree, " ", format_entry)
225
+
226
+ header = f"Listing of {display_path}\nRoot: /\nEntries: {total}"
227
+ if truncated:
228
+ header += f"\n… Truncated at {max_entries} entries."
229
+
230
+ lines = [header, "", "└── /"]
231
+ lines.extend(tree_lines)
232
+
233
+ return "\n".join(lines).strip()
234
+
235
+
236
+ # ===========================================================================
237
+ # Part 1: Sandboxed Filesystem Operations
238
+ # ===========================================================================
239
+
240
+
241
+ class SandboxedRoot:
242
+ """
243
+ A configurable sandboxed root directory with path resolution and safety checks.
244
+
245
+ Args:
246
+ root_dir: Absolute path to the sandbox root.
247
+ allow_abs: If True, allow absolute paths outside the sandbox.
248
+ """
249
+
250
+ def __init__(self, root_dir: str, allow_abs: bool = False):
251
+ self.root_dir = os.path.abspath(root_dir)
252
+ self.allow_abs = allow_abs
253
+ # Ensure root exists
254
+ try:
255
+ os.makedirs(self.root_dir, exist_ok=True)
256
+ except Exception:
257
+ pass
258
+
259
+ def safe_err(self, exc: Exception | str) -> str:
260
+ """Return an error string with any absolute root replaced by '/' and slashes normalized."""
261
+ s = str(exc)
262
+ s_norm = s.replace("\\", "/")
263
+ root_fwd = self.root_dir.replace("\\", "/")
264
+ root_variants = {self.root_dir, root_fwd, re.sub(r"/+", "/", root_fwd)}
265
+ for variant in root_variants:
266
+ if variant:
267
+ s_norm = s_norm.replace(variant, "/")
268
+ s_norm = re.sub(r"/+", "/", s_norm)
269
+ return s_norm
270
+
271
+ def err(
272
+ self,
273
+ code: str,
274
+ message: str,
275
+ *,
276
+ path: Optional[str] = None,
277
+ hint: Optional[str] = None,
278
+ data: Optional[dict] = None,
279
+ ) -> str:
280
+ """Return a structured error JSON string."""
281
+ payload = {
282
+ "status": "error",
283
+ "code": code,
284
+ "message": message,
285
+ "root": "/",
286
+ }
287
+ if path is not None and path != "":
288
+ payload["path"] = path
289
+ if hint:
290
+ payload["hint"] = hint
291
+ if data:
292
+ payload["data"] = data
293
+ return json.dumps(payload, ensure_ascii=False)
294
+
295
+ def display_path(self, abs_path: str) -> str:
296
+ """Return a user-friendly path relative to root using forward slashes."""
297
+ try:
298
+ norm_root = os.path.normpath(self.root_dir)
299
+ norm_abs = os.path.normpath(abs_path)
300
+ common = os.path.commonpath([norm_root, norm_abs])
301
+ if os.path.normcase(common) == os.path.normcase(norm_root):
302
+ rel = os.path.relpath(norm_abs, norm_root)
303
+ if rel == ".":
304
+ return "/"
305
+ return "/" + rel.replace("\\", "/")
306
+ except Exception:
307
+ pass
308
+ return abs_path.replace("\\", "/")
309
+
310
+ def resolve_path(self, path: str) -> tuple[str, str]:
311
+ """
312
+ Resolve a user-provided path to an absolute, normalized path constrained to root.
313
+ Returns (abs_path, error_message). error_message is empty when ok.
314
+ """
315
+ try:
316
+ user_input = (path or "/").strip() or "/"
317
+ if user_input.startswith("/"):
318
+ rel_part = user_input.lstrip("/") or "."
319
+ raw = os.path.expanduser(rel_part)
320
+ treat_as_relative = True
321
+ else:
322
+ raw = os.path.expanduser(user_input)
323
+ treat_as_relative = False
324
+
325
+ if not treat_as_relative and os.path.isabs(raw):
326
+ if not self.allow_abs:
327
+ return "", self.err(
328
+ "absolute_path_disabled",
329
+ "Absolute paths are disabled in safe mode.",
330
+ path=raw.replace("\\", "/"),
331
+ hint="Use a path relative to / (e.g., /notes/todo.txt).",
332
+ )
333
+ abs_path = os.path.abspath(raw)
334
+ else:
335
+ abs_path = os.path.abspath(os.path.join(self.root_dir, raw))
336
+
337
+ # Constrain to root when not allowing absolute paths
338
+ if not self.allow_abs:
339
+ try:
340
+ common = os.path.commonpath(
341
+ [os.path.normpath(self.root_dir), os.path.normpath(abs_path)]
342
+ )
343
+ if common != os.path.normpath(self.root_dir):
344
+ return "", self.err(
345
+ "path_outside_root",
346
+ "Path is outside the sandbox root.",
347
+ path=abs_path,
348
+ )
349
+ except Exception:
350
+ return "", self.err(
351
+ "path_outside_root",
352
+ "Path is outside the sandbox root.",
353
+ path=abs_path,
354
+ )
355
+
356
+ return abs_path, ""
357
+ except Exception as exc:
358
+ return "", self.err(
359
+ "resolve_path_failed",
360
+ "Failed to resolve path.",
361
+ path=(path or ""),
362
+ data={"error": self.safe_err(exc)},
363
+ )
364
+
365
+ def safe_open(self, file, *args, **kwargs):
366
+ """A drop-in replacement for open() that enforces sandbox constraints."""
367
+ if isinstance(file, int):
368
+ return open(file, *args, **kwargs)
369
+
370
+ path_str = os.fspath(file)
371
+ abs_path, err = self.resolve_path(path_str)
372
+ if err:
373
+ try:
374
+ msg = json.loads(err)["message"]
375
+ except Exception:
376
+ msg = err
377
+ raise PermissionError(f"Sandboxed open() failed: {msg}")
378
+
379
+ return open(abs_path, *args, **kwargs)
380
+
381
+ def list_dir(
382
+ self,
383
+ abs_path: str,
384
+ *,
385
+ show_hidden: bool = False,
386
+ recursive: bool = False,
387
+ max_entries: int = 100,
388
+ ) -> str:
389
+ """List directory contents as a visual tree."""
390
+ return format_dir_listing(
391
+ abs_path,
392
+ self.display_path(abs_path),
393
+ show_hidden=show_hidden,
394
+ recursive=recursive,
395
+ max_entries=max_entries,
396
+ fmt_size_fn=_fmt_size,
397
+ )
398
+
399
+ def search_text(
400
+ self,
401
+ abs_path: str,
402
+ query: str,
403
+ *,
404
+ recursive: bool = False,
405
+ show_hidden: bool = False,
406
+ max_results: int = 20,
407
+ case_sensitive: bool = False,
408
+ start_index: int = 0,
409
+ ) -> str:
410
+ """Search for text within files."""
411
+ if not os.path.exists(abs_path):
412
+ return self.err(
413
+ "path_not_found",
414
+ f"Path not found: {self.display_path(abs_path)}",
415
+ path=self.display_path(abs_path),
416
+ )
417
+
418
+ query = query or ""
419
+ normalized_query = query if case_sensitive else query.lower()
420
+ if normalized_query == "":
421
+ return self.err(
422
+ "missing_search_query",
423
+ "Search query is required for the search action.",
424
+ hint="Provide text in the Content field to search for.",
425
+ )
426
+
427
+ max_results = max(1, int(max_results) if max_results is not None else 20)
428
+ start_index = max(0, int(start_index) if start_index is not None else 0)
429
+ matches: list[tuple[str, int, str]] = []
430
+ errors: list[str] = []
431
+ files_scanned = 0
432
+ truncated = False
433
+ total_matches = 0
434
+
435
+ def _should_skip(name: str) -> bool:
436
+ return not show_hidden and name.startswith(".")
437
+
438
+ def _handle_match(file_path: str, line_no: int, line_text: str) -> bool:
439
+ nonlocal truncated, total_matches
440
+ total_matches += 1
441
+ if total_matches <= start_index:
442
+ return False
443
+ if len(matches) < max_results:
444
+ snippet = line_text.strip()
445
+ if len(snippet) > 200:
446
+ snippet = snippet[:197] + "…"
447
+ matches.append((self.display_path(file_path), line_no, snippet))
448
+ return False
449
+ truncated = True
450
+ return True
451
+
452
+ def _search_file(file_path: str) -> bool:
453
+ nonlocal files_scanned
454
+ files_scanned += 1
455
+ try:
456
+ with open(file_path, "r", encoding="utf-8", errors="replace") as handle:
457
+ for line_no, line in enumerate(handle, start=1):
458
+ haystack = line if case_sensitive else line.lower()
459
+ if normalized_query in haystack:
460
+ if _handle_match(file_path, line_no, line):
461
+ return True
462
+ except Exception as exc:
463
+ errors.append(f"{self.display_path(file_path)} ({self.safe_err(exc)})")
464
+ return truncated
465
+
466
+ if os.path.isfile(abs_path):
467
+ _search_file(abs_path)
468
+ else:
469
+ for root, dirs, files in os.walk(abs_path):
470
+ dirs[:] = [d for d in dirs if not _should_skip(d)]
471
+ visible_files = [f for f in files if show_hidden or not f.startswith(".")]
472
+ for name in visible_files:
473
+ file_path = os.path.join(root, name)
474
+ if _search_file(file_path):
475
+ break
476
+ if truncated:
477
+ break
478
+ if not recursive:
479
+ break
480
+
481
+ header_lines = [
482
+ f"Search results for {query!r}",
483
+ f"Scope: {self.display_path(abs_path)}",
484
+ f"Recursive: {'yes' if recursive else 'no'}, Hidden: {'yes' if show_hidden else 'no'}, Case-sensitive: {'yes' if case_sensitive else 'no'}",
485
+ f"Start offset: {start_index}",
486
+ f"Matches returned: {len(matches)}" + (" (truncated)" if truncated else ""),
487
+ f"Files scanned: {files_scanned}",
488
+ ]
489
+
490
+ next_cursor = start_index + len(matches) if truncated else None
491
+
492
+ if truncated:
493
+ header_lines.append(f"Matches encountered before truncation: {total_matches}")
494
+ header_lines.append(f"Truncated: yes — re-run with offset={next_cursor} to continue.")
495
+ header_lines.append(f"Next cursor: {next_cursor}")
496
+ else:
497
+ header_lines.append(f"Total matches found: {total_matches}")
498
+ header_lines.append("Truncated: no — end of results.")
499
+ header_lines.append("Next cursor: None")
500
+
501
+ if not matches:
502
+ if total_matches > 0 and start_index >= total_matches:
503
+ hint_limit = max(total_matches - 1, 0)
504
+ body_lines = [
505
+ f"No matches found at or after offset {start_index}. Total matches available: {total_matches}.",
506
+ (f"Try a smaller offset (≤ {hint_limit})." if hint_limit >= 0 else ""),
507
+ ]
508
+ body_lines = [line for line in body_lines if line]
509
+ else:
510
+ body_lines = [
511
+ "No matches found.",
512
+ (f"Total matches encountered: {total_matches}." if total_matches else ""),
513
+ ]
514
+ body_lines = [line for line in body_lines if line]
515
+ else:
516
+ body_lines = [
517
+ f"{idx}. {path}:{line_no}: {text}"
518
+ for idx, (path, line_no, text) in enumerate(matches, start=1)
519
+ ]
520
+
521
+ if errors:
522
+ shown = errors[:5]
523
+ body_lines.extend(["", "Warnings:"])
524
+ body_lines.extend(shown)
525
+ if len(errors) > len(shown):
526
+ body_lines.append(f"… {len(errors) - len(shown)} additional files could not be read.")
527
+
528
+ return "\n".join(header_lines) + "\n\n" + "\n".join(body_lines)
529
+
530
+ def read_file(self, abs_path: str, *, offset: int = 0, max_chars: int = 4000) -> str:
531
+ """Read file contents with optional offset and character limit."""
532
+ if not os.path.exists(abs_path):
533
+ return self.err(
534
+ "file_not_found",
535
+ f"File not found: {self.display_path(abs_path)}",
536
+ path=self.display_path(abs_path),
537
+ )
538
+ if os.path.isdir(abs_path):
539
+ return self.err(
540
+ "is_directory",
541
+ f"Path is a directory, not a file: {self.display_path(abs_path)}",
542
+ path=self.display_path(abs_path),
543
+ hint="Provide a file path.",
544
+ )
545
+ try:
546
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
547
+ data = f.read()
548
+ except Exception as exc:
549
+ return self.err(
550
+ "read_failed",
551
+ "Failed to read file.",
552
+ path=self.display_path(abs_path),
553
+ data={"error": self.safe_err(exc)},
554
+ )
555
+ total = len(data)
556
+ start = max(0, min(offset, total))
557
+ if max_chars > 0:
558
+ end = min(total, start + max_chars)
559
+ else:
560
+ end = total
561
+ chunk = data[start:end]
562
+ next_cursor = end if end < total else None
563
+ header = (
564
+ f"Reading {self.display_path(abs_path)}\n"
565
+ f"Offset {start}, returned {len(chunk)} of {total}."
566
+ + (f"\nNext cursor: {next_cursor}" if next_cursor is not None else "")
567
+ )
568
+ sep = "\n\n---\n\n"
569
+ return header + sep + chunk
570
+
571
+ def info(self, abs_path: str) -> str:
572
+ """Get file/directory metadata as JSON."""
573
+ try:
574
+ st = os.stat(abs_path)
575
+ except Exception as exc:
576
+ return self.err(
577
+ "stat_failed",
578
+ "Failed to stat path.",
579
+ path=self.display_path(abs_path),
580
+ data={"error": self.safe_err(exc)},
581
+ )
582
+ info_dict = {
583
+ "path": self.display_path(abs_path),
584
+ "type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
585
+ "size": st.st_size,
586
+ "modified": datetime.fromtimestamp(st.st_mtime).isoformat(sep=" ", timespec="seconds"),
587
+ "created": datetime.fromtimestamp(st.st_ctime).isoformat(sep=" ", timespec="seconds"),
588
+ "mode": oct(st.st_mode),
589
+ "root": "/",
590
+ }
591
+ return json.dumps(info_dict, indent=2)
592
+
593
+
594
+ # ---------------------------------------------------------------------------
595
+ # Default roots (can be overridden by environment variables)
596
+ # ---------------------------------------------------------------------------
597
+
598
+ def _get_filesystem_root() -> str:
599
+ """Get the default filesystem root directory."""
600
+ root = os.getenv("NYMBO_TOOLS_ROOT")
601
+ if root and root.strip():
602
+ return os.path.abspath(os.path.expanduser(root.strip()))
603
+ try:
604
+ here = os.path.abspath(__file__)
605
+ tools_dir = os.path.dirname(os.path.dirname(here))
606
+ return os.path.abspath(os.path.join(tools_dir, "Filesystem"))
607
+ except Exception:
608
+ return os.path.abspath(os.getcwd())
609
+
610
+
611
+ def _get_obsidian_root() -> str:
612
+ """Get the default Obsidian vault root directory."""
613
+ env_root = os.getenv("OBSIDIAN_VAULT_ROOT")
614
+ if env_root and env_root.strip():
615
+ return os.path.abspath(os.path.expanduser(env_root.strip()))
616
+ try:
617
+ here = os.path.abspath(__file__)
618
+ tools_dir = os.path.dirname(os.path.dirname(here))
619
+ return os.path.abspath(os.path.join(tools_dir, "Obsidian"))
620
+ except Exception:
621
+ return os.path.abspath(os.getcwd())
622
+
623
+
624
+ # Pre-configured sandbox instances
625
+ ALLOW_ABS = bool(int(os.getenv("UNSAFE_ALLOW_ABS_PATHS", "0")))
626
+
627
+ FILESYSTEM_ROOT = _get_filesystem_root()
628
+ OBSIDIAN_ROOT = _get_obsidian_root()
629
+
630
+ # Default sandbox for /Filesystem (used by most tools)
631
+ filesystem_sandbox = SandboxedRoot(FILESYSTEM_ROOT, allow_abs=ALLOW_ABS)
632
+
633
+ # Sandbox for /Obsidian vault
634
+ obsidian_sandbox = SandboxedRoot(OBSIDIAN_ROOT, allow_abs=ALLOW_ABS)
635
+
636
+
637
+ # Convenience exports (for backward compatibility)
638
+ ROOT_DIR = FILESYSTEM_ROOT
639
+
640
+ def _resolve_path(path: str) -> tuple[str, str]:
641
+ """Resolve path using the default filesystem sandbox."""
642
+ return filesystem_sandbox.resolve_path(path)
643
+
644
+ def _display_path(abs_path: str) -> str:
645
+ """Display path using the default filesystem sandbox."""
646
+ return filesystem_sandbox.display_path(abs_path)
647
+
648
+ def safe_open(file, *args, **kwargs):
649
+ """Open file using the default filesystem sandbox."""
650
+ return filesystem_sandbox.safe_open(file, *args, **kwargs)
651
+
652
+
653
+ # ===========================================================================
654
+ # Part 2: Sandboxed Python Execution
655
+ # ===========================================================================
656
+
657
+
658
+ def create_safe_builtins() -> dict:
659
+ """Create a builtins dict with sandboxed open()."""
660
+ if isinstance(__builtins__, dict):
661
+ safe_builtins = __builtins__.copy()
662
+ else:
663
+ safe_builtins = vars(__builtins__).copy()
664
+ safe_builtins["open"] = safe_open
665
+ return safe_builtins
666
+
667
+
668
+ def sandboxed_exec(
669
+ code: str,
670
+ *,
671
+ extra_globals: dict[str, Any] | None = None,
672
+ ast_mode: bool = False,
673
+ ) -> str:
674
+ """
675
+ Execute Python code in a sandboxed environment.
676
+
677
+ Args:
678
+ code: Python source code to execute
679
+ extra_globals: Additional globals to inject (e.g., tools)
680
+ ast_mode: If True, parse and print results of all expression statements
681
+ (like Agent_Terminal). If False, simple exec (like Code_Interpreter).
682
+
683
+ Returns:
684
+ Captured stdout output, or exception text on error.
685
+ """
686
+ if not code:
687
+ return "No code provided."
688
+
689
+ old_stdout = sys.stdout
690
+ old_cwd = os.getcwd()
691
+ redirected_output = sys.stdout = StringIO()
692
+
693
+ # Build execution environment
694
+ safe_builtins = create_safe_builtins()
695
+ env: dict[str, Any] = {
696
+ "open": safe_open,
697
+ "__builtins__": safe_builtins,
698
+ "print": print,
699
+ }
700
+ if extra_globals:
701
+ env.update(extra_globals)
702
+
703
+ try:
704
+ os.chdir(ROOT_DIR)
705
+
706
+ if ast_mode:
707
+ # Parse and evaluate each statement, printing expression results
708
+ tree = ast.parse(code)
709
+ for node in tree.body:
710
+ if isinstance(node, ast.Expr):
711
+ # Standalone expression - evaluate and print result
712
+ expr = compile(ast.Expression(node.value), filename="<string>", mode="eval")
713
+ result_val = eval(expr, env)
714
+ if result_val is not None:
715
+ print(result_val)
716
+ else:
717
+ # Statement - execute it
718
+ mod = ast.Module(body=[node], type_ignores=[])
719
+ exec(compile(mod, filename="<string>", mode="exec"), env)
720
+ else:
721
+ # Simple exec mode
722
+ exec(code, env)
723
+
724
+ result = redirected_output.getvalue()
725
+ except Exception as exc:
726
+ result = str(exc)
727
+ finally:
728
+ sys.stdout = old_stdout
729
+ try:
730
+ os.chdir(old_cwd)
731
+ except Exception:
732
+ pass
733
+
734
+ return result
735
+
736
+
737
+ # ===========================================================================
738
+ # Part 3: Hugging Face Inference Utilities
739
+ # ===========================================================================
740
+
741
+
742
+ def get_hf_token() -> str | None:
743
+ """Get the HF API token from environment variables.
744
+
745
+ Checks HF_READ_TOKEN first, then falls back to HF_TOKEN.
746
+ """
747
+ return os.getenv("HF_READ_TOKEN") or os.getenv("HF_TOKEN")
748
+
749
+
750
+ # Pre-instantiated token for modules that prefer this pattern
751
+ HF_TOKEN = get_hf_token()
752
+
753
+ # Standard provider list for image/video generation
754
+ DEFAULT_PROVIDERS = ["auto", "replicate", "fal-ai"]
755
+
756
+ # Provider list for text generation (Deep Research)
757
+ TEXTGEN_PROVIDERS = ["cerebras", "auto"]
758
+
759
+
760
+ T = TypeVar("T")
761
+
762
+
763
+ def handle_hf_error(msg: str, model_id: str, *, context: str = "generation") -> None:
764
+ """
765
+ Raise appropriate gr.Error for common HF API error codes.
766
+
767
+ Args:
768
+ msg: Error message string to analyze
769
+ model_id: The model ID being used (for error messages)
770
+ context: Description of operation for error messages
771
+
772
+ Raises:
773
+ gr.Error: With user-friendly message based on error type
774
+ """
775
+ lowered = msg.lower()
776
+
777
+ if "404" in msg:
778
+ raise gr.Error(f"Model not found or unavailable: {model_id}. Check the id and your HF token access.")
779
+
780
+ if "503" in msg:
781
+ raise gr.Error("The model is warming up. Please try again shortly.")
782
+
783
+ if "401" in msg or "403" in msg:
784
+ raise gr.Error("Please duplicate the space and provide a `HF_READ_TOKEN` to enable Image and Video Generation.")
785
+
786
+ if any(pattern in lowered for pattern in ("api_key", "hf auth login", "unauthorized", "forbidden")):
787
+ raise gr.Error("Please duplicate the space and provide a `HF_READ_TOKEN` to enable Image and Video Generation.")
788
+
789
+ # If none of the known patterns match, raise generic error
790
+ raise gr.Error(f"{context.capitalize()} failed: {msg}")
791
+
792
+
793
+ def invoke_with_fallback(
794
+ fn: Callable[[str], T],
795
+ providers: list[str] | None = None,
796
+ ) -> T:
797
+ """
798
+ Try calling fn(provider) for each provider until one succeeds.
799
+
800
+ Args:
801
+ fn: Function that takes a provider string and returns a result.
802
+ Should raise an exception on failure.
803
+ providers: List of provider strings to try. Defaults to DEFAULT_PROVIDERS.
804
+
805
+ Returns:
806
+ The result from the first successful fn() call.
807
+
808
+ Raises:
809
+ The last exception if all providers fail.
810
+ """
811
+ if providers is None:
812
+ providers = DEFAULT_PROVIDERS
813
+
814
+ last_error: Exception | None = None
815
+
816
+ for provider in providers:
817
+ try:
818
+ return fn(provider)
819
+ except Exception as exc:
820
+ last_error = exc
821
+ continue
822
+
823
+ # All providers failed
824
+ if last_error:
825
+ raise last_error
826
+ raise RuntimeError("No providers available")
827
+
828
+
829
+ # ===========================================================================
830
+ # Public API
831
+ # ===========================================================================
832
+
833
+ __all__ = [
834
+ # Tree Utils
835
+ "_fmt_size",
836
+ "build_tree",
837
+ "render_tree",
838
+ "walk_and_build_tree",
839
+ "format_dir_listing",
840
+ # Filesystem
841
+ "SandboxedRoot",
842
+ "filesystem_sandbox",
843
+ "obsidian_sandbox",
844
+ "ROOT_DIR",
845
+ "FILESYSTEM_ROOT",
846
+ "OBSIDIAN_ROOT",
847
+ "ALLOW_ABS",
848
+ "_resolve_path",
849
+ "_display_path",
850
+ "safe_open",
851
+ # Execution
852
+ "sandboxed_exec",
853
+ "create_safe_builtins",
854
+ # HF Inference
855
+ "get_hf_token",
856
+ "HF_TOKEN",
857
+ "DEFAULT_PROVIDERS",
858
+ "TEXTGEN_PROVIDERS",
859
+ "handle_hf_error",
860
+ "invoke_with_fallback",
861
+ ]