diff --git a/agent-node/VERSION b/agent-node/VERSION index e7468c7..5257575 100644 --- a/agent-node/VERSION +++ b/agent-node/VERSION @@ -1 +1 @@ -1.0.76 +1.0.77 diff --git a/agent-node/src/agent_node/core/sync.py b/agent-node/src/agent_node/core/sync.py index 9d4d6cf..3855222 100644 --- a/agent-node/src/agent_node/core/sync.py +++ b/agent-node/src/agent_node/core/sync.py @@ -74,7 +74,7 @@ print(f"[📁] Reconciling Sync Directory: {session_dir}") from shared_core.ignore import CortexIgnore - ignore_filter = CortexIgnore(session_dir) + ignore_filter = CortexIgnore(session_dir, is_upstream=True) expected_paths = {f.path for f in manifest.files} # 1. Purge extraneous local files and directories (handles Deletions) diff --git a/agent-node/src/agent_node/core/watcher.py b/agent-node/src/agent_node/core/watcher.py index 5285f09..bfebe68 100644 --- a/agent-node/src/agent_node/core/watcher.py +++ b/agent-node/src/agent_node/core/watcher.py @@ -21,7 +21,7 @@ self.session_id = session_id self.root_path = os.path.realpath(root_path) self.callback = callback - self.ignore_filter = CortexIgnore(self.root_path) + self.ignore_filter = CortexIgnore(self.root_path, is_upstream=True) self.last_sync = {} # path -> last_hash self.locked = False self.suppressed_paths = set() # Paths currently being modified by the system @@ -116,7 +116,7 @@ # Phase 3: Dynamic reload if .cortexignore / .gitignore changed if rel_path in [".cortexignore", ".gitignore"]: print(f" [*] Reloading Ignore Filter for {self.session_id}") - self.ignore_filter = CortexIgnore(self.root_path) + self.ignore_filter = CortexIgnore(self.root_path, is_upstream=True) if self.ignore_filter.is_ignored(rel_path): return diff --git a/agent-node/src/shared_core/ignore.py b/agent-node/src/shared_core/ignore.py index c3f0cb5..69e2884 100644 --- a/agent-node/src/shared_core/ignore.py +++ b/agent-node/src/shared_core/ignore.py @@ -3,12 +3,15 @@ class CortexIgnore: """Handles .cortexignore (and .gitignore) pattern matching.""" - def __init__(self, root_path): + def __init__(self, root_path, is_upstream=False): self.root_path = root_path + self.is_upstream = is_upstream self.patterns = self._load_patterns() def _load_patterns(self): patterns = [".git", "node_modules", ".cortex_sync", "__pycache__", "*.pyc"] # Default ignores + if self.is_upstream: + patterns.append(".skills") ignore_file = os.path.join(self.root_path, ".cortexignore") if not os.path.exists(ignore_file): ignore_file = os.path.join(self.root_path, ".gitignore") diff --git a/ai-hub/app/core/grpc/shared_core/ignore.py b/ai-hub/app/core/grpc/shared_core/ignore.py index 1bbc230..8e95857 100644 --- a/ai-hub/app/core/grpc/shared_core/ignore.py +++ b/ai-hub/app/core/grpc/shared_core/ignore.py @@ -3,12 +3,15 @@ class CortexIgnore: """Handles .cortexignore (and .gitignore) pattern matching.""" - def __init__(self, root_path): + def __init__(self, root_path, is_upstream=False): self.root_path = root_path + self.is_upstream = is_upstream self.patterns = self._load_patterns() def _load_patterns(self): patterns = [".git", "node_modules", ".cortex_sync", "__pycache__", "*.pyc", ".browser_data"] # Default ignores + if self.is_upstream: + patterns.append(".skills") ignore_file = os.path.join(self.root_path, ".cortexignore") if not os.path.exists(ignore_file): ignore_file = os.path.join(self.root_path, ".gitignore") diff --git a/ai-hub/app/core/orchestration/profiles.py b/ai-hub/app/core/orchestration/profiles.py index 7e698cd..a4c31d0 100644 --- a/ai-hub/app/core/orchestration/profiles.py +++ b/ai-hub/app/core/orchestration/profiles.py @@ -10,7 +10,7 @@ - **Large Data Rule**: For files larger than 100KB, DO NOT use `mesh_file_explorer`'s `write` action. Instead, use `mesh_terminal_control` with native commands like `dd`, `head`, `base64`, or `cat <` tags. Use standard function calling. @@ -22,7 +22,7 @@ ## ✍️ Interaction Format (MANDATORY PROTOCOL): 1. **TITLE (MANDATORY)**: Your turn **MUST** begin with exactly one line: `Title: Your Specific Objective`. - - **CRITICAL**: This line must appear **BEFORE** any `` tags and before any other text. + - **CRITICAL**: This line must appear **BEFORE** any `` tags or any other text. 2. **BRIDGE ANALYSIS**: Provide 1-2 sentences of auditable analysis. 3. **ACT**: Call the single atomic tool required for your plan. diff --git a/ai-hub/app/core/services/session.py b/ai-hub/app/core/services/session.py index 5c6669b..8700417 100644 --- a/ai-hub/app/core/services/session.py +++ b/ai-hub/app/core/services/session.py @@ -1,3 +1,4 @@ +import os import uuid import logging from sqlalchemy.orm import Session @@ -12,6 +13,41 @@ def __init__(self, services=None): self.services = services + def _mount_skills_to_workspace(self, db: Session, session: models.Session): + if not session.sync_workspace_id: return + try: + orchestrator = getattr(self.services, "orchestrator", None) + tool_service = getattr(self.services, "tool_service", None) + if not orchestrator or not orchestrator.mirror or not tool_service: return + + workspace_path = orchestrator.mirror.get_workspace_path(session.sync_workspace_id) + skills_dir = os.path.join(workspace_path, ".skills") + os.makedirs(skills_dir, exist_ok=True) + + tools = tool_service.get_available_tools(db, user_id=session.user_id, feature=session.feature_name) + valid_tool_names = {t["function"]["name"] for t in tools} + + from app.core.skills.fs_loader import fs_loader + from app.config import settings + all_fs_skills = fs_loader.get_all_skills() + + for fs_skill in all_fs_skills: + skill_name = fs_skill.get("name") + if skill_name in valid_tool_names: + feature = fs_skill.get("features", ["chat"])[0] + skill_id = fs_skill.get("id", "").replace("fs-", "") + skill_path = os.path.join(settings.DATA_DIR, "skills", feature, skill_id) + link_path = os.path.join(skills_dir, skill_name) + + if os.path.exists(skill_path): + if not os.path.exists(link_path): + try: + os.symlink(skill_path, link_path, target_is_directory=True) + except OSError: + pass + except Exception as e: + logger.error(f"Failed to mount skills to workspace: {e}") + def create_session( self, db: Session, @@ -99,6 +135,7 @@ logger.error(f"Failed to trigger sync for node {nid}: {sync_err}") except Exception as e: logger.error(f"Failed to initialize orchestrator sync: {e}") + self._mount_skills_to_workspace(db, session) return session def attach_nodes(self, db: Session, session_id: int, request: schemas.NodeAttachRequest) -> schemas.SessionNodeStatusResponse: @@ -194,6 +231,7 @@ except Exception as e: logger.error(f"Failed to trigger session node sync: {e}") + self._mount_skills_to_workspace(db, session) return schemas.SessionNodeStatusResponse( session_id=session_id, sync_workspace_id=session.sync_workspace_id, diff --git a/ai-hub/app/core/services/tool.py b/ai-hub/app/core/services/tool.py index f714390..14736f5 100644 --- a/ai-hub/app/core/services/tool.py +++ b/ai-hub/app/core/services/tool.py @@ -83,16 +83,18 @@ skill_md_file = next((f for f in ds.files if f.file_path == "SKILL.md"), None) if ds.files else None if skill_md_file and skill_md_file.content: - # If the skill text fits within the model's dynamic safe boundary, provide it directly! - if len(skill_md_file.content) > max_md_len: - excerpt = skill_md_file.content[:max_md_len].strip() + "..." - description += ( - f"\n\n[VFS Skill] Full instructions available. Call `read_skill_artifact` with " - f"skill_name='{ds.name}', file_path='SKILL.md' to load them before using this tool.\n" - f"Summary: {excerpt}" - ) - else: - description += f"\n\n[Instructions]\n{skill_md_file.content}" + exec_file = "" + for f in ds.files: + if f.file_path.endswith(".sh") or f.file_path.endswith(".py") or "run." in f.file_path: + exec_file = f.file_path + break + exec_cmd = f"bash .skills/{ds.name}/{exec_file}" if exec_file.endswith(".sh") else f"python3 .skills/{ds.name}/{exec_file}" if exec_file.endswith(".py") else f".skills/{ds.name}/{exec_file}" + + description += ( + f"\n\n[VFS Skill] This skill is natively mapped to your workspace.\n" + f"You can execute it directly on the node via: `{exec_cmd}`.\n" + f"[Instructions]\n{skill_md_file.content}" + ) # Parse YAML frontmatter to get the tool schema parameters if skill_md_file.content.startswith("---"): @@ -125,16 +127,11 @@ desc_match = re.search(r"\*\*Description:\*\*\s*(.*?)(?=\n\n|\n#|$)", skill_md_file.content, re.DOTALL | re.IGNORECASE) if desc_match: extracted_desc = desc_match.group(1).strip() - # Keep the same logic as above when updating the description if parameters failed - if len(skill_md_file.content) > max_md_len: - excerpt = skill_md_file.content[:max_md_len].strip() + "..." - description = ( - f"{extracted_desc}\n\n[VFS Skill] Full instructions available. Call `read_skill_artifact` with " - f"skill_name='{ds.name}', file_path='SKILL.md' to load them before using this tool.\n" - f"Summary: {excerpt}" - ) - else: - description = f"{extracted_desc}\n\n[Instructions]\n{skill_md_file.content}" + description = ( + f"{extracted_desc}\n\n[VFS Skill] This skill is natively mapped to your workspace.\n" + f"You can execute it directly on the node via: `{exec_cmd}`.\n" + f"[Instructions]\n{skill_md_file.content}" + ) # Parse Parameters Table table_pattern = r"\|\s*Name\s*\|\s*Type\s*\|\s*Description\s*\|\s*Required\s*\|\n(?:\|[-:\s]+\|[-:\s]+\|[-:\s]+\|[-:\s]+\|\n)(.*?)(?=\n\n|\n#|$)" @@ -215,9 +212,14 @@ try: import asyncio if asyncio.iscoroutinefunction(task_fn): - return await task_fn(**task_args) + res = await task_fn(**task_args) else: - return task_fn(**task_args) + res = task_fn(**task_args) + + # M6: Post-processing for Binary Artifacts (Screenshots, etc.) + if isinstance(res, dict): + res = self._process_binary_artifacts(tool_name, res, session_id, arguments, orchestrator.assistant if orchestrator else None) + return res except Exception as e: logger.exception(f"Plugin '{tool_name}' execution failed: {e}") return {"success": False, "error": str(e)} @@ -337,45 +339,60 @@ if bash_match: bash_logic = bash_match.group(1).strip() - if bash_logic: - class DynamicBashPlugin: - name = skill.name - retries = 0 - def prepare_task(self, invoke_args, invoke_context): + if not bash_logic: + exec_file = "" + for f in getattr(skill, "files", []): + if f.file_path.endswith(".sh") or f.file_path.endswith(".py") or "run." in f.file_path: + exec_file = f.file_path + break + exec_cmd = f"bash .skills/{skill.name}/{exec_file}" if exec_file.endswith(".sh") else f"python3 .skills/{skill.name}/{exec_file}" if exec_file.endswith(".py") else f".skills/{skill.name}/{exec_file}" + + class DynamicBashPlugin: + name = skill.name + retries = 0 + def prepare_task(self, invoke_args, invoke_context): + import shlex + + if bash_logic: cmd = bash_logic for k, v in invoke_args.items(): cmd = cmd.replace(f"${{{k}}}", str(v)) - - timeout = int(invoke_args.get("timeout", 60)) - node_id = invoke_context.get("node_id") - node_ids = invoke_context.get("node_ids") - resolved_sid = invoke_context.get("session_id") - assistant = invoke_context.get("assistant") - services = invoke_context.get("services") + else: + # Auto-bridging fallback: construct command with env vars and positional args + safe_args = {k: v for k, v in invoke_args.items() if k != "timeout" and k != "node_id" and k != "node_ids"} + bash_env = " ".join([f'{k}={shlex.quote(str(v))}' for k, v in safe_args.items()]) + bash_args_str = " ".join([shlex.quote(str(v)) for v in safe_args.values()]) + cmd = f"{bash_env} {exec_cmd} {bash_args_str}".strip() - if node_id in ["hub", "server", "local"] or (node_ids and any(nid in ["hub", "server", "local"] for nid in node_ids)): - def _hub_command(**kwargs): - import subprocess, os - cwd = os.getcwd() - if kwargs.get("resolved_sid") and getattr(services, "orchestrator", None): - try: cwd = services.orchestrator.mirror.get_workspace_path(kwargs.get("resolved_sid")) - except: pass - try: - proc = subprocess.run(kwargs.get("cmd"), shell=True, capture_output=True, text=True, timeout=kwargs.get("timeout"), cwd=cwd) - return {"status": "SUCCESS" if proc.returncode == 0 else "FAILED", "stdout": proc.stdout, "stderr": proc.stderr, "exit_code": proc.returncode, "node_id": "hub"} - except subprocess.TimeoutExpired as e: - return {"status": "TIMEOUT", "stdout": e.stdout or "", "stderr": e.stderr or "", "error": "Command timed out"} - except Exception as e: - return {"status": "ERROR", "error": str(e)} - return _hub_command, {"cmd": cmd, "timeout": timeout, "resolved_sid": resolved_sid} - elif node_ids and isinstance(node_ids, list): - return assistant.dispatch_swarm, {"node_ids": node_ids, "cmd": cmd, "timeout": timeout, "session_id": resolved_sid, "no_abort": False} - elif node_id: - return assistant.dispatch_single, {"node_id": node_id, "cmd": cmd, "timeout": timeout, "session_id": resolved_sid, "no_abort": False} - return None, {"success": False, "error": "target node_id or node_ids is required for Bash execution"} - plugin = DynamicBashPlugin() - else: - return {"success": False, "error": f"Tool implementation '{skill.name}' not found in registry and no Bash logic found in SKILL.md"} + timeout = int(invoke_args.get("timeout", 60)) + node_id = invoke_context.get("node_id", invoke_args.get("node_id")) + node_ids = invoke_context.get("node_ids", invoke_args.get("node_ids")) + resolved_sid = invoke_context.get("session_id") + assistant = invoke_context.get("assistant") + services = invoke_context.get("services") + + if node_id in ["hub", "server", "local"] or (node_ids and any(nid in ["hub", "server", "local"] for nid in node_ids)): + def _hub_command(**kwargs): + import subprocess, os + cwd = os.getcwd() + if kwargs.get("resolved_sid") and getattr(services, "orchestrator", None): + try: cwd = services.orchestrator.mirror.get_workspace_path(kwargs.get("resolved_sid")) + except: pass + try: + proc = subprocess.run(kwargs.get("cmd"), shell=True, capture_output=True, text=True, timeout=kwargs.get("timeout"), cwd=cwd) + return {"status": "SUCCESS" if proc.returncode == 0 else "FAILED", "stdout": proc.stdout, "stderr": proc.stderr, "exit_code": proc.returncode, "node_id": "hub"} + except subprocess.TimeoutExpired as e: + return {"status": "TIMEOUT", "stdout": e.stdout or "", "stderr": e.stderr or "", "error": "Command timed out"} + except Exception as e: + return {"status": "ERROR", "error": str(e)} + return _hub_command, {"cmd": cmd, "timeout": timeout, "resolved_sid": resolved_sid} + elif node_ids and isinstance(node_ids, list): + return assistant.dispatch_swarm, {"node_ids": node_ids, "cmd": cmd, "timeout": timeout, "session_id": resolved_sid, "no_abort": False} + elif node_id: + return assistant.dispatch_single, {"node_id": node_id, "cmd": cmd, "timeout": timeout, "session_id": resolved_sid, "no_abort": False} + return None, {"success": False, "error": f"Please map a target node_id to execute this tool natively."} + + plugin = DynamicBashPlugin() context = { "db": db, @@ -415,59 +432,7 @@ # M6: Post-processing for Binary Artifacts (Screenshots, etc.) if isinstance(res, dict): - # Unconditionally prevent binary data from leaking into the JSON serializer - screenshot_bits = res.pop("_screenshot_bytes", None) - - if skill.name == "browser_automation_agent": - # Organise browser data by session for better UX - if resolved_sid and resolved_sid != "__fs_explorer__": - try: - abs_workspace = assistant.mirror.get_workspace_path(resolved_sid) - # M6: Use .browser_data (ignored from node sync) - base_dir = os.path.join(abs_workspace, ".browser_data") - os.makedirs(base_dir, exist_ok=True) - - timestamp = int(time.time()) - action = args.get("action", "unknown").lower() - - # Clean filename for the image: {timestamp}_{action}.png - ss_filename = f"{timestamp}_{action}.png" - - # Save Screenshot if available - if screenshot_bits: - ss_path = os.path.join(base_dir, ss_filename) - with open(ss_path, "wb") as f: - f.write(screenshot_bits) - res["screenshot_url"] = f"/.browser_data/{resolved_sid}/{ss_filename}" - res["_visual_feedback"] = f"Action screenshot captured: {res['screenshot_url']}" - - # Save Metadata/A11y into a hidden or specific sub-folder if needed, - # but keep images in the root of the session for quick gallery view. - action_dir = os.path.join(base_dir, ".metadata", f"{timestamp}_{action}") - os.makedirs(action_dir, exist_ok=True) - - # Save Metadata/Result for easy debugging in file explorer - import json - meta = { - "timestamp": timestamp, - "action": action, - "url": res.get("url"), - "title": res.get("title"), - "success": res.get("success"), - "error": res.get("error"), - "eval_result": res.get("eval_result") - } - with open(os.path.join(action_dir, "metadata.json"), "w") as f: - json.dump(meta, f, indent=2) - - # Optional: Save A11y summary for quick viewing - if "a11y_summary" in res: - with open(os.path.join(action_dir, "a11y_summary.txt"), "w") as f: - f.write(res["a11y_summary"]) - - logger.info(f"[ToolService] Browser artifacts saved to: {action_dir}") - except Exception as sse: - logger.warning(f"Failed to persist browser data to workspace: {sse}") + res = self._process_binary_artifacts(skill.name, res, resolved_sid, args, assistant) logger.info(f"[ToolService] System skill '{skill.name}' completed (Status: {sub_agent.status}).") return res @@ -478,3 +443,60 @@ return {"success": False, "error": "Skill execution logic not found"} + def _process_binary_artifacts(self, skill_name: str, res: Dict[str, Any], resolved_sid: str, args: Dict[str, Any], assistant: Any) -> Dict[str, Any]: + """Post-processing for Binary Artifacts (Screenshots, etc.)""" + # Unconditionally prevent binary data from leaking into the JSON serializer + screenshot_bits = res.pop("_screenshot_bytes", None) + + if skill_name == "browser_automation_agent" and assistant: + # Organise browser data by session for better UX + if resolved_sid and resolved_sid != "__fs_explorer__": + try: + abs_workspace = assistant.mirror.get_workspace_path(resolved_sid) + # M6: Use .browser_data (ignored from node sync) + base_dir = os.path.join(abs_workspace, ".browser_data") + os.makedirs(base_dir, exist_ok=True) + + timestamp = int(time.time()) + action = args.get("action", "unknown").lower() + + # Clean filename for the image: {timestamp}_{action}.png + ss_filename = f"{timestamp}_{action}.png" + + # Save Screenshot if available + if screenshot_bits: + ss_path = os.path.join(base_dir, ss_filename) + with open(ss_path, "wb") as f: + f.write(screenshot_bits) + res["screenshot_url"] = f"/.browser_data/{resolved_sid}/{ss_filename}" + res["_visual_feedback"] = f"Action screenshot captured: {res['screenshot_url']}" + + # Save Metadata/A11y into a hidden or specific sub-folder if needed, + # but keep images in the root of the session for quick gallery view. + action_dir = os.path.join(base_dir, ".metadata", f"{timestamp}_{action}") + os.makedirs(action_dir, exist_ok=True) + + # Save Metadata/Result for easy debugging in file explorer + import json + meta = { + "timestamp": timestamp, + "action": action, + "url": res.get("url"), + "title": res.get("title"), + "success": res.get("success"), + "error": res.get("error"), + "eval_result": res.get("eval_result") + } + with open(os.path.join(action_dir, "metadata.json"), "w") as f: + json.dump(meta, f, indent=2) + + # Optional: Save A11y summary for quick viewing + if "a11y_summary" in res: + with open(os.path.join(action_dir, "a11y_summary.txt"), "w") as f: + f.write(res["a11y_summary"]) + + logger.info(f"[ToolService] Browser artifacts saved to: {action_dir}") + except Exception as sse: + logger.warning(f"Failed to persist browser data to workspace: {sse}") + + return res diff --git a/ai-hub/app/core/skills/fs_loader.py b/ai-hub/app/core/skills/fs_loader.py index 84a6024..daed851 100644 --- a/ai-hub/app/core/skills/fs_loader.py +++ b/ai-hub/app/core/skills/fs_loader.py @@ -2,6 +2,7 @@ import yaml import json import logging +import collections from typing import List, Dict, Any from app.config import settings @@ -76,12 +77,24 @@ continue file_abs = os.path.join(root, file) file_rel = os.path.relpath(file_abs, skill_path).replace('\\', '/') - try: - with open(file_abs, "r", encoding='utf-8') as ff: - vfs_files.append({"file_path": file_rel, "content": ff.read()}) - except Exception: - pass # skip binary or unreadable - + + class LazyFileContent(collections.UserString): + def __init__(self, fpath): + self._fpath = fpath + self._content = None + + @property + def data(self): + if self._content is None: + try: + with open(self._fpath, "r", encoding='utf-8') as ff: + self._content = ff.read() + except Exception: + self._content = "" + return self._content + + vfs_files.append({"file_path": file_rel, "content": LazyFileContent(file_abs), "absolute_path": file_abs}) + # Extract or Generate Metadata metadata = self._ensure_metadata(skill_path, skill_id, is_system=False) diff --git a/ai-hub/app/core/tools/definitions/mesh_file_explorer.py b/ai-hub/app/core/tools/definitions/mesh_file_explorer.py index e4ab518..406ef24 100644 --- a/ai-hub/app/core/tools/definitions/mesh_file_explorer.py +++ b/ai-hub/app/core/tools/definitions/mesh_file_explorer.py @@ -19,9 +19,15 @@ services = context.get("services") if node_id in ["hub", "server", "local"]: - def _hub_fs(**kwargs): - return self._execute_hub_fs(services, kwargs.get("action"), kwargs.get("path"), kwargs.get("session_id"), kwargs.get("content")) - return _hub_fs, {"action": action, "path": path, "session_id": resolved_sid, "content": content} + if action == "write": + content_bytes = content.encode('utf-8') if content else b"" + return assistant.write, {"node_id": "hub", "path": path, "content": content_bytes, "session_id": resolved_sid} + elif action == "delete": + return assistant.rm, {"node_id": "hub", "path": path, "session_id": resolved_sid} + else: + def _hub_fs(**kwargs): + return self._execute_hub_fs(services, kwargs.get("action"), kwargs.get("path"), kwargs.get("session_id"), kwargs.get("content")) + return _hub_fs, {"action": action, "path": path, "session_id": resolved_sid, "content": content} if action == "list": return assistant.ls, {"node_id": node_id, "path": path, "session_id": resolved_sid} @@ -50,6 +56,8 @@ if not os.path.exists(target): return {"error": "Path not found"} files = [] for entry in os.scandir(target): + if entry.name in [".cortex_sync"] and path in [".", "", "/"]: + continue files.append({ "path": os.path.relpath(entry.path, base), "name": entry.name, diff --git a/ai-hub/app/core/tools/definitions/read_skill_artifact.py b/ai-hub/app/core/tools/definitions/read_skill_artifact.py index f6b7e11..5895d5d 100644 --- a/ai-hub/app/core/tools/definitions/read_skill_artifact.py +++ b/ai-hub/app/core/tools/definitions/read_skill_artifact.py @@ -35,6 +35,27 @@ return None, {"success": False, "error": "Database context not available"} def _read_artifact(skill_name: str, file_path: str) -> dict: + # 1. Try resolving skill from FS first (VFS format) + try: + from app.core.skills.fs_loader import fs_loader + all_fs_skills = fs_loader.get_all_skills() + for fs_skill in all_fs_skills: + if fs_skill.get("name") == skill_name: + for f in fs_skill.get("files", []): + if f.get("file_path") == file_path: + content_str = str(f.get("content", "")) + logger.info(f"[ReadSkillArtifact] Lazy-loaded '{file_path}' from FS skill '{skill_name}' ({len(content_str)} chars)") + return { + "success": True, + "skill_name": skill_name, + "file_path": file_path, + "content": content_str + } + return {"success": False, "error": f"File '{file_path}' not found in virtual skill '{skill_name}'.", "hint": "Check the available skill files."} + except Exception as e: + logger.warning(f"Error checking FS skills for artifact: {e}") + + # 2. Fallback to DB definition from app.db import models as m skill = db.query(m.Skill).filter(m.Skill.name == skill_name).first() diff --git a/docs/refactors/skill_implementation_plan.md b/docs/refactors/skill_implementation_plan.md deleted file mode 100644 index 5fa4ed3..0000000 --- a/docs/refactors/skill_implementation_plan.md +++ /dev/null @@ -1,94 +0,0 @@ -# Skill Framework Migration: Step-by-Step AI Implementation Plan - -This document serves as a self-contained, step-by-step guide for an AI agent to execute the migration from the flat, database-backed skill system to the new "Skills as Folders" (Lazy-Loading) architecture. - -**Prerequisites to check before starting:** -- Backend uses FastAPI (`/app/ai-hub/app/api/`). -- Frontend uses React (`/app/frontend/src/features/skills/`). -- Database uses SQLAlchemy (`/app/db/models.py`). - ---- - -## Phase 1: Database & Model Virtualization (Backend) -**Objective:** Evolve the `Skill` model to support a Virtual File System (VFS) instead of monolithic text fields. - -- [x] **Step 1.1: Create the `SkillFile` SQLAlchemy Model** - - **Location:** `/app/ai-hub/app/db/models.py` - - **Action:** Define a new `SkillFile` model with: - - `id` (Integer, Primary Key) - - `skill_id` (Integer, Foreign Key to `skills.id`) - - `file_path` (String, e.g., `SKILL.md`, `scripts/run.py`, `artifacts/data.json`) - - `content` (Text or LargeBinary, to hold file data) - - `created_at` / `updated_at` (DateTime) - - **Action:** Add a `files = relationship("SkillFile", back_populates="skill", cascade="all, delete-orphan")` to the main `Skill` model. - -- [x] **Step 1.2: Deprecate Legacy Fields in the `Skill` Model** - - **Location:** `/app/ai-hub/app/db/models.py` - - **Action:** Mark `system_prompt`, `preview_markdown`, and `config` as nullable/deprecated. The core logic should now expect a `SKILL.md` file in the `SkillFile` table to replace `system_prompt` and `preview_markdown`. - -- [x] **Step 1.3: Generate & Run Alembic Migration** - - **Action:** Run the command to generate an alembic migration script for the new `SkillFile` table and apply it to the database. - ---- - -## Phase 2: REST API Overhaul (Backend) -**Objective:** Expose endpoints for standard IDE operations (CRUD for files/folders). - -- [x] **Step 2.1: Implement File Tree API** - - **Location:** `/app/ai-hub/app/api/routes/skills.py` - - **Action:** Create `GET /skills/{skill_id}/files`. - - **Logic:** Return a hierarchical JSON representation of the files associated with the skill (e.g., simulating folders by parsing the `file_path` strings). - -- [x] **Step 2.2: Implement File Content CRUD Operations** - - **Location:** `/app/ai-hub/app/api/routes/skills.py` - - **Action:** Create `GET /skills/{skill_id}/files/{path:path}` to read a specific file's text/binary content. - - **Action:** Create `POST /skills/{skill_id}/files/{path:path}` to create or update file contents. - - **Action:** Create `DELETE /skills/{skill_id}/files/{path:path}` to remove a file. - -- [x] **Step 2.3: Modify Skill Creation Logic** - - **Location:** `/app/ai-hub/app/api/routes/skills.py` (POST `/skills/`) - - **Action:** Upon creating a new skill, automatically seed the `SkillFile` database with a default `SKILL.md` file containing basic boilerplate instructions. - ---- - -## Phase 3: "Lazy Loading" Agent Logic Integration (Backend/Worker) -**Objective:** Teach the AI agent how to interact with this new folder structure instead of relying on the giant `system_prompt`. - -- [x] **Step 3.1: Create the `read_skill_artifact` Tool** - - **Location:** Core agent tooling directory (e.g., `/app/ai-hub/app/core/agent/tools.py` or similar). - - **Action:** Program a standard tool allowing the LLM to provide a `skill_name` and `file_path` to read content dynamically during a session. - -- [x] **Step 3.2: Update Agent Initialization Context** - - **Location:** Node/Agent execution wrapper (`sessions.py` or agent orchestrator). - - **Action:** When loading a skill for an agent, inject *only* the contents of `SKILL.md` into the initial system prompt. Provide the agent with the `read_skill_artifact` tool to discover and read anything in the `/scripts/` or `/artifacts/` folders when necessary. - ---- - -## Phase 4: The "Skill Studio" IDE Upgrade (Frontend) -**Objective:** Replace the basic textarea forms with a true workspace IDE UI. - -- [ ] **Step 4.1: Abstract File APIs in Frontend Service** - - **Location:** `/app/frontend/src/services/apiService.js` - - **Action:** Add helper functions: `getSkillFiles(id)`, `getSkillFileContent(id, path)`, `saveSkillFile(id, path, content)`, `deleteSkillFile(id, path)`. - -- [ ] **Step 4.2: Build the File Tree Sidebar Component** - - **Location:** `/app/frontend/src/features/skills/components/SkillFileTree.js` (New Component) - - **Action:** Implement a clickable folder/file tree UI. Selecting a file triggers a state update for the active open code editor. - -- [ ] **Step 4.3: Implement the Code Editor Component** - - **Location:** `/app/frontend/src/features/skills/components/SkillEditor.js` (New Component) - - **Action:** Drop in a code editor (like Monaco Editor, or a high-quality textarea with syntax highlighting) to edit the active file fetched in Step 4.2. - - **Action:** Add "Save" shortcuts (Ctrl+S) hooked to the `saveSkillFile` API. - -- [ ] **Step 4.4: Refactor `SkillsPageContent.js`** - - **Location:** `/app/frontend/src/features/skills/components/SkillsPageContent.js` - - **Action:** Remove the legacy "Engineering Mode" and monolithic textareas (`formData.system_prompt`, `formData.preview_markdown`, `formData.config`). - - **Action:** Replace the modal body with the newly created Workspace Layout: Side-by-side split view of `SkillFileTree` (Left) and `SkillEditor` (Right). - ---- - -## Final Validation Checklist -1. Create a new skill; verify `SKILL.md` is automatically created. -2. Add a new file `scripts/test.py` via the Frontend UI. -3. Observe the AI Agent reading `SKILL.md` by default, but executing a tool to read `scripts/test.py` only when requested. -4. Verify token counts drop significantly compared to the legacy monolithic system prompt. diff --git a/frontend/src/features/skills/components/SkillsPageContent.js b/frontend/src/features/skills/components/SkillsPageContent.js index f0955e7..edbd587 100644 --- a/frontend/src/features/skills/components/SkillsPageContent.js +++ b/frontend/src/features/skills/components/SkillsPageContent.js @@ -22,7 +22,9 @@ editingSkill, formData, setFormData, + confirmDeleteId, setConfirmDeleteId, + confirmDelete, skillFiles, activeFile, activeFileContent, @@ -183,9 +185,7 @@ { - setActiveFileContent(c); - }} + onChange={() => {}} onSave={handleSaveActiveFile} isSaving={isSavingFile} /> @@ -257,6 +257,32 @@ )} + + {/* --- Delete Confirmation Modal --- */} + {confirmDeleteId && ( +
+
+
+

