main
py 760 lines 24.1 KB
Raw
1 from abc import ABC, abstractmethod
2 from fnmatch import fnmatch
3 import json
4 import os
5 import re
6 import base64
7 import shutil
8 import tempfile
9 from typing import Any, Literal
10 import zipfile
11 import glob
12 import mimetypes
13 from simpleeval import simple_eval
14 from helpers import yaml
15
16 AGENTS_DIR = "agents"
17 PLUGINS_DIR = "plugins"
18 PROJECTS_DIR = "projects"
19 EXTENSIONS_DIR = "extensions"
20 USER_DIR = "usr"
21 TEMP_DIR = "tmp"
22 API_DIR = "api"
23 _base_dir = os.path.dirname(os.path.abspath(os.path.join(__file__, "../")))
24
25 class VariablesPlugin(ABC):
26 @abstractmethod
27 def get_variables(self, file: str, backup_dirs: list[str] | None = None, **kwargs) -> dict[str, Any]: # type: ignore
28 pass
29
30
31 def load_plugin_variables(
32 file: str, backup_dirs: list[str] | None = None, **kwargs
33 ) -> dict[str, Any]:
34 if not file.endswith(".md"):
35 return {}
36
37 if backup_dirs is None:
38 backup_dirs = []
39
40 try:
41 # Create filename and directories list
42 plugin_filename = basename(file, ".md") + ".py"
43 directories = [dirname(file)] + backup_dirs
44 plugin_file = find_file_in_dirs(plugin_filename, directories)
45 except FileNotFoundError:
46 plugin_file = None
47
48 if plugin_file and exists(plugin_file):
49
50 from helpers import modules
51
52 classes = modules.load_classes_from_file(
53 plugin_file, VariablesPlugin, one_per_file=False
54 )
55 for cls in classes:
56 return cls().get_variables(file, backup_dirs, **kwargs) # type: ignore < abstract class here is ok, it is always a subclass
57
58 # load python code and extract variables variables from it
59 # module = None
60 # module_name = dirname(plugin_file).replace("/", ".") + "." + basename(plugin_file, '.py')
61
62 # try:
63 # spec = importlib.util.spec_from_file_location(module_name, plugin_file)
64 # if not spec:
65 # return {}
66 # module = importlib.util.module_from_spec(spec)
67 # sys.modules[spec.name] = module
68 # spec.loader.exec_module(module) # type: ignore
69 # except ImportError:
70 # return {}
71
72 # if module is None:
73 # return {}
74
75 # # Get all classes in the module
76 # class_list = inspect.getmembers(module, inspect.isclass)
77 # # Filter for classes that are subclasses of VariablesPlugin
78 # # iterate backwards to skip imported superclasses
79 # for cls in reversed(class_list):
80 # if cls[1] is not VariablesPlugin and issubclass(cls[1], VariablesPlugin):
81 # return cls[1]().get_variables() # type: ignore
82 return {}
83
84
85 from helpers.strings import sanitize_string
86
87
88 def parse_file(
89 _filename: str, _directories: list[str] | None = None, _encoding="utf-8", **kwargs
90 ):
91 if _directories is None:
92 _directories = []
93
94 # Find the file in the directories
95 absolute_path = find_file_in_dirs(_filename, _directories)
96
97 # Read the file content
98 with open(absolute_path, "r", encoding=_encoding) as f:
99 # content = remove_code_fences(f.read())
100 content = f.read()
101
102 is_json = is_full_json_template(content)
103 content = remove_code_fences(content)
104 variables = load_plugin_variables(absolute_path, _directories, **kwargs) or {} # type: ignore
105 variables.update(kwargs)
106 if is_json:
107 content = replace_placeholders_json(content, **variables)
108 obj = json.loads(content)
109 # obj = replace_placeholders_dict(obj, **variables)
110 return obj
111 else:
112 content = replace_placeholders_text(content, **variables)
113 # Process include statements
114 content = process_includes(
115 # here we use kwargs, the plugin variables are not inherited
116 content,
117 _directories,
118 **kwargs,
119 )
120 return content
121
122
123 def read_prompt_file(
124 _file: str, _directories: list[str] | None = None, _encoding="utf-8", **kwargs
125 ):
126 if _directories is None:
127 _directories = []
128
129 # If filename contains folder path, extract it and add to directories
130 if os.path.dirname(_file):
131 folder_path = os.path.dirname(_file)
132 _file = os.path.basename(_file)
133 _directories = [folder_path] + _directories
134
135 # Find the file in the directories
136 absolute_path = find_file_in_dirs(_file, _directories)
137 source_dir = os.path.dirname(absolute_path)
138
139 # Read the file content
140 with open(absolute_path, "r", encoding=_encoding) as f:
141 content = f.read()
142
143 variables = load_plugin_variables(_file, _directories, **kwargs) or {} # type: ignore
144 variables.update(kwargs)
145
146 # evaluate conditions
147 content = evaluate_text_conditions(content, **variables)
148
149 # Replace placeholders with values from kwargs
150 content = replace_placeholders_text(content, **variables)
151
152 # Process include statements (with source tracking for {{include original}})
153 content = process_includes(
154 # here we use kwargs, the plugin variables are not inherited
155 content,
156 _directories,
157 _source_file=_file,
158 _source_dir=source_dir,
159 **kwargs,
160 )
161
162 return content
163
164
165 def evaluate_text_conditions(_content: str, **kwargs):
166 # search for {{if ...}} ... {{endif}} blocks and evaluate conditions with nesting support
167 if_pattern = re.compile(r"{{\s*if\s+(.*?)}}", flags=re.DOTALL)
168 token_pattern = re.compile(r"{{\s*(if\b.*?|endif)\s*}}", flags=re.DOTALL)
169
170 def _process(text: str) -> str:
171 m_if = if_pattern.search(text)
172 if not m_if:
173 return text
174
175 depth = 1
176 pos = m_if.end()
177 while True:
178 m = token_pattern.search(text, pos)
179 if not m:
180 # Unterminated if-block, do not modify text
181 return text
182 token = m.group(1)
183 depth += 1 if token.startswith("if ") else -1
184 if depth == 0:
185 break
186 pos = m.end()
187
188 before = text[: m_if.start()]
189 condition = m_if.group(1).strip()
190 inner = text[m_if.end() : m.start()]
191 after = text[m.end() :]
192
193 try:
194 result = simple_eval(condition, names=kwargs)
195 except Exception:
196 # On evaluation error, do not modify this block
197 return text
198
199 if result:
200 # Keep inner content (processed recursively), remove if/endif markers
201 kept = before + _process(inner)
202 else:
203 # Skip entire block, including inner content and markers
204 kept = before
205
206 # Continue processing the remaining text after this block
207 return kept + _process(after)
208
209 return _process(_content)
210
211
212 def read_file(relative_path: str, encoding="utf-8"):
213 # Try to get the absolute path for the file from the original directory or backup directories
214 absolute_path = get_abs_path(relative_path)
215
216 # Read the file content
217 with open(absolute_path, "r", encoding=encoding) as f:
218 return f.read()
219
220 def read_file_json(relative_path: str, encoding="utf-8"):
221 # Try to get the absolute path for the file from the original directory or backup directories
222 absolute_path = get_abs_path(relative_path)
223
224 # Read the file content
225 with open(absolute_path, "r", encoding=encoding) as f:
226 return json.load(f)
227
228 def read_file_yaml(relative_path: str, encoding="utf-8"):
229 absolute_path = get_abs_path(relative_path)
230
231 with open(absolute_path, "r", encoding=encoding) as f:
232 return yaml.loads(f.read())
233
234 def read_file_bin(relative_path: str):
235 # Try to get the absolute path for the file from the original directory or backup directories
236 absolute_path = get_abs_path(relative_path)
237
238 # read binary content
239 with open(absolute_path, "rb") as f:
240 return f.read()
241
242
243 def read_file_base64(relative_path):
244 # get absolute path
245 absolute_path = get_abs_path(relative_path)
246
247 # read binary content and encode to base64
248 with open(absolute_path, "rb") as f:
249 return base64.b64encode(f.read()).decode("utf-8")
250
251
252 def is_probably_binary_bytes(data: bytes, threshold: float = 0.3) -> bool:
253 """
254 Binary detection.
255
256 - Fast path: NUL bytes => binary
257 - Otherwise: treat high ratio of suspicious ASCII control bytes as binary.
258 (We intentionally do NOT treat bytes >= 0x80 as binary to avoid false
259 positives for UTF-8 text.)
260 """
261 if not data:
262 return False
263 if b"\x00" in data:
264 return True
265
266 # Count suspicious control bytes
267 allowed = {8, 9, 10, 12, 13} # \b \t \n \f \r
268 suspicious = sum(1 for b in data if ((b < 32 and b not in allowed) or b == 127))
269 return (suspicious / len(data)) > threshold
270
271
272 def is_probably_binary_file(
273 file_path: str, sample_size: int = 10 * 1024, threshold: float = 0.3
274 ) -> bool:
275 """Binary detection by reading only the first ~sample_size bytes of a file."""
276 try:
277 with open(file_path, "rb") as f:
278 sample = f.read(sample_size)
279 except (FileNotFoundError, PermissionError, OSError):
280 raise OSError(f"Unable to read file for binary detection: {file_path}")
281 return is_probably_binary_bytes(sample, threshold=threshold)
282
283
284 def replace_placeholders_text(_content: str, **kwargs):
285 # Replace placeholders with values from kwargs
286 for key, value in kwargs.items():
287 placeholder = "{{" + key + "}}"
288 strval = str(value)
289 _content = _content.replace(placeholder, strval)
290 return _content
291
292
293 def replace_placeholders_json(_content: str, **kwargs):
294 # Replace placeholders with values from kwargs
295 for key, value in kwargs.items():
296 placeholder = "{{" + key + "}}"
297 if placeholder in _content:
298 strval = json.dumps(value)
299 _content = _content.replace(placeholder, strval)
300 return _content
301
302
303 def replace_placeholders_dict(_content: dict, **kwargs):
304 def replace_value(value):
305 if isinstance(value, str):
306 placeholders = re.findall(r"{{(\w+)}}", value)
307 if placeholders:
308 for placeholder in placeholders:
309 if placeholder in kwargs:
310 replacement = kwargs[placeholder]
311 if value == f"{{{{{placeholder}}}}}":
312 return replacement
313 elif isinstance(replacement, (dict, list)):
314 value = value.replace(
315 f"{{{{{placeholder}}}}}", json.dumps(replacement)
316 )
317 else:
318 value = value.replace(
319 f"{{{{{placeholder}}}}}", str(replacement)
320 )
321 return value
322 elif isinstance(value, dict):
323 return {k: replace_value(v) for k, v in value.items()}
324 elif isinstance(value, list):
325 return [replace_value(item) for item in value]
326 else:
327 return value
328
329 return replace_value(_content)
330
331
332 def process_includes(
333 _content: str,
334 _directories: list[str],
335 _source_file: str = "",
336 _source_dir: str = "",
337 **kwargs,
338 ):
339 # {{include original}} — include same file from lower-priority directory
340 original_pattern = re.compile(r"{{\s*include\s+original\s*}}")
341
342 def replace_original(match):
343 if not _source_file or not _source_dir:
344 return match.group(0)
345 remaining_dirs = _get_dirs_after(_directories, _source_dir)
346 if not remaining_dirs:
347 return ""
348 try:
349 return read_prompt_file(_source_file, remaining_dirs, **kwargs)
350 except FileNotFoundError:
351 return ""
352
353 _content = re.sub(original_pattern, replace_original, _content)
354
355 # {{ include 'path' }} — include a named file
356 include_pattern = re.compile(r"{{\s*include\s*['\"](.*?)['\"]\s*}}")
357
358 def replace_include(match):
359 include_path = match.group(1)
360 if os.path.isabs(include_path):
361 return match.group(0)
362 try:
363 return read_prompt_file(include_path, _directories, **kwargs)
364 except FileNotFoundError:
365 return match.group(0)
366
367 return re.sub(include_pattern, replace_include, _content)
368
369
370 def _get_dirs_after(_directories: list[str], _source_dir: str) -> list[str]:
371 """Return directories after _source_dir in the priority list."""
372 source_abs = os.path.normpath(os.path.abspath(_source_dir))
373 found = False
374 result: list[str] = []
375 for d in _directories:
376 d_abs = os.path.normpath(os.path.abspath(get_abs_path(d)))
377 if found:
378 result.append(d)
379 elif d_abs == source_abs:
380 found = True
381 return result
382
383
384 def find_file_in_dirs(_filename: str, _directories: list[str]):
385 """
386 This function searches for a filename in a list of directories in order.
387 Returns the absolute path of the first found file.
388 """
389 # Loop through the directories in order
390 for directory in _directories:
391 # Create full path
392 full_path = get_abs_path(directory, _filename)
393 if exists(full_path):
394 return full_path
395
396 # If the file is not found, raise FileNotFoundError
397 raise FileNotFoundError(
398 f"File '{_filename}' not found in any of the provided directories."
399 )
400
401
402 def get_unique_filenames_in_dirs(
403 dir_paths: list[str],
404 pattern: str = "*",
405 type: Literal["file", "dir", "any"] = "file",
406 ):
407 # returns absolute paths for unique filenames, priority by order in dir_paths
408 seen = set()
409 result = []
410 for dir_path in dir_paths:
411 full_dir = get_abs_path(dir_path)
412 for file_path in glob.glob(os.path.join(full_dir, pattern)):
413 fname = os.path.basename(file_path)
414 if fname not in seen and (
415 type == "any"
416 or (type == "file" and os.path.isfile(file_path))
417 or (type == "dir" and os.path.isdir(file_path))
418 ):
419 seen.add(fname)
420 result.append(get_abs_path(file_path))
421 # sort by filename (basename), not the full path
422 result.sort(key=lambda path: os.path.basename(path))
423 return result
424
425
426 def find_existing_paths_by_pattern(pattern: str):
427 if not pattern:
428 return []
429
430 search_pattern = get_abs_path(pattern)
431 matches = glob.glob(search_pattern, recursive=True)
432 matches.sort()
433 return matches
434
435
436 def remove_code_fences(text, language: str | None = None):
437 if language:
438 pattern = (
439 rf"(?ims)^[ \t]*(```|~~~)[ \t]*{re.escape(language)}[ \t]*\r?\n"
440 r"(.*?)^[ \t]*\1[ \t]*\r?$"
441 )
442 return re.sub(pattern, lambda match: match.group(2), text)
443
444 # Pattern to match code fences with optional language specifier
445 pattern = r"(```|~~~)(.*?\n)(.*?)(\1)"
446
447 # Function to replace the code fences
448 def replacer(match):
449 return match.group(3) # Return the code without fences
450
451 # Use re.DOTALL to make '.' match newlines
452 result = re.sub(pattern, replacer, text, flags=re.DOTALL)
453
454 return result
455
456
457 def is_full_json_template(text):
458 # Pattern to match the entire text enclosed in ```json or ~~~json fences
459 pattern = r"^\s*(```|~~~)\s*json\s*\n(.*?)\n\1\s*$"
460 # Use re.DOTALL to make '.' match newlines
461 match = re.fullmatch(pattern, text.strip(), flags=re.DOTALL)
462 return bool(match)
463
464
465 def write_file(relative_path: str, content: str, encoding: str = "utf-8"):
466 abs_path = get_abs_path(relative_path)
467 os.makedirs(os.path.dirname(abs_path), exist_ok=True)
468 content = sanitize_string(content, encoding)
469 with open(abs_path, "w", encoding=encoding) as f:
470 f.write(content)
471
472 def delete_file(relative_path: str):
473 abs_path = get_abs_path(relative_path)
474 if exists(abs_path):
475 os.remove(abs_path)
476
477 def write_file_bin(relative_path: str, content: bytes):
478 abs_path = get_abs_path(relative_path)
479 os.makedirs(os.path.dirname(abs_path), exist_ok=True)
480 with open(abs_path, "wb") as f:
481 f.write(content)
482
483
484 def write_file_base64(relative_path: str, content: str):
485 # decode base64 string to bytes
486 data = base64.b64decode(content)
487 abs_path = get_abs_path(relative_path)
488 os.makedirs(os.path.dirname(abs_path), exist_ok=True)
489 with open(abs_path, "wb") as f:
490 f.write(data)
491
492
493 def delete_dir(relative_path: str):
494 # ensure deletion of directory without propagating errors
495 abs_path = get_abs_path(relative_path)
496 if os.path.exists(abs_path):
497 # first try with ignore_errors=True which is the safest option
498 shutil.rmtree(abs_path, ignore_errors=True)
499
500 # if directory still exists, try more aggressive methods
501 if os.path.exists(abs_path):
502 try:
503 # try to change permissions and delete again
504 for root, dirs, files in os.walk(abs_path, topdown=False):
505 for name in files:
506 file_path = os.path.join(root, name)
507 os.chmod(file_path, 0o777)
508 for name in dirs:
509 dir_path = os.path.join(root, name)
510 os.chmod(dir_path, 0o777)
511
512 # try again after changing permissions
513 shutil.rmtree(abs_path, ignore_errors=True)
514 except:
515 # suppress all errors - we're ensuring no errors propagate
516 pass
517
518
519 def move_dir(old_path: str, new_path: str):
520 # rename/move the directory from old_path to new_path (both relative)
521 abs_old = get_abs_path(old_path)
522 abs_new = get_abs_path(new_path)
523 if not os.path.isdir(abs_old):
524 return # nothing to rename
525
526 # ensure parent directory exists
527 os.makedirs(os.path.dirname(abs_new), exist_ok=True)
528
529 try:
530 os.rename(abs_old, abs_new)
531 except OSError:
532 # os.rename fails across Docker volume mount points
533 import shutil
534 shutil.move(abs_old, abs_new)
535
536
537 # move dir safely, remove with number if needed
538 def move_dir_safe(src, dst, rename_format="{name}_{number}"):
539 base_dst = dst
540 i = 2
541 while exists(dst):
542 dst = rename_format.format(name=base_dst, number=i)
543 i += 1
544 move_dir(src, dst)
545 return dst
546
547
548 # create dir safely, add number if needed
549 def create_dir_safe(dst, rename_format="{name}_{number}"):
550 base_dst = dst
551 i = 2
552 while exists(dst):
553 dst = rename_format.format(name=base_dst, number=i)
554 i += 1
555 create_dir(dst)
556 return dst
557
558
559 def create_dir(relative_path: str):
560 abs_path = get_abs_path(relative_path)
561 os.makedirs(abs_path, exist_ok=True)
562
563
564 def list_files(relative_path: str, filter: str = "*"):
565 abs_path = get_abs_path(relative_path)
566 if not os.path.exists(abs_path):
567 return []
568 return [file for file in os.listdir(abs_path) if fnmatch(file, filter)]
569
570
571 def make_dirs(relative_path: str):
572 abs_path = get_abs_path(relative_path)
573 os.makedirs(os.path.dirname(abs_path), exist_ok=True)
574
575
576 def _resolve_path(*relative_paths):
577 if len(relative_paths) == 1 and os.path.isabs(relative_paths[0]):
578 return relative_paths[0]
579 return os.path.join(_base_dir, *relative_paths)
580
581
582 def get_abs_path(*relative_paths):
583 "Convert relative paths to absolute paths based on the base directory."
584 return _resolve_path(*relative_paths)
585
586
587 def get_abs_path_dockerized(*relative_paths):
588 "Ensures the abs path is dockerized (i.e. /a0/... path)"
589 abs = get_abs_path(*relative_paths)
590 from helpers import runtime
591
592 if runtime.is_dockerized():
593 return abs
594 return normalize_a0_path(abs)
595
596
597 def get_abs_path_development(*relative_paths):
598 "Ensures the abs path is relevant for dev environment"
599 abs = get_abs_path(*relative_paths)
600 return fix_dev_path(abs)
601
602
603 def deabsolute_path(path: str):
604 "Convert absolute paths to relative paths based on the base directory."
605 return os.path.relpath(path, get_base_dir())
606
607
608 def fix_dev_path(path: str):
609 "On dev environment, convert /a0/... paths to local absolute paths"
610 from helpers.runtime import is_development
611
612 if is_development():
613 if path.startswith("/a0/"):
614 path = path.replace("/a0/", "")
615 return get_abs_path(path)
616
617
618 def normalize_a0_path(path: str):
619 "Convert absolute paths into /a0/... paths"
620 if is_in_base_dir(path):
621 deabs = deabsolute_path(path)
622 return "/a0/" + deabs
623 return path
624
625
626 def exists(*relative_paths):
627 path = _resolve_path(*relative_paths)
628 return os.path.exists(path)
629
630
631 def is_file(*relative_paths):
632 path = _resolve_path(*relative_paths)
633 return os.path.isfile(path)
634
635
636 def is_dir(*relative_paths):
637 path = _resolve_path(*relative_paths)
638 return os.path.isdir(path)
639
640
641 def get_base_dir():
642 return _base_dir
643
644
645 def basename(path: str, suffix: str | None = None):
646 if suffix:
647 return os.path.basename(path).removesuffix(suffix)
648 return os.path.basename(path)
649
650
651 def dirname(path: str):
652 return os.path.dirname(path)
653
654
655 def is_in_base_dir(path: str):
656 return is_in_dir(path, get_base_dir())
657
658
659 def is_in_dir(path: str, dir: str):
660 # check if the given path is within the directory
661 abs_path = os.path.abspath(path)
662 abs_dir = os.path.abspath(dir)
663 return os.path.commonpath([abs_path, abs_dir]) == abs_dir
664
665
666 def get_subdirectories(
667 relative_path: str,
668 include: str | list[str] = "*",
669 exclude: str | list[str] | None = None,
670 ):
671 abs_path = get_abs_path(relative_path)
672 if not os.path.exists(abs_path):
673 return []
674 if isinstance(include, str):
675 include = [include]
676 if isinstance(exclude, str):
677 exclude = [exclude]
678 return [
679 subdir
680 for subdir in os.listdir(abs_path)
681 if os.path.isdir(os.path.join(abs_path, subdir))
682 and any(fnmatch(subdir, inc) for inc in include)
683 and (exclude is None or not any(fnmatch(subdir, exc) for exc in exclude))
684 ]
685
686
687 def zip_dir(dir_path: str):
688 full_path = get_abs_path(dir_path)
689 zip_file_path = tempfile.NamedTemporaryFile(suffix=".zip", delete=False).name
690 base_name = os.path.basename(full_path)
691 with zipfile.ZipFile(zip_file_path, "w", compression=zipfile.ZIP_DEFLATED) as zip:
692 for root, _, files in os.walk(full_path):
693 for file in files:
694 file_path = os.path.join(root, file)
695 rel_path = os.path.relpath(file_path, full_path)
696 zip.write(file_path, os.path.join(base_name, rel_path))
697 return zip_file_path
698
699
700 def move_file(relative_path: str, new_path: str):
701 abs_path = get_abs_path(relative_path)
702 new_abs_path = get_abs_path(new_path)
703 os.makedirs(os.path.dirname(new_abs_path), exist_ok=True)
704 try:
705 os.rename(abs_path, new_abs_path)
706 except OSError:
707 # fallback to copy and delete
708 import shutil
709
710 shutil.copy2(abs_path, new_abs_path)
711 try:
712 os.unlink(abs_path)
713 except OSError:
714 pass
715
716
717 def safe_file_name(filename: str) -> str:
718 # Replace any character that's not alphanumeric, dash, underscore, or dot with underscore
719 return re.sub(r"[^a-zA-Z0-9-._]", "_", filename)
720
721
722 def read_text_files_in_dir(
723 dir_path: str, max_size: int = 1024 * 1024, pattern: str = "*"
724 ) -> dict[str, str]:
725
726 abs_path = get_abs_path(dir_path)
727 if not os.path.exists(abs_path):
728 return {}
729 result = {}
730 for file_path in [os.path.join(abs_path, f) for f in os.listdir(abs_path)]:
731 try:
732 if not os.path.isfile(file_path):
733 continue
734 if not fnmatch(os.path.basename(file_path), pattern):
735 continue
736 if max_size > 0 and os.path.getsize(file_path) > max_size:
737 continue
738 mime, _ = mimetypes.guess_type(file_path)
739 if mime is not None and not mime.startswith("text"):
740 continue
741 # Check if file is binary by reading a small chunk
742 content = read_file(file_path)
743 result[os.path.basename(file_path)] = content
744 except Exception:
745 continue
746 return result
747
748
749 def list_files_in_dir_recursively(relative_path: str) -> list[str]:
750 abs_path = get_abs_path(relative_path)
751 if not os.path.exists(abs_path):
752 return []
753 result = []
754 for root, dirs, files in os.walk(abs_path):
755 for file in files:
756 file_path = os.path.join(root, file)
757 # Return relative path from the base directory
758 rel_path = os.path.relpath(file_path, abs_path)
759 result.append(rel_path)
760 return result