main
py 933 lines 29.1 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 import re, json, glob
5 import time
6 from pathlib import Path
7 from typing import (
8 Any,
9 Dict,
10 Iterator,
11 List,
12 Literal,
13 Optional,
14 TYPE_CHECKING,
15 TypedDict,
16 )
17
18 from regex import W
19
20 from helpers import (
21 files,
22 git,
23 notification,
24 print_style,
25 yaml as yaml_helper,
26 cache,
27 extension,
28 watchdog,
29 modules,
30 functions,
31 )
32 from pydantic import BaseModel, Field
33
34 from helpers.defer import DeferredTask
35 from helpers.watchdog import WatchItem
36
37 if TYPE_CHECKING:
38 from agent import Agent
39
40 # Extracts target selector from <meta name="plugin-target" content="...">
41 _META_TARGET_RE = re.compile(
42 r'<meta\s+name=["\']plugin-target["\']\s+content=["\']([^"\']+)["\']',
43 re.IGNORECASE,
44 )
45
46
47 type ToggleState = Literal["enabled", "disabled"]
48 type CallerContext = Literal["ui", "agent", "api"]
49
50
51 class PluginAssetFile(TypedDict):
52 path: str
53 project_name: str
54 agent_profile: str
55
56
57 META_FILE_NAME = "plugin.yaml"
58 CONFIG_FILE_NAME = "config.json"
59 CONFIG_DEFAULT_FILE_NAME = "default_config.yaml"
60 DISABLED_FILE_NAME = ".toggle-0"
61 ENABLED_FILE_NAME = ".toggle-1"
62 TOGGLE_FILE_PATTERN = ".toggle-[01]"
63
64 HOOKS_SCRIPT = "hooks.py"
65 HOOKS_CACHE_AREA = "plugin_hooks(plugins)"
66 PLUGINS_LIST_CACHE_AREA = "plugins_list(plugins)"
67 ENABLED_PLUGINS_LIST_CACHE_AREA = "enabled_plugins(plugins)"
68 ENABLED_PLUGINS_PATHS_CACHE_AREA = "enabled_plugins_paths(plugins)"
69
70
71 _last_frontend_reload_notification_at = 0.0
72
73
74 class PluginMetadata(BaseModel):
75 name: str = ""
76 title: str = ""
77 description: str = ""
78 version: str = ""
79 settings_sections: List[str] = Field(default_factory=list)
80 per_project_config: bool = False
81 per_agent_config: bool = False
82 always_enabled: bool = False
83
84
85 class PluginListItem(BaseModel):
86 name: str
87 path: str
88 display_name: str = ""
89 description: str = ""
90 version: str = ""
91 author: str = ""
92 repo: str = ""
93 settings_sections: List[str] = Field(default_factory=list)
94 per_project_config: bool = False
95 per_agent_config: bool = False
96 always_enabled: bool = False
97 is_custom: bool = False
98 has_main_screen: bool = False
99 has_config_screen: bool = False
100 has_readme: bool = False
101 has_license: bool = False
102 has_execute_script: bool = False
103 toggle_state: ToggleState = "disabled"
104 current_commit: str = ""
105 current_commit_timestamp: str = ""
106 thumbnail_url: str = ""
107
108
109 class PluginUpdateInfo(BaseModel):
110 name: str
111 path: str
112 display_name: str = ""
113 commits_since_local: int = 0
114 last_remote_commit_at: str = ""
115 branch: str = ""
116 remote_branch: str = ""
117 is_git_repo: bool = False
118 is_remote: bool = False
119 error: str = ""
120
121
122 def register_watchdogs():
123
124 def on_plugin_change(events: list[WatchItem], frontend_reload: bool = True):
125 plugin_names: list[str] = []
126 for path, _event in events:
127 path = path.replace("\\", "/")
128 if "/plugins/" not in path:
129 continue
130 plugin_name = path.split("/plugins/", 1)[1].split("/", 1)[0]
131 if plugin_name and plugin_name not in plugin_names:
132 plugin_names.append(plugin_name)
133 print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names)
134 python_change = any(path.endswith('.py') for path, _event in events)
135 after_plugin_change(
136 plugin_names or None,
137 python_change=python_change,
138 frontend_reload=frontend_reload,
139 )
140
141 relevant_patterns = ["**/extensions/**/*", TOGGLE_FILE_PATTERN, HOOKS_SCRIPT]
142
143 # combine relevant patterns with base path
144 def expand_patterns(base_path: str):
145 result = []
146 for pattern in relevant_patterns:
147 result.append(base_path + pattern)
148 return result
149
150 # add watchdogs for plugin roots
151 watchdog.add_watchdog(
152 id="plugins_roots",
153 roots=get_plugin_roots(),
154 patterns=[*expand_patterns("*/")],
155 handler=on_plugin_change,
156 )
157
158 from helpers import projects
159 from helpers import subagents
160
161 # add watchdogs for plugin overrides in projects/plugins and projects/agents/plugins
162 watchdog.add_watchdog(
163 id="plugins_projects",
164 roots=[files.get_abs_path(projects.PROJECTS_PARENT_DIR)],
165 patterns=[
166 *expand_patterns(f"*/{projects.PROJECT_META_DIR}/plugins/"),
167 *expand_patterns(f"*/{projects.PROJECT_META_DIR}/agents/*/plugins/"),
168 ],
169 handler=lambda events: on_plugin_change(events, frontend_reload=False),
170 )
171
172 # add watchdogs for plugin overrides in /agents/plugins and /usr/agents/plugins
173 watchdog.add_watchdog(
174 id="plugins_agents",
175 roots=[
176 files.get_abs_path(subagents.DEFAULT_AGENTS_DIR),
177 files.get_abs_path(subagents.USER_AGENTS_DIR),
178 ],
179 patterns=[*expand_patterns(f"*/plugins/*/")],
180 handler=lambda events: on_plugin_change(events, frontend_reload=False),
181 )
182
183
184 @extension.extensible
185 def after_plugin_change(
186 plugin_names: list[str] | None = None,
187 python_change: bool = False,
188 frontend_reload: bool = True,
189 ):
190 clear_plugin_cache(plugin_names)
191 if python_change:
192 refresh_plugin_modules(plugin_names)
193 if frontend_reload:
194 send_frontend_reload_notification(plugin_names)
195
196
197 def refresh_plugin_modules(plugin_names: list[str] | None = None):
198 if plugin_names:
199 clear_plugins = any(name.startswith("_") for name in plugin_names)
200 clear_usr_plugins = any(not name.startswith("_") for name in plugin_names)
201 if clear_plugins:
202 modules.purge_namespace("plugins")
203 if clear_usr_plugins:
204 modules.purge_namespace("usr.plugins")
205 else:
206 modules.purge_namespace("plugins")
207 modules.purge_namespace("usr.plugins")
208
209
210 def clear_plugin_cache(plugin_names: list[str] | None = None):
211 areas = ["*(plugins)*", "*(extensions)*", "*(api)*"]
212 for area in areas:
213 cache.clear(area)
214
215 from helpers.ws_manager import send_data
216
217 DeferredTask().start_task(
218 send_data,
219 "clear_cache",
220 {"areas": areas},
221 endpoint_name="/ws",
222 )
223
224
225 def get_plugin_roots(plugin_name: str = "") -> List[str]:
226 """Plugin root directories, ordered by priority (user first)."""
227 return [
228 files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name),
229 files.get_abs_path(files.PLUGINS_DIR, plugin_name),
230 ]
231
232
233 def get_plugin_name_from_path(path: str | Path) -> str:
234 """Return the plugin directory name for a path under a canonical plugin root."""
235 candidate = Path(path).absolute()
236 for root in get_plugin_roots():
237 try:
238 return candidate.relative_to(Path(root).absolute()).parts[0]
239 except (IndexError, ValueError):
240 continue
241 return ""
242
243
244 def get_plugins_list():
245 if cached := cache.get(PLUGINS_LIST_CACHE_AREA, ""):
246 return cached
247
248 result: list[str] = []
249 seen_names: set[str] = set()
250 for root in get_plugin_roots():
251 for dir in Path(root).iterdir():
252 if not dir.is_dir() or dir.name.startswith("."):
253 continue
254 if dir.name in seen_names:
255 continue
256 if files.exists(str(dir), META_FILE_NAME):
257 seen_names.add(dir.name)
258 result.append(dir.name)
259 result.sort(key=lambda p: Path(p).name)
260
261 cache.add(PLUGINS_LIST_CACHE_AREA, "", result)
262 return result
263
264
265 def get_enhanced_plugins_list(
266 custom: bool = True, builtin: bool = True, plugin_names: list[str] | None = None
267 ) -> List[PluginListItem]:
268 """Discover plugins by directory convention. First root wins on ID conflict."""
269 results = []
270 allowed_names = set(plugin_names) if plugin_names else None
271
272 def load_plugins(root_path: str, is_custom: bool):
273 for d in sorted(Path(root_path).iterdir(), key=lambda p: p.name):
274 try:
275 if not d.is_dir() or d.name.startswith("."):
276 continue
277 if allowed_names is not None and d.name not in allowed_names:
278 continue
279 meta_file = str(d / META_FILE_NAME)
280 if not files.exists(meta_file):
281 continue
282 meta = PluginMetadata.model_validate(files.read_file_yaml(meta_file))
283 has_main_screen = files.exists(str(d / "webui" / "main.html"))
284 has_config_screen = files.exists(str(d / "webui" / "config.html"))
285 has_readme = files.exists(str(d / "README.md"))
286 has_license = files.exists(str(d / "LICENSE"))
287 has_execute_script = files.exists(str(d / "execute.py"))
288 toggle_state = get_toggle_state(d.name)
289 thumbnail_url = ""
290 _thumb_exts = ("png", "jpg", "jpeg", "gif", "webp")
291 for _ext in _thumb_exts:
292 _thumb = d / "webui" / f"thumbnail.{_ext}"
293 if _thumb.is_file():
294 thumbnail_url = f"/plugins/{d.name}/webui/thumbnail.{_ext}"
295 break
296 current_commit = ""
297 current_commit_timestamp = ""
298 author = ""
299 repo_name = ""
300 if is_custom:
301 repo_info = git.get_repo_release_info(str(d))
302 if repo_info.is_git_repo:
303 author = repo_info.author
304 repo_name = repo_info.repo
305 if repo_info.head:
306 current_commit = repo_info.head.hash
307 current_commit_timestamp = repo_info.head.committed_at
308 results.append(
309 PluginListItem(
310 name=d.name,
311 path=files.normalize_a0_path(str(d)),
312 display_name=meta.title or d.name,
313 description=meta.description,
314 version=meta.version,
315 author=author,
316 repo=repo_name,
317 settings_sections=meta.settings_sections,
318 per_project_config=meta.per_project_config,
319 per_agent_config=meta.per_agent_config,
320 always_enabled=meta.always_enabled,
321 is_custom=is_custom,
322 has_main_screen=has_main_screen,
323 has_config_screen=has_config_screen,
324 has_readme=has_readme,
325 has_license=has_license,
326 has_execute_script=has_execute_script,
327 toggle_state=toggle_state,
328 current_commit=current_commit,
329 current_commit_timestamp=current_commit_timestamp,
330 thumbnail_url=thumbnail_url,
331 )
332 )
333 except Exception as e:
334 print_style.PrintStyle.error(f"Failed to load plugin {d.name}: {e}")
335 continue
336
337 if custom:
338 load_plugins(files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR), True)
339 if builtin:
340 load_plugins(files.get_abs_path(files.PLUGINS_DIR), False)
341 return results
342
343
344 def get_custom_plugins_updates(
345 plugin_names: list[str] | None = None,
346 ) -> List[PluginUpdateInfo]:
347 plugins = get_enhanced_plugins_list(
348 custom=True, builtin=False, plugin_names=plugin_names
349 )
350 results: list[PluginUpdateInfo] = []
351
352 for plugin in plugins:
353 update = git.get_remote_commits_since_local(plugin.path)
354 results.append(
355 PluginUpdateInfo(
356 name=plugin.name,
357 path=plugin.path,
358 display_name=plugin.display_name,
359 commits_since_local=update.commits_since_local,
360 last_remote_commit_at=update.last_remote_commit_at,
361 branch=update.branch,
362 remote_branch=update.remote_branch,
363 is_git_repo=update.is_git_repo,
364 is_remote=update.is_remote,
365 error=update.error,
366 )
367 )
368
369 return results
370
371
372 def get_plugin_meta(plugin_name: str):
373 plugin_dir = find_plugin_dir(plugin_name)
374 if not plugin_dir:
375 return None
376 return PluginMetadata.model_validate(
377 files.read_file_yaml(files.get_abs_path(plugin_dir, META_FILE_NAME))
378 )
379
380
381 def find_plugin_dir(plugin_name: str):
382 if not plugin_name:
383 return None
384
385 # check if the plugin is in the user directory
386 user_plugin_path = files.get_abs_path(
387 files.USER_DIR, files.PLUGINS_DIR, plugin_name, META_FILE_NAME
388 )
389 if files.exists(user_plugin_path):
390 return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name)
391
392 # check if the plugin is in the default directory
393 default_plugin_path = files.get_abs_path(
394 files.PLUGINS_DIR, plugin_name, META_FILE_NAME
395 )
396 if files.exists(default_plugin_path):
397 return files.get_abs_path(files.PLUGINS_DIR, plugin_name)
398
399 return None
400
401
402 @extension.extensible
403 def uninstall_plugin(plugin_name):
404 # call the uninstall hook if any
405 call_plugin_hook(plugin_name, "uninstall")
406 # then delete
407 delete_plugin(plugin_name)
408
409
410 @extension.extensible
411 def delete_plugin(plugin_name: str):
412 plugin_dir = find_plugin_dir(plugin_name)
413 if not plugin_dir:
414 raise FileNotFoundError(f"Plugin '{plugin_name}' not found")
415 custom_plugins_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
416 if not files.is_in_dir(plugin_dir, custom_plugins_dir):
417 raise ValueError("Only custom plugins can be deleted")
418
419 # delete additional plugin folders
420 assets = [asset for asset in find_plugin_assets("", plugin_name=plugin_name) if not asset["path"].startswith(plugin_dir)]
421 for asset in assets:
422 files.delete_dir(asset["path"])
423
424 send_frontend_reload_notification(
425 [plugin_name]
426 ) # send before deletion to properly check the extensions, second notification will be skipped automatically
427
428 # does it have python files?
429 python_change = bool(files.find_existing_paths_by_pattern(plugin_dir+"/**/*.py"))
430
431 # delete main plugin folder
432 files.delete_dir(plugin_dir)
433
434 after_plugin_change([plugin_name], python_change=python_change)
435
436
437 def get_plugin_paths(*subpaths: str) -> List[str]:
438 sub = "*/" + "/".join(subpaths) if subpaths else "*"
439 paths: List[str] = []
440 for root in get_plugin_roots():
441 paths.extend(
442 files.find_existing_paths_by_pattern(files.get_abs_path(root, sub))
443 )
444 return paths
445
446
447 def get_enabled_plugin_paths(agent: Agent | None, *subpaths: str) -> List[str]:
448 if cached := cache.get(
449 ENABLED_PLUGINS_PATHS_CACHE_AREA, cache.determine_cache_key(agent, *subpaths)
450 ):
451 return cached
452
453 enabled = get_enabled_plugins(agent)
454 paths: list[str] = []
455
456 for plugin in enabled:
457 base_dir = find_plugin_dir(plugin)
458 if not base_dir:
459 continue
460
461 if not subpaths:
462 if files.exists(base_dir):
463 paths.append(base_dir)
464 continue
465
466 path_pattern = files.get_abs_path(base_dir, *subpaths)
467 paths.extend(files.find_existing_paths_by_pattern(path_pattern))
468
469 cache.add(
470 ENABLED_PLUGINS_PATHS_CACHE_AREA,
471 cache.determine_cache_key(agent, *subpaths),
472 paths,
473 )
474
475 return paths
476
477
478 def get_enabled_plugins(agent: Agent | None):
479 if cached := cache.get(
480 ENABLED_PLUGINS_LIST_CACHE_AREA, cache.determine_cache_key(agent)
481 ):
482 return cached
483
484 plugins = get_plugins_list()
485 active = []
486
487 for plugin in plugins:
488 meta = get_plugin_meta(plugin)
489 if meta and meta.always_enabled:
490 active.append(plugin)
491 continue
492
493 # plugins are toggled via .enabled / .disabled files
494 # every plugin is on by default, unless disabled in usr dir
495 enabled = True
496
497 # root plugin paths
498 plugin_paths = get_plugin_roots(plugin)
499
500 # + agent paths
501 if agent:
502 from helpers import subagents
503
504 agent_paths = subagents.get_paths(
505 agent,
506 files.PLUGINS_DIR,
507 plugin,
508 must_exist_completely=True,
509 include_default=False,
510 include_user=False,
511 include_plugins=False,
512 include_project=True,
513 )
514 plugin_paths = agent_paths + plugin_paths
515
516 # go through paths in reverse order and determine the state
517 enabled = determined_toggle_from_paths(enabled, reversed(plugin_paths))
518
519 if enabled:
520 active.append(plugin)
521
522 cache.add(ENABLED_PLUGINS_LIST_CACHE_AREA, cache.determine_cache_key(agent), active)
523
524 return active
525
526
527 def determined_toggle_from_paths(default: bool, paths: Iterator[str]):
528 enabled = default
529 for plugin_path in paths:
530 if enabled:
531 enabled = not files.exists(
532 files.get_abs_path(plugin_path, DISABLED_FILE_NAME)
533 )
534 else:
535 enabled = files.exists(files.get_abs_path(plugin_path, ENABLED_FILE_NAME))
536 return enabled
537
538
539 def get_toggle_state(plugin_name: str) -> ToggleState:
540 meta = get_plugin_meta(plugin_name)
541 if not meta:
542 return "disabled"
543 if meta.always_enabled:
544 return "enabled"
545
546 # List-level activation is the global/root state. Scoped project/profile
547 # overrides are managed inside the plugin config modal.
548 plugin_paths = get_plugin_roots(plugin_name)
549 return (
550 "enabled"
551 if determined_toggle_from_paths(True, reversed(plugin_paths))
552 else "disabled"
553 )
554
555
556 @extension.extensible
557 def toggle_plugin(
558 plugin_name: str,
559 enabled: bool,
560 project_name: str = "",
561 agent_profile: str = "",
562 clear_overrides: bool = False,
563 ):
564 meta = get_plugin_meta(plugin_name)
565 if meta and meta.always_enabled and not enabled:
566 raise ValueError(f'Plugin "{plugin_name}" is always enabled.')
567
568 if clear_overrides:
569 all_toggles = find_plugin_assets(
570 TOGGLE_FILE_PATTERN,
571 plugin_name=plugin_name,
572 project_name="*",
573 agent_profile="*",
574 only_first=False,
575 )
576 for toggle in all_toggles:
577 files.delete_file(toggle["path"])
578
579 enabled_file = determine_plugin_asset_path(
580 plugin_name, project_name, agent_profile, ENABLED_FILE_NAME
581 )
582 disabled_file = determine_plugin_asset_path(
583 plugin_name, project_name, agent_profile, DISABLED_FILE_NAME
584 )
585
586 # ensure clean state by deleting both potential files first
587 files.delete_file(enabled_file)
588 files.delete_file(disabled_file)
589
590 if enabled:
591 files.write_file(enabled_file, "")
592 else:
593 files.write_file(disabled_file, "")
594 after_plugin_change(
595 [plugin_name], frontend_reload=not (project_name or agent_profile)
596 )
597
598
599 @extension.extensible
600 def get_plugin_config(
601 plugin_name: str,
602 agent: Agent | None = None,
603 project_name: str | None = None,
604 agent_profile: str | None = None,
605 caller: CallerContext = "api",
606 ):
607
608 default_used = False
609
610 if project_name is None and agent is not None:
611 from helpers import projects
612
613 project_name = projects.get_context_project_name(agent.context)
614 if agent_profile is None and agent is not None:
615 agent_profile = agent.config.profile
616
617 # find config.json in all possible places
618 file = find_plugin_asset(
619 plugin_name,
620 CONFIG_FILE_NAME,
621 project_name=project_name or "",
622 agent_profile=agent_profile or "",
623 )
624 file_path = file.get("path", "") if file else ""
625
626 # use default config if not found
627 if not file_path:
628 plugin_dir = find_plugin_dir(plugin_name)
629 if not plugin_dir:
630 return None
631 file_path = files.get_abs_path(plugin_dir, CONFIG_DEFAULT_FILE_NAME)
632 default_used = True
633
634 result = None
635 if file_path and files.exists(file_path):
636 result = (
637 json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
638 )(files.read_file(file_path))
639
640 if default_used:
641 _apply_defaults_from_env(plugin_name, result)
642
643 # call plugin hook to modify the standard result if needed
644 result = call_plugin_hook(
645 plugin_name,
646 "get_plugin_config",
647 default=result,
648 agent=agent,
649 project_name=project_name,
650 agent_profile=agent_profile,
651 hook_context={"caller": caller},
652 )
653
654 return result
655
656
657 def get_default_plugin_config(plugin_name: str):
658 plugin_dir = find_plugin_dir(plugin_name)
659 if not plugin_dir:
660 return None
661
662 file_path = files.get_abs_path(plugin_dir, CONFIG_DEFAULT_FILE_NAME)
663
664 # call plugin hook to get the result
665 result = call_plugin_hook(
666 plugin_name, "get_default_plugin_config", file_path=file_path
667 )
668
669 # or do standard load
670 if result is None and file_path and files.exists(file_path):
671 result = (
672 json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
673 )(files.read_file(file_path))
674
675 return result
676
677
678 @extension.extensible
679 def save_plugin_config(
680 plugin_name: str,
681 project_name: str,
682 agent_profile: str,
683 settings: dict,
684 caller: CallerContext = "api",
685 ):
686 file_path = determine_plugin_asset_path(
687 plugin_name, project_name, agent_profile, CONFIG_FILE_NAME
688 )
689
690 # call plugin hook to get the result first
691 new_settings = call_plugin_hook(
692 plugin_name,
693 "save_plugin_config",
694 default=settings,
695 project_name=project_name,
696 agent_profile=agent_profile,
697 settings=settings,
698 hook_context={"caller": caller},
699 )
700
701 # or do standard load
702 if new_settings is not None and file_path:
703 files.write_file(file_path, json.dumps(new_settings))
704 # after_plugin_change([plugin_name]) # don't trigger when only config changes
705
706
707 def find_plugin_asset(
708 plugin_name: str, *subpaths: str, project_name="", agent_profile=""
709 ):
710 result = find_plugin_assets(
711 *subpaths,
712 plugin_name=plugin_name,
713 project_name=project_name,
714 agent_profile=agent_profile,
715 only_first=True,
716 )
717 return result[0] if result else None
718
719
720 def find_plugin_assets(
721 *subpaths: str,
722 plugin_name: str = "*",
723 project_name: str = "*",
724 agent_profile: str = "*",
725 only_first: bool = False,
726 ) -> list[PluginAssetFile]:
727 from helpers import projects, subagents
728
729 results: list[PluginAssetFile] = []
730
731 def _collect(path: str, proj: str, profile: str) -> bool:
732 is_glob = glob.has_magic(path)
733 matched_paths = (
734 files.find_existing_paths_by_pattern(path)
735 if is_glob
736 else ([path] if files.exists(path) else [])
737 )
738
739 need_proj = proj == "*"
740 need_prof = profile == "*"
741
742 def _after(s: str, marker: str, last: bool = False) -> str:
743 i = s.rfind(marker) if last else s.find(marker)
744 if i == -1:
745 return ""
746 start = i + len(marker)
747 end = s.find("/", start)
748 return s[start:] if end == -1 else s[start:end]
749
750 for matched in matched_paths:
751 inferred_proj = _after(matched, "/projects/") if need_proj else proj
752 inferred_prof = (
753 _after(matched, "/agents/", last=True) if need_prof else profile
754 )
755 results.append(
756 {
757 "project_name": inferred_proj,
758 "agent_profile": inferred_prof,
759 "path": matched,
760 }
761 )
762 if only_first:
763 return True
764 return False
765
766 # project/.a0proj/agents/<profile>/plugins/<plugin_name>/...
767 if project_name:
768 if agent_profile:
769 path = projects.get_project_meta(
770 project_name,
771 files.AGENTS_DIR,
772 agent_profile,
773 files.PLUGINS_DIR,
774 plugin_name,
775 *subpaths,
776 )
777 if _collect(path, project_name, agent_profile):
778 return results
779 # project/.a0proj/plugins/<plugin_name>/... (always check as fallback, even when agent_profile is set)
780 path = projects.get_project_meta(
781 project_name, files.PLUGINS_DIR, plugin_name, *subpaths
782 )
783 if _collect(path, project_name, ""):
784 return results
785
786 # usr/agents/<profile>/plugins/<plugin_name>/...
787 if agent_profile:
788 path = files.get_abs_path(
789 subagents.USER_AGENTS_DIR,
790 agent_profile,
791 files.PLUGINS_DIR,
792 plugin_name,
793 *subpaths,
794 )
795 if _collect(path, "", agent_profile):
796 return results
797
798 # usr?/plugins/<any_plugin>/agents/<profile>/plugins/<plugin_name>/...
799 for plugin_base in get_enabled_plugin_paths(None):
800 path = files.get_abs_path(
801 plugin_base,
802 files.AGENTS_DIR,
803 agent_profile,
804 files.PLUGINS_DIR,
805 plugin_name,
806 *subpaths,
807 )
808 if _collect(path, "", agent_profile):
809 return results
810
811 # agents/<profile>/plugins/<plugin_name>/...
812 path = files.get_abs_path(
813 subagents.DEFAULT_AGENTS_DIR,
814 agent_profile,
815 files.PLUGINS_DIR,
816 plugin_name,
817 *subpaths,
818 )
819 if _collect(path, "", agent_profile):
820 return results
821
822 # usr/plugins/<plugin_name>/...
823 path = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name, *subpaths)
824 if _collect(path, "", ""):
825 return results
826
827 # plugins/<plugin_name>/...
828 path = files.get_abs_path(files.PLUGINS_DIR, plugin_name, *subpaths)
829 _collect(path, "", "")
830
831 return results
832
833
834 def determine_plugin_asset_path(
835 plugin_name: str, project_name: str, agent_profile: str, *subpaths: str
836 ):
837 base_path = files.get_abs_path(files.USER_DIR)
838
839 if project_name:
840 from helpers import projects
841
842 base_path = projects.get_project_meta(project_name)
843
844 if agent_profile:
845 base_path = files.get_abs_path(base_path, files.AGENTS_DIR, agent_profile)
846
847 return files.get_abs_path(base_path, files.PLUGINS_DIR, plugin_name, *subpaths)
848
849
850 def send_frontend_reload_notification(plugin_names: list[str] | None = None):
851 """If the plugin changed has webui extensions, notify frontend to reload the page"""
852 global _last_frontend_reload_notification_at
853
854 display_time = 5
855 now = time.monotonic()
856 if now - _last_frontend_reload_notification_at < display_time:
857 return
858
859 if plugin_names:
860 has_webui_extension = False
861 for plugin_name in plugin_names:
862 plugin_dir = find_plugin_dir(plugin_name)
863 if plugin_dir and files.exists(
864 files.get_abs_path(plugin_dir, "extensions", "webui")
865 ):
866 has_webui_extension = True
867 break
868 if not has_webui_extension:
869 return
870
871 async def _send_later():
872 global _last_frontend_reload_notification_at
873
874 await asyncio.sleep(1)
875
876 _last_frontend_reload_notification_at = time.monotonic()
877
878 notification.NotificationManager.send_notification(
879 type=notification.NotificationType.INFO,
880 priority=notification.NotificationPriority.NORMAL,
881 title="Plugins with frontend extensions updated, page reload recommended",
882 message="""<div class="toast-action-row"><button type="button" class="button confirm" @click.stop="$store.notificationStore.dismissToastAndReload(toast.toastId)"><span class="icon material-symbols-outlined">refresh</span>Reload page</button></div>""",
883 detail="",
884 display_time=0,
885 group="plugins_changed",
886 id="plugins_frontend_reload",
887 )
888
889 DeferredTask().start_task(_send_later)
890
891
892 def call_plugin_hook(
893 plugin_name: str, hook_name: str, default: Any = None, *args, **kwargs
894 ):
895 hooks = None
896
897 # use cached hooks if enabled
898 if not cache.has(HOOKS_CACHE_AREA, plugin_name):
899 plugin_dir = find_plugin_dir(plugin_name)
900 if not plugin_dir:
901 return default # plugin directory not found, skip hooks
902 hooks_script = files.get_abs_path(plugin_dir, HOOKS_SCRIPT)
903 hooks = (
904 modules.import_module(hooks_script) if files.exists(hooks_script) else None
905 )
906 cache.add(HOOKS_CACHE_AREA, plugin_name, hooks)
907 else:
908 hooks = cache.get(HOOKS_CACHE_AREA, plugin_name)
909
910 if not hooks:
911 return default
912
913 hook = getattr(hooks, hook_name, None)
914 if not hook:
915 return default
916
917 if asyncio.iscoroutinefunction(hook):
918 return asyncio.run(functions.safe_call(hook, *args, default=default, **kwargs))
919
920 return functions.safe_call(hook, *args, default=default, **kwargs)
921
922
923 def _apply_defaults_from_env(plugin_name: str, config: dict[str, Any]):
924 from helpers.settings import get_default_value
925
926 def _apply(prefix: list[str], value: dict[str, Any]):
927 for key, child in value.items():
928 env_name = "__".join([plugin_name, *prefix, key])
929 value[key] = get_default_value(env_name, child)
930 if isinstance(value[key], dict):
931 _apply([*prefix, key], value[key])
932
933 _apply([], config)