+ + Confirm Deletion +

+
+
+

+ Are you sure you want to permanently delete this folder? +

+

This action destroys all configuration and scripts inside. It cannot be undone.

+
+
+ + +
+
+
+ )} ); } diff --git a/skills/mesh-file-explorer/SKILL.md b/skills/mesh-file-explorer/SKILL.md index eb8c91c..d65ea78 100644 --- a/skills/mesh-file-explorer/SKILL.md +++ b/skills/mesh-file-explorer/SKILL.md @@ -30,7 +30,7 @@ description: Relative path to the file/directory. node_id: type: string - description: The target node ID. Use 'hub' or 'server' for local Hub filesystem actions. + description: "The target node ID. Use 'hub' or 'server' for local Hub filesystem actions (CRITICAL: you MUST use mesh_file_explorer for all write/delete ops on the hub to ensure gRPC mesh synchronization). " content: type: string description: Optional content for write action. @@ -49,10 +49,15 @@ You are a decentralized file management specialist. Use this tool based on the context: ### 1. 🔄 Standard Workspace Sync (Ghost Mirror) + +This is the primary method for managing files that need to be synchronized across your entire agent mesh. + +- **CRITICAL**: When performing `write` or `delete` actions for synchronized files on the `hub` node (i.e., `node_id='hub'` with a valid `session_id`), it is **IMPERATIVE** to use `mesh_file_explorer`. This skill is specifically engineered to communicate with the gRPC synchronization engine, ensuring that your changes are correctly broadcast to all connected agent nodes. +- **AVOID**: **DO NOT** use `mesh_terminal_control` to execute native shell commands (`rm`, `echo`, `cp`, `mv`, `mkdir`) for files within the synchronized workspace on the `hub` node. Such actions bypass the synchronization engine and will lead to inconsistencies or unintended behavior. - **WHEN**: You are working on project files intended to sync across all nodes. - **PATH**: Use a **RELATIVE** path (e.g., `src/main.py`). NEVER use absolute paths starting with `/tmp/cortex-sync/`. - **SESSION**: You MUST provide the `session_id` (usually your current Ghost Mirror ID). -- **BENEFIT**: Zero-latency write to the Hub mirror + background push to nodes. +- **BENEFIT**: Zero-latency write to the Hub mirror + instantaneous broadcast to nodes, ensuring consistent state across the mesh. ### 2. 🖥️ Physical Node Maintenance - **WHEN**: You need to interact with system files OUTSIDE the project workspace (e.g., `/etc/hosts` or personal home dirs). @@ -60,8 +65,8 @@ - **SESSION**: Set `session_id` to `__fs_explorer__`. - **BEHAVIOR**: Direct gRPC call to the physical node. Slower, but bypasses the mirror. -### Actions +### Actions (Ensuring Mesh Consistency) - **`list`**: Explore the filesystem. - **`read`**: Retrieve content. -- **`write`**: Create/Update files. -- **`delete`**: Remove files. +- **`write`**: Create/Update files. (Correctly broadcasts changes to the mesh when targeting 'hub'.) +- **`delete`**: Remove files. (Correctly broadcasts deletions to the mesh when targeting 'hub'.) diff --git a/skills/mesh-terminal-control/SKILL.md b/skills/mesh-terminal-control/SKILL.md index ed0db6e..330a4a8 100644 --- a/skills/mesh-terminal-control/SKILL.md +++ b/skills/mesh-terminal-control/SKILL.md @@ -22,7 +22,7 @@ description: 'Command to run. Use !RAW: prefix for REPL inputs.' node_id: type: string - description: Target node ID. Use 'hub' or 'server' to perform operations directly on the server side (e.g. for cleaning up .browser_data). + description: "Target node ID. Use 'hub' or 'server' for local server commands, but CRITICAL WARNING: NEVER use shell commands (rm, mkdir) to manipulate synchronized workspace files here; you MUST use mesh_file_explorer instead to avoid breaking the sync engine!" node_ids: type: array items: @@ -46,6 +46,13 @@ This capability allows the orchestrator to interact with terminal sessions on remote nodes. It supports stateful REPLs, parallel execution across multiple nodes, and background task management. +# Important Note on Hub File Operations + +**CRITICAL WARNING for 'hub' node_id and File Operations:** +When `node_id` is set to 'hub' (or 'server'), `mesh_terminal_control` executes commands directly on the Hub's host operating system. For operations involving files within the synchronized Ghost Mirror workspace (`/tmp/cortex-sync/{session_id}/`), using native shell commands like `rm`, `mkdir`, `cp`, or `mv` will **BYPASS** the mesh synchronization engine. This can lead to file drift, inconsistencies, or unintended file restorations as the Hub's reconciliation logic may conflict with direct out-of-band modifications. + +For **ALL** file creation, modification, or deletion actions intended to be synchronized across the mesh, you **MUST** use the `mesh_file_explorer` skill, even when targeting the 'hub' node. `mesh_file_explorer` is specifically designed to interact with the gRPC synchronization engine to ensure proper broadcast and consistency. + # AI Instructions You are a high-level Mesh Orchestrator. When executing commands: