From 85d57dd05eac94e42f75c38bfe24fba146be80d1 Mon Sep 17 00:00:00 2001 From: RunChuan123 <3233096165@qq.com> Date: Sat, 2 May 2026 16:43:46 +0800 Subject: [PATCH 1/5] feat/lbs(api): repository store feature and version git --- .gitignore | 3 + skillos/skillos/api/main.py | 528 ++++++++++++--- skillos/skillos/api/repository_store.py | 443 +++++++++++++ .../layers/skill_repository/repository.py | 613 +++++++++++++----- 4 files changed, 1342 insertions(+), 245 deletions(-) create mode 100644 skillos/skillos/api/repository_store.py diff --git a/.gitignore b/.gitignore index 1913c3f..305a8b2 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ htmlcov/ *.tmp *.bak idea.txt + +skillos/skillos/storage/skill_repo/ +**/storage/skill_repo/ diff --git a/skillos/skillos/api/main.py b/skillos/skillos/api/main.py index d191e9c..8db6d16 100644 --- a/skillos/skillos/api/main.py +++ b/skillos/skillos/api/main.py @@ -4,7 +4,8 @@ import argparse from contextlib import asynccontextmanager -from typing import Any, AsyncGenerator, Dict +from pathlib import Path +from typing import Any, AsyncGenerator, Dict, Optional import uvicorn from fastapi import FastAPI, Request @@ -13,49 +14,108 @@ from ..utils.llm_client import LLMClient from .deps import app_state -from .memory_store import MemoryGraphManager, MemoryWikiManager +from .memory_store import MemoryGraphManager from .routes import evolution, execution, graph, ingest, lifecycle, skills, ws +def _default_skill_repo_dir() -> Path: + """ + main.py 路径通常是: + + outer-skillos/ + skillos/ + api/ + main.py + layers/ + storage/ + skill_repo/ + SkillStorage/ + + 所以 Path(__file__).resolve().parents[2] 指向 outer-skillos。 + """ + repo_root = Path(__file__).resolve().parents[2] + return repo_root / "layers" / "storage" / "skill_repo" / "SkillStorage" + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: llm_cfg = app.state.llm_cfg llm = LLMClient(llm_cfg) - wiki = MemoryWikiManager() + + skill_repo_dir: Path = app.state.skill_repo_dir + + # 这里接入你负责的 repository-backed wiki manager。 + # + # 你需要在 skillos/api/repository_store.py 里实现 RepositoryWikiManager, + # 并保证它至少兼容 MemoryWikiManager 被 routes 使用到的方法。 + from .repository_store import RepositoryWikiManager + + wiki = RepositoryWikiManager(base_dir=skill_repo_dir) + + # Graph 先保留内存版,降低改动面。 + # 如果后续你也有 GraphRepository,再把这里替换掉。 graph_mgr = MemoryGraphManager() app_state.initialize(llm=llm, wiki=wiki, graph=graph_mgr) - # 预加载 demo 数据 - await _seed_demo_skills(wiki) + # 持久化 repository 默认不 seed demo,避免每次启动重复写入。 + if app.state.seed_demo: + await _seed_demo_skills(wiki) # 注入 WebSocket 广播到 executor from .routes.ws import broadcast + if app_state.executor: + async def ws_callback(event_type: str, data: Dict[str, Any]) -> None: await broadcast(event_type, data) + app_state.executor.add_event_callback(ws_callback) yield -async def _seed_demo_skills(wiki: MemoryWikiManager) -> None: +async def _seed_demo_skills(wiki: Any) -> None: """预加载 demo Skill + 12 个 Meta-Skill(Strategic)。""" from ..models.skill_model import ( - Skill, SkillInterface, SkillImplementation, - SkillState, SkillType, SkillProvenance, + Skill, + SkillImplementation, + SkillInterface, + SkillProvenance, + SkillState, + SkillType, ) - def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> SkillInterface: + def iface( + inputs: list, + outputs: list, + pre: Optional[list] = None, + post: Optional[list] = None, + ) -> SkillInterface: + pre = pre or [] + post = post or [] + return SkillInterface( input_schema={ "type": "object", - "properties": {p["name"]: {"type": p["type"], "description": p.get("description", "")} for p in inputs}, + "properties": { + p["name"]: { + "type": p["type"], + "description": p.get("description", ""), + } + for p in inputs + }, "required": [p["name"] for p in inputs if p.get("required")], }, output_schema={ "type": "object", - "properties": {p["name"]: {"type": p["type"], "description": p.get("description", "")} for p in outputs}, + "properties": { + p["name"]: { + "type": p["type"], + "description": p.get("description", ""), + } + for p in outputs + }, }, preconditions=pre, postconditions=post, @@ -69,9 +129,23 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill skill_type=SkillType.ATOMIC, tags=["web", "ui", "interaction"], interface=iface( - [{"name": "selector", "type": "string", "description": "CSS 选择器", "required": True}], - [{"name": "success", "type": "boolean", "description": "是否成功"}], - pre=["页面已加载"], post=["元素已被点击"], + [ + { + "name": "selector", + "type": "string", + "description": "CSS 选择器", + "required": True, + } + ], + [ + { + "name": "success", + "type": "boolean", + "description": "是否成功", + } + ], + pre=["页面已加载"], + post=["元素已被点击"], ), implementation=SkillImplementation( language="python", @@ -84,10 +158,29 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill skill_type=SkillType.ATOMIC, tags=["web", "ui", "input"], interface=iface( - [{"name": "selector", "type": "string", "required": True}, {"name": "text", "type": "string", "required": True}], - [{"name": "success", "type": "boolean"}], + [ + { + "name": "selector", + "type": "string", + "required": True, + }, + { + "name": "text", + "type": "string", + "required": True, + }, + ], + [ + { + "name": "success", + "type": "boolean", + } + ], + ), + implementation=SkillImplementation( + language="python", + code='output["success"] = True', ), - implementation=SkillImplementation(language="python", code='output["success"] = True'), ), dict( name="fill_form", @@ -95,10 +188,25 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill skill_type=SkillType.FUNCTIONAL, tags=["web", "form", "functional"], interface=iface( - [{"name": "form_data", "type": "object", "description": "表单字段字典", "required": True}], - [{"name": "submitted", "type": "boolean"}], + [ + { + "name": "form_data", + "type": "object", + "description": "表单字段字典", + "required": True, + } + ], + [ + { + "name": "submitted", + "type": "boolean", + } + ], + ), + implementation=SkillImplementation( + language="python", + sub_skill_ids=["click_element", "type_text"], ), - implementation=SkillImplementation(language="python", sub_skill_ids=["click_element", "type_text"]), ), dict( name="locate_element", @@ -106,12 +214,26 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill skill_type=SkillType.ATOMIC, tags=["web", "ui", "query"], interface=iface( - [{"name": "description", "type": "string", "required": True}], - [{"name": "selector", "type": "string"}], + [ + { + "name": "description", + "type": "string", + "required": True, + } + ], + [ + { + "name": "selector", + "type": "string", + } + ], ), implementation=SkillImplementation( language="python", - prompt_template="在页面上找到描述为 '{description}' 的元素,返回其 CSS 选择器。只输出选择器字符串,不要其他内容。", + prompt_template=( + "在页面上找到描述为 '{description}' 的元素," + "返回其 CSS 选择器。只输出选择器字符串,不要其他内容。" + ), ), ), ] @@ -125,16 +247,31 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="generation", tags=["meta", "generation", "strategic"], interface=iface( - [{"name": "task_description", "type": "string", "description": "任务描述", "required": True}, - {"name": "context", "type": "object", "description": "上下文信息"}], - [{"name": "skill_name", "type": "string"}, {"name": "skill_draft", "type": "object"}, - {"name": "confidence", "type": "number"}], + [ + { + "name": "task_description", + "type": "string", + "description": "任务描述", + "required": True, + }, + { + "name": "context", + "type": "object", + "description": "上下文信息", + }, + ], + [ + {"name": "skill_name", "type": "string"}, + {"name": "skill_draft", "type": "object"}, + {"name": "confidence", "type": "number"}, + ], ), implementation=SkillImplementation( prompt_template=( "你是 SkillOS Skill Builder。从以下任务描述中提取可复用的 Skill:\n\n" "任务:{task_description}\n\n" - "请生成一个 JSON 格式的 Skill 定义,包含 name、description、input_schema、output_schema、prompt_template。" + "请生成一个 JSON 格式的 Skill 定义,包含 " + "name、description、input_schema、output_schema、prompt_template。" ), ), ), @@ -145,12 +282,23 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="generation", tags=["meta", "generation", "trajectory", "strategic"], interface=iface( - [{"name": "trajectory", "type": "string", "description": "执行轨迹文本", "required": True}], - [{"name": "skill_name", "type": "string"}, {"name": "skill_draft", "type": "object"}], + [ + { + "name": "trajectory", + "type": "string", + "description": "执行轨迹文本", + "required": True, + } + ], + [ + {"name": "skill_name", "type": "string"}, + {"name": "skill_draft", "type": "object"}, + ], ), implementation=SkillImplementation( prompt_template=( - "你是 SkillOS Skill Builder。分析以下执行轨迹,提取可复用的操作模式并生成 Skill:\n\n" + "你是 SkillOS Skill Builder。分析以下执行轨迹," + "提取可复用的操作模式并生成 Skill:\n\n" "轨迹:{trajectory}\n\n" "输出 JSON 格式的 Skill 定义。" ), @@ -163,8 +311,17 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="knowledge_management", tags=["meta", "schema", "formalization", "strategic"], interface=iface( - [{"name": "informal_description", "type": "string", "required": True}], - [{"name": "input_schema", "type": "object"}, {"name": "output_schema", "type": "object"}], + [ + { + "name": "informal_description", + "type": "string", + "required": True, + } + ], + [ + {"name": "input_schema", "type": "object"}, + {"name": "output_schema", "type": "object"}, + ], ), implementation=SkillImplementation( prompt_template=( @@ -181,10 +338,26 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="quality_assurance", tags=["meta", "testing", "quality", "strategic"], interface=iface( - [{"name": "skill_name", "type": "string", "required": True}, - {"name": "skill_description", "type": "string", "required": True}, - {"name": "input_schema", "type": "object"}], - [{"name": "test_cases", "type": "array"}, {"name": "test_count", "type": "integer"}], + [ + { + "name": "skill_name", + "type": "string", + "required": True, + }, + { + "name": "skill_description", + "type": "string", + "required": True, + }, + { + "name": "input_schema", + "type": "object", + }, + ], + [ + {"name": "test_cases", "type": "array"}, + {"name": "test_count", "type": "integer"}, + ], ), implementation=SkillImplementation( prompt_template=( @@ -192,7 +365,8 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill "Skill 名称:{skill_name}\n" "描述:{skill_description}\n" "输入 Schema:{input_schema}\n\n" - "生成 3-5 个测试用例,包含正常情况、边界情况和异常情况。输出 JSON 数组。" + "生成 3-5 个测试用例,包含正常情况、边界情况和异常情况。" + "输出 JSON 数组。" ), ), ), @@ -203,10 +377,22 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="quality_assurance", tags=["meta", "safety", "audit", "strategic"], interface=iface( - [{"name": "skill_name", "type": "string", "required": True}, - {"name": "implementation_code", "type": "string"}], - [{"name": "is_safe", "type": "boolean"}, {"name": "risks", "type": "array"}, - {"name": "audit_score", "type": "number"}], + [ + { + "name": "skill_name", + "type": "string", + "required": True, + }, + { + "name": "implementation_code", + "type": "string", + }, + ], + [ + {"name": "is_safe", "type": "boolean"}, + {"name": "risks", "type": "array"}, + {"name": "audit_score", "type": "number"}, + ], ), implementation=SkillImplementation( prompt_template=( @@ -225,10 +411,27 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="quality_assurance", tags=["meta", "verification", "postcondition", "strategic"], interface=iface( - [{"name": "skill_name", "type": "string", "required": True}, - {"name": "postconditions", "type": "array", "required": True}, - {"name": "execution_output", "type": "object", "required": True}], - [{"name": "satisfied", "type": "boolean"}, {"name": "violations", "type": "array"}], + [ + { + "name": "skill_name", + "type": "string", + "required": True, + }, + { + "name": "postconditions", + "type": "array", + "required": True, + }, + { + "name": "execution_output", + "type": "object", + "required": True, + }, + ], + [ + {"name": "satisfied", "type": "boolean"}, + {"name": "violations", "type": "array"}, + ], ), implementation=SkillImplementation( prompt_template=( @@ -246,17 +449,34 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="maintenance", tags=["meta", "repair", "maintenance", "strategic"], interface=iface( - [{"name": "skill_name", "type": "string", "required": True}, - {"name": "failure_info", "type": "string", "required": True}, - {"name": "current_implementation", "type": "string"}], - [{"name": "repaired_implementation", "type": "string"}, {"name": "repair_notes", "type": "string"}], + [ + { + "name": "skill_name", + "type": "string", + "required": True, + }, + { + "name": "failure_info", + "type": "string", + "required": True, + }, + { + "name": "current_implementation", + "type": "string", + }, + ], + [ + {"name": "repaired_implementation", "type": "string"}, + {"name": "repair_notes", "type": "string"}, + ], ), implementation=SkillImplementation( prompt_template=( "修复失败的 Skill '{skill_name}':\n\n" "失败信息:{failure_info}\n" "当前实现:{current_implementation}\n\n" - "分析根因并提供修复后的实现。输出 JSON:{{repaired_implementation, repair_notes}}" + "分析根因并提供修复后的实现。" + "输出 JSON:{{repaired_implementation, repair_notes}}" ), ), ), @@ -267,10 +487,26 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="maintenance", tags=["meta", "split", "decomposition", "strategic"], interface=iface( - [{"name": "skill_name", "type": "string", "required": True}, - {"name": "skill_description", "type": "string", "required": True}, - {"name": "split_reason", "type": "string"}], - [{"name": "sub_skills", "type": "array"}, {"name": "split_count", "type": "integer"}], + [ + { + "name": "skill_name", + "type": "string", + "required": True, + }, + { + "name": "skill_description", + "type": "string", + "required": True, + }, + { + "name": "split_reason", + "type": "string", + }, + ], + [ + {"name": "sub_skills", "type": "array"}, + {"name": "split_count", "type": "integer"}, + ], ), implementation=SkillImplementation( prompt_template=( @@ -289,9 +525,22 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="maintenance", tags=["meta", "merge", "deduplication", "strategic"], interface=iface( - [{"name": "skill_names", "type": "array", "description": "待合并的 Skill 名称列表", "required": True}, - {"name": "skill_descriptions", "type": "array"}], - [{"name": "merged_skill", "type": "object"}, {"name": "merge_notes", "type": "string"}], + [ + { + "name": "skill_names", + "type": "array", + "description": "待合并的 Skill 名称列表", + "required": True, + }, + { + "name": "skill_descriptions", + "type": "array", + }, + ], + [ + {"name": "merged_skill", "type": "object"}, + {"name": "merge_notes", "type": "string"}, + ], ), implementation=SkillImplementation( prompt_template=( @@ -309,11 +558,31 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="lifecycle", tags=["meta", "deprecation", "maintenance", "strategic"], interface=iface( - [{"name": "skill_name", "type": "string", "required": True}, - {"name": "usage_count", "type": "integer", "required": True}, - {"name": "success_rate", "type": "number", "required": True}, - {"name": "last_used_days_ago", "type": "integer"}], - [{"name": "should_deprecate", "type": "boolean"}, {"name": "reason", "type": "string"}], + [ + { + "name": "skill_name", + "type": "string", + "required": True, + }, + { + "name": "usage_count", + "type": "integer", + "required": True, + }, + { + "name": "success_rate", + "type": "number", + "required": True, + }, + { + "name": "last_used_days_ago", + "type": "integer", + }, + ], + [ + {"name": "should_deprecate", "type": "boolean"}, + {"name": "reason", "type": "string"}, + ], ), implementation=SkillImplementation( prompt_template=( @@ -332,11 +601,30 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="knowledge_management", tags=["meta", "wiki", "documentation", "strategic"], interface=iface( - [{"name": "skill_id", "type": "string", "required": True}, - {"name": "update_reason", "type": "string", "required": True}, - {"name": "new_description", "type": "string"}, - {"name": "new_tags", "type": "array"}], - [{"name": "updated", "type": "boolean"}, {"name": "wiki_url", "type": "string"}], + [ + { + "name": "skill_id", + "type": "string", + "required": True, + }, + { + "name": "update_reason", + "type": "string", + "required": True, + }, + { + "name": "new_description", + "type": "string", + }, + { + "name": "new_tags", + "type": "array", + }, + ], + [ + {"name": "updated", "type": "boolean"}, + {"name": "wiki_url", "type": "string"}, + ], ), implementation=SkillImplementation( prompt_template=( @@ -354,15 +642,37 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill meta_category="graph", tags=["meta", "graph", "relations", "strategic"], interface=iface( - [{"name": "source_skill", "type": "string", "required": True}, - {"name": "target_skill", "type": "string", "required": True}, - {"name": "relation_type", "type": "string", "description": "depends_on/composes/replaces", "required": True}, - {"name": "weight", "type": "number"}], - [{"name": "edge_added", "type": "boolean"}, {"name": "graph_updated", "type": "boolean"}], + [ + { + "name": "source_skill", + "type": "string", + "required": True, + }, + { + "name": "target_skill", + "type": "string", + "required": True, + }, + { + "name": "relation_type", + "type": "string", + "description": "depends_on/composes/replaces", + "required": True, + }, + { + "name": "weight", + "type": "number", + }, + ], + [ + {"name": "edge_added", "type": "boolean"}, + {"name": "graph_updated", "type": "boolean"}, + ], ), implementation=SkillImplementation( prompt_template=( - "分析 Skill '{source_skill}' 和 '{target_skill}' 之间的 '{relation_type}' 关系:\n\n" + "分析 Skill '{source_skill}' 和 '{target_skill}' 之间的 " + "'{relation_type}' 关系:\n\n" "验证这个关系是否合理,并说明理由。" "输出 JSON:{{valid: bool, reasoning: string}}" ), @@ -371,6 +681,7 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill ] all_skills = demos + meta_skills + for d in all_skills: skill = Skill( **d, @@ -378,17 +689,25 @@ def iface(inputs: list, outputs: list, pre: list = [], post: list = []) -> Skill ) skill.transition_to(SkillState.VERIFIED) skill.transition_to(SkillState.RELEASED) + for _ in range(20): skill.record_execution(success=True, latency_ms=120.0) + for _ in range(2): skill.record_execution(success=False, latency_ms=500.0) + try: await wiki.create(skill) except ValueError: pass -def create_app(api_key: str, model: str = "claude-sonnet-4-6") -> FastAPI: +def create_app( + api_key: str, + model: str = "claude-sonnet-4-6", + seed_demo: bool = False, + skill_repo_dir: Optional[Path] = None, +) -> FastAPI: from ..config.llm_config import LLMConfig llm_cfg = LLMConfig(api_key=api_key, model=model) @@ -401,7 +720,10 @@ def create_app(api_key: str, model: str = "claude-sonnet-4-6") -> FastAPI: docs_url="/docs", redoc_url="/redoc", ) + app.state.llm_cfg = llm_cfg + app.state.seed_demo = seed_demo + app.state.skill_repo_dir = (skill_repo_dir or _default_skill_repo_dir()).resolve() app.add_middleware( CORSMiddleware, @@ -412,8 +734,17 @@ def create_app(api_key: str, model: str = "claude-sonnet-4-6") -> FastAPI: ) @app.exception_handler(Exception) - async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: - return JSONResponse(status_code=500, content={"ok": False, "error": str(exc)}) + async def global_exception_handler( + request: Request, + exc: Exception, + ) -> JSONResponse: + return JSONResponse( + status_code=500, + content={ + "ok": False, + "error": str(exc), + }, + ) app.include_router(skills.router, prefix="/api/v1") app.include_router(lifecycle.router, prefix="/api/v1") @@ -425,11 +756,17 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp @app.get("/") async def root() -> Dict[str, str]: - return {"name": "SkillOS", "version": "1.0.0", "status": "running"} + return { + "name": "SkillOS", + "version": "1.0.0", + "status": "running", + } @app.get("/health") async def health() -> Dict[str, str]: - return {"status": "ok"} + return { + "status": "ok", + } return app @@ -441,11 +778,38 @@ def main() -> None: parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--port", type=int, default=8000) parser.add_argument("--reload", action="store_true") + parser.add_argument( + "--seed-demo", + action="store_true", + help="启动时向 repository 写入 demo skills。默认关闭。", + ) + parser.add_argument( + "--skill-repo-dir", + default=None, + help=( + "Skill repository 路径。" + "默认使用 outer-skillos/layers/storage/skill_repo/SkillStorage。" + ), + ) + args = parser.parse_args() - app = create_app(api_key=args.api_key, model=args.model) - uvicorn.run(app, host=args.host, port=args.port, reload=args.reload) + skill_repo_dir = Path(args.skill_repo_dir).resolve() if args.skill_repo_dir else None + + app = create_app( + api_key=args.api_key, + model=args.model, + seed_demo=args.seed_demo, + skill_repo_dir=skill_repo_dir, + ) + + uvicorn.run( + app, + host=args.host, + port=args.port, + reload=args.reload, + ) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/skillos/skillos/api/repository_store.py b/skillos/skillos/api/repository_store.py new file mode 100644 index 0000000..b3a02b1 --- /dev/null +++ b/skillos/skillos/api/repository_store.py @@ -0,0 +1,443 @@ +"""Repository-backed Wiki Manager for SkillOS API. + +This module adapts a local Git skill repository to the API layer. + +Design: +- The FastAPI routes talk to app_state.wiki. +- app_state.wiki is RepositoryWikiManager. +- RepositoryWikiManager reads/writes Skill objects from/to a local git repository. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +from pathlib import Path +from typing import Any, Optional + +import yaml + +from ..models.skill_model import Skill + +logger = logging.getLogger(__name__) + + +class RepositoryWikiManager: + """ + Local-git-repository backed Wiki Manager. + + Public methods intentionally match the MemoryWikiManager-like interface used by routes: + - create + - get + - list + - update + - delete + - exists + + The repository may contain JSON/YAML skill files. + New or updated skills are written as JSON under: + + /skills/.json + """ + + SKILL_FILE_SUFFIXES = {".json", ".yaml", ".yml"} + + IGNORED_DIR_NAMES = { + ".git", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".venv", + "venv", + "node_modules", + ".idea", + ".vscode", + } + + def __init__( + self, + base_dir: Path, + write_subdir: str = "skills", + auto_git_add: bool = False, + auto_git_commit: bool = False, + ): + self.base_dir = Path(base_dir).resolve() + self.base_dir.mkdir(parents=True, exist_ok=True) + + self.write_dir = self.base_dir / write_subdir + self.write_dir.mkdir(parents=True, exist_ok=True) + + self.auto_git_add = auto_git_add + self.auto_git_commit = auto_git_commit + + logger.info("RepositoryWikiManager initialized: %s", self.base_dir) + logger.info("Skill write directory: %s", self.write_dir) + + # ------------------------------------------------------------------------- + # Public API used by FastAPI routes + # ------------------------------------------------------------------------- + + async def create(self, skill: Skill) -> Skill: + skill_id = self._get_skill_id(skill) + + existing = await self.get(skill_id) + if existing is not None: + raise ValueError(f"Skill 已存在: {skill_id}") + + path = self._default_write_path(skill_id) + self._write_skill_file(path, skill) + + self._maybe_git_track(path, message=f"create skill: {skill_id}") + + logger.info("Skill 已创建到本地 git repository: %s -> %s", skill_id, path) + return skill + + async def get(self, skill_id: str) -> Optional[Skill]: + path = self._find_skill_path(skill_id) + + if path is None: + return None + + return self._read_skill_file(path) + + async def list( + self, + state: Any = None, + skill_type: Any = None, + tags: Optional[list[str]] = None, + query: Optional[str] = None, + limit: int = 100, + offset: int = 0, + **kwargs: Any, + ) -> list[Skill]: + skills: list[Skill] = [] + + for path in self._iter_skill_files(): + try: + skill = self._read_skill_file(path) + except Exception as exc: + logger.warning("跳过无法解析的 skill 文件: %s, error=%s", path, exc) + continue + + if not self._matches_filters( + skill=skill, + state=state, + skill_type=skill_type, + tags=tags, + query=query, + ): + continue + + skills.append(skill) + + skills.sort(key=lambda s: str(getattr(s, "name", "") or getattr(s, "id", ""))) + + if offset < 0: + offset = 0 + + if limit is None or limit <= 0: + return skills[offset:] + + return skills[offset : offset + limit] + + async def update(self, skill_id: str, skill: Skill) -> Skill: + existing_path = self._find_skill_path(skill_id) + + if existing_path is None: + path = self._default_write_path(skill_id) + else: + path = existing_path + + self._write_skill_file(path, skill) + + self._maybe_git_track(path, message=f"update skill: {skill_id}") + + logger.info("Skill 已更新到本地 git repository: %s -> %s", skill_id, path) + return skill + + async def delete(self, skill_id: str) -> bool: + path = self._find_skill_path(skill_id) + + if path is None: + return False + + path.unlink() + + self._maybe_git_track(path, message=f"delete skill: {skill_id}", deleted=True) + + logger.info("Skill 已从本地 git repository 删除: %s -> %s", skill_id, path) + return True + + async def exists(self, skill_id: str) -> bool: + return self._find_skill_path(skill_id) is not None + + # ------------------------------------------------------------------------- + # File discovery + # ------------------------------------------------------------------------- + + def _iter_skill_files(self) -> list[Path]: + files: list[Path] = [] + + for path in self.base_dir.rglob("*"): + if not path.is_file(): + continue + + if path.suffix.lower() not in self.SKILL_FILE_SUFFIXES: + continue + + if self._is_ignored_path(path): + continue + + files.append(path) + + return sorted(files) + + def _is_ignored_path(self, path: Path) -> bool: + parts = set(path.relative_to(self.base_dir).parts) + return bool(parts & self.IGNORED_DIR_NAMES) + + def _find_skill_path(self, skill_id: str) -> Optional[Path]: + target = str(skill_id) + + # Fast path: default write location. + default_path = self._default_write_path(target) + if default_path.exists(): + return default_path + + # Full scan: supports existing git repo layouts. + for path in self._iter_skill_files(): + try: + skill = self._read_skill_file(path) + except Exception: + continue + + candidate_ids = self._candidate_skill_ids(skill) + + if target in candidate_ids: + return path + + return None + + def _default_write_path(self, skill_id: str) -> Path: + safe_id = self._safe_filename(skill_id) + return self.write_dir / f"{safe_id}.json" + + # ------------------------------------------------------------------------- + # Serialization + # ------------------------------------------------------------------------- + + def _read_skill_file(self, path: Path) -> Skill: + raw = path.read_text(encoding="utf-8") + + if path.suffix.lower() == ".json": + data = json.loads(raw) + elif path.suffix.lower() in {".yaml", ".yml"}: + data = yaml.safe_load(raw) + else: + raise ValueError(f"不支持的 skill 文件格式: {path}") + + if not isinstance(data, dict): + raise ValueError(f"Skill 文件内容必须是 object/dict: {path}") + + data = self._normalize_skill_payload(data) + + return self._skill_from_dict(data) + + def _write_skill_file(self, path: Path, skill: Skill) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + data = self._skill_to_dict(skill) + + if path.suffix.lower() == ".json": + text = json.dumps(data, ensure_ascii=False, indent=2) + elif path.suffix.lower() in {".yaml", ".yml"}: + text = yaml.safe_dump(data, allow_unicode=True, sort_keys=False) + else: + raise ValueError(f"不支持的写入格式: {path}") + + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text(text + "\n", encoding="utf-8") + tmp_path.replace(path) + + def _normalize_skill_payload(self, data: dict[str, Any]) -> dict[str, Any]: + """ + Normalize common repository layouts. + + Supported examples: + + 1. Direct Skill object: + { + "name": "...", + "description": "...", + ... + } + + 2. Wrapped object: + { + "skill": { + "name": "...", + ... + } + } + + 3. Metadata wrapper: + { + "metadata": {...}, + "spec": { + "name": "...", + ... + } + } + + If your local git repository uses a different schema, add conversion here. + """ + + if "skill" in data and isinstance(data["skill"], dict): + return data["skill"] + + if "spec" in data and isinstance(data["spec"], dict): + spec = dict(data["spec"]) + + metadata = data.get("metadata") + if isinstance(metadata, dict): + for key in ("name", "description", "tags"): + if key not in spec and key in metadata: + spec[key] = metadata[key] + + return spec + + return data + + def _skill_to_dict(self, skill: Skill) -> dict[str, Any]: + if hasattr(skill, "model_dump"): + return skill.model_dump(mode="json") + + return skill.dict() + + def _skill_from_dict(self, data: dict[str, Any]) -> Skill: + if hasattr(Skill, "model_validate"): + return Skill.model_validate(data) + + return Skill.parse_obj(data) + + # ------------------------------------------------------------------------- + # Skill identity and filters + # ------------------------------------------------------------------------- + + def _get_skill_id(self, skill: Skill) -> str: + for attr in ("id", "skill_id", "name"): + value = getattr(skill, attr, None) + if value: + return str(value) + + raise ValueError("Skill 缺少 id / skill_id / name,无法生成 repository 文件名") + + def _candidate_skill_ids(self, skill: Skill) -> set[str]: + ids: set[str] = set() + + for attr in ("id", "skill_id", "name"): + value = getattr(skill, attr, None) + if value: + ids.add(str(value)) + + return ids + + def _matches_filters( + self, + skill: Skill, + state: Any = None, + skill_type: Any = None, + tags: Optional[list[str]] = None, + query: Optional[str] = None, + ) -> bool: + if state is not None: + current_state = getattr(skill, "state", None) + if not self._loosely_equal(current_state, state): + return False + + if skill_type is not None: + current_type = getattr(skill, "skill_type", None) + if not self._loosely_equal(current_type, skill_type): + return False + + if tags: + skill_tags = set(getattr(skill, "tags", []) or []) + if not set(tags).issubset(skill_tags): + return False + + if query: + q = query.lower() + + name = str(getattr(skill, "name", "") or "").lower() + description = str(getattr(skill, "description", "") or "").lower() + tag_text = " ".join(getattr(skill, "tags", []) or []).lower() + + if q not in name and q not in description and q not in tag_text: + return False + + return True + + def _loosely_equal(self, current: Any, expected: Any) -> bool: + if current == expected: + return True + + if str(current) == str(expected): + return True + + current_value = getattr(current, "value", None) + expected_value = getattr(expected, "value", None) + + if current_value == expected: + return True + + if current == expected_value: + return True + + if current_value is not None and expected_value is not None: + return current_value == expected_value + + return False + + def _safe_filename(self, value: str) -> str: + safe = str(value).strip() + safe = safe.replace("/", "_").replace("\\", "_") + safe = safe.replace(":", "_") + safe = safe.replace(" ", "_") + return safe + + # ------------------------------------------------------------------------- + # Git integration + # ------------------------------------------------------------------------- + + def _is_git_repo(self) -> bool: + return (self.base_dir / ".git").exists() + + def _run_git(self, args: list[str]) -> None: + if not self._is_git_repo(): + return + + subprocess.run( + ["git", *args], + cwd=str(self.base_dir), + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def _maybe_git_track(self, path: Path, message: str, deleted: bool = False) -> None: + if not self._is_git_repo(): + return + + rel_path = path.relative_to(self.base_dir) + + if self.auto_git_add: + if deleted: + self._run_git(["rm", "--ignore-unmatch", str(rel_path)]) + else: + self._run_git(["add", str(rel_path)]) + + if self.auto_git_commit: + self._run_git(["commit", "-m", message]) \ No newline at end of file diff --git a/skillos/skillos/layers/skill_repository/repository.py b/skillos/skillos/layers/skill_repository/repository.py index a03e8ef..76dec47 100644 --- a/skillos/skillos/layers/skill_repository/repository.py +++ b/skillos/skillos/layers/skill_repository/repository.py @@ -1,166 +1,351 @@ -"""Skill 仓库层 — 高层 CRUD、缓存协调、版本管理。 +"""Skill 仓库层 — Git-backed 高层 CRUD、版本管理、生命周期管理。 -SkillWikiManager 是 Skill 数据的统一入口,协调 PostgreSQL(持久化) -和 Redis(缓存),屏蔽底层存储细节。 +这一版将原来的 PostgreSQL + Redis 后端替换为本地 Git 仓库后端。 + +底层存储位置: + skillos/storage/skill_repo/SkillStorage + +底层操作模块: + skillos/storage/skill_repo/common.py + +设计目标: +- 尽量保留原 SkillWikiManager 的 async 方法签名,减少 API 层改动。 +- 本地 Git 仓库作为服务器端事实源。 +- Skill 的新增、查询、删除、版本历史、diff、merge、状态迁移都委托给 common.py。 +- 构造函数仍兼容旧代码传入 pg_conn / redis_conn,但不会再使用它们。 + +注意: +- create(skill) 是普通新增,若 name + version 已存在会报错。 +- update(skill_id, **kwargs) 是覆盖当前版本文件,不创建新版本。 +- create_new_version(source_skill_id, bump, **overrides) 才会创建新版本。 +- delete(skill_id) 默认软删除。 """ from __future__ import annotations -import uuid from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional from ...models.skill_model import Skill, SkillState, SkillType -from ...storage.postgres_db import PostgresConnection, SkillRepository -from ...storage.redis_cache import RedisConnection, SkillCache, StatsCache +from ...storage.skill_repo import common as git_store from ...utils.logger import get_logger logger = get_logger(__name__) class SkillWikiManager: - """Skill Wiki 的统一管理器。 + """Git-backed Skill Wiki 统一管理器。 职责: - - Skill CRUD(含版本管理) - - 缓存读写协调(Cache-Aside 策略) - - 状态机转换 - - 批量操作 + - Skill CRUD + - 版本管理 + - 生命周期状态迁移 + - 批量查询 + - 运行指标更新 + - Git diff / history / merge 辅助操作 + + 构造函数保留 pg_conn / redis_conn 参数,仅用于兼容旧调用点。 """ def __init__( self, - pg_conn: PostgresConnection, - redis_conn: Optional[RedisConnection] = None, + pg_conn: Any = None, + redis_conn: Optional[Any] = None, + *, + init_storage: bool = True, ) -> None: - self._repo = SkillRepository(pg_conn) - self._cache = SkillCache(redis_conn) if redis_conn else None - self._stats_cache = StatsCache(redis_conn) if redis_conn else None + self._pg_conn = pg_conn + self._redis_conn = redis_conn + + if init_storage: + git_store.init_repo(initial_commit=False) # ------------------------------------------------------------------ # Read # ------------------------------------------------------------------ async def get(self, skill_id: str) -> Optional[Skill]: - """按 ID 获取 Skill(先查缓存,再查 DB)。""" - if self._cache: - cached = await self._cache.get(skill_id) - if cached: - return cached - - skill = await self._repo.get(skill_id) - if skill and self._cache: - await self._cache.set(skill) - return skill + """按 skill_id 获取 Skill。""" + return git_store.get_skill_by_id(skill_id) - async def get_by_name(self, name: str, version: Optional[str] = None) -> Optional[Skill]: - """按名称(和可选版本)获取 Skill。""" - if version: - return await self._repo.get_by_name_version(name, version) - # 无版本时返回最新 released 版本,否则返回最新 draft - skills = await self._repo.list( - filters={"name_like": name}, - limit=20, - ) - exact = [s for s in skills if s.name == name] - if not exact: - return None - # 优先 released,其次按 updated_at 降序 - released = [s for s in exact if s.state == SkillState.RELEASED] - if released: - return max(released, key=lambda s: s.updated_at) - return max(exact, key=lambda s: s.updated_at) + async def get_by_name( + self, + name: str, + version: Optional[str] = None, + ) -> Optional[Skill]: + """按名称和可选版本获取 Skill。 + + 如果不传 version,返回该 skill 的最新可见版本。 + """ + return git_store.get_skill(name, version) async def get_many(self, skill_ids: List[str]) -> Dict[str, Optional[Skill]]: - """批量获取(缓存 pipeline + DB 补全)。""" - result: Dict[str, Optional[Skill]] = {} - if self._cache: - cached = await self._cache.get_many(skill_ids) - miss_ids = [sid for sid, v in cached.items() if v is None] - result.update({sid: v for sid, v in cached.items() if v is not None}) - else: - miss_ids = skill_ids - - if miss_ids: - for sid in miss_ids: - skill = await self._repo.get(sid) - result[sid] = skill - if self._cache: - to_cache = [s for s in result.values() if s is not None] - await self._cache.set_many(to_cache) + """批量按 skill_id 获取 Skill。""" + return {skill_id: git_store.get_skill_by_id(skill_id) for skill_id in skill_ids} + + async def list( + self, + skill_type: Optional[SkillType] = None, + state: Optional[SkillState] = None, + domain: Optional[str] = None, + name_like: Optional[str] = None, + limit: int = 100, + offset: int = 0, + ) -> List[Skill]: + """列表查询,支持多维过滤。 + + 默认只返回每个 Skill 的最新版本。 + """ + rows = git_store.list_skills( + skill_type=skill_type, + state=state, + domain=domain, + name_like=name_like, + latest_only=True, + include_deleted=False, + limit=limit, + offset=offset, + ) + + result: List[Skill] = [] + for row in rows: + skill = git_store.get_skill(row["name"], row["version"]) + if skill: + result.append(skill) return result - async def list( + async def list_versions( + self, + name: str, + *, + include_deleted: bool = False, + ) -> List[str]: + """获取某个 Skill 的版本号列表。""" + return git_store.get_skill_versions(name, include_deleted=include_deleted) + + async def list_all_versions( self, skill_type: Optional[SkillType] = None, state: Optional[SkillState] = None, domain: Optional[str] = None, name_like: Optional[str] = None, + include_deleted: bool = False, limit: int = 100, offset: int = 0, ) -> List[Skill]: - """列表查询,支持多维过滤。""" - filters: Dict[str, Any] = {} - if skill_type: - filters["skill_type"] = skill_type.value - if state: - filters["state"] = state.value - if domain: - filters["domain"] = domain - if name_like: - filters["name_like"] = name_like - return await self._repo.list(filters=filters, limit=limit, offset=offset) + """列表查询,返回所有版本,而不是仅最新版本。""" + rows = git_store.list_skills( + skill_type=skill_type, + state=state, + domain=domain, + name_like=name_like, + latest_only=False, + include_deleted=include_deleted, + limit=limit, + offset=offset, + ) + + result: List[Skill] = [] + for row in rows: + skill = git_store.get_skill( + row["name"], + row["version"], + include_deleted=include_deleted, + ) + if skill: + result.append(skill) + + return result async def search_by_tags(self, tags: List[str], limit: int = 50) -> List[Skill]: - return await self._repo.search_by_tags(tags, limit=limit) + """按 tag 简单搜索。 + + 当前 Git 版没有专门索引倒排表,先从 list 中过滤。 + """ + normalized = {tag.strip().lower() for tag in tags if tag.strip()} + if not normalized: + return [] + + skills = await self.list(limit=100000) + matched: List[Skill] = [] + + for skill in skills: + skill_tags = {tag.strip().lower() for tag in skill.tags} + if normalized.intersection(skill_tags): + matched.append(skill) + if len(matched) >= limit: + break + + return matched + + async def search(self, query: str, limit: int = 20) -> List[Skill]: + """简单关键词搜索。 + + 匹配 name / display_name / description / tags。 + 后续如果 indexing.py 接入向量检索,可以替换这里。 + """ + q = query.strip().lower() + if not q: + return [] + + skills = await self.list(limit=100000) + scored: List[tuple[int, Skill]] = [] + + for skill in skills: + score = 0 + if q in skill.name.lower(): + score += 5 + if q in skill.display_name.lower(): + score += 4 + if q in skill.description.lower(): + score += 3 + if any(q in tag.lower() for tag in skill.tags): + score += 2 + if q in skill.domain.lower(): + score += 1 + + if score > 0: + scored.append((score, skill)) + + scored.sort(key=lambda item: item[0], reverse=True) + return [skill for _, skill in scored[:limit]] async def count( self, skill_type: Optional[SkillType] = None, state: Optional[SkillState] = None, ) -> int: - filters: Dict[str, Any] = {} - if skill_type: - filters["skill_type"] = skill_type.value - if state: - filters["state"] = state.value - return await self._repo.count(filters=filters) + """统计 Skill 数量。 + + 默认统计最新版本。 + """ + rows = git_store.list_skills( + skill_type=skill_type, + state=state, + latest_only=True, + include_deleted=False, + limit=100000, + ) + return len(rows) # ------------------------------------------------------------------ # Write # ------------------------------------------------------------------ async def create(self, skill: Skill) -> Skill: - """创建新 Skill,检查名称+版本唯一性。""" - existing = await self._repo.get_by_name_version(skill.name, skill.version) + """创建新 Skill 版本,检查 name + version 唯一性。""" + existing = git_store.get_skill(skill.name, skill.version, include_deleted=True) if existing: raise ValueError( - f"Skill '{skill.name}' v{skill.version} 已存在 (id={existing.skill_id})" + f"Skill '{skill.name}' v{skill.version} 已存在 " + f"(id={existing.skill_id})" ) - created = await self._repo.create(skill) - if self._cache: - await self._cache.set(created) - logger.info(f"Skill 已创建: {skill.name} v{skill.version}") + + created = git_store.add_skill( + skill, + author="repository", + commit=True, + overwrite=False, + event_action="create", + ) + + logger.info(f"Skill 已创建: {created.name} v{created.version}") return created async def update(self, skill_id: str, **kwargs: Any) -> Optional[Skill]: - """部分更新 Skill 字段,自动更新 updated_at。""" - # 不允许直接修改 skill_id、created_at + """部分更新 Skill 字段,覆盖当前版本文件。 + + 该操作不创建新版本。 + 如需创建新版本,请使用 create_new_version。 + """ + skill = git_store.get_skill_by_id(skill_id) + if not skill: + return None + kwargs.pop("skill_id", None) kwargs.pop("created_at", None) - kwargs["updated_at"] = datetime.utcnow() - updated = await self._repo.update(skill_id, kwargs) - if updated and self._cache: - await self._cache.set(updated) + for key, value in kwargs.items(): + if key == "state" and isinstance(value, str): + value = SkillState(value) + elif key == "skill_type" and isinstance(value, str): + value = SkillType(value) + + setattr(skill, key, value) + + skill.updated_at = datetime.utcnow() + + updated = git_store.update_skill_version( + skill, + author="repository", + commit=True, + ) + + logger.info(f"Skill 已更新: {updated.name} v{updated.version}") return updated async def delete(self, skill_id: str) -> bool: - """删除 Skill(同时清除缓存)。""" - ok = await self._repo.delete(skill_id) - if ok and self._cache: - await self._cache.delete(skill_id) + """按 skill_id 软删除 Skill 当前版本。""" + skill = git_store.get_skill_by_id(skill_id) + if not skill: + return False + + ok = git_store.delete_skill( + skill.name, + version=skill.version, + hard=False, + author="repository", + reason="deleted by SkillWikiManager.delete", + commit=True, + ) + + if ok: + logger.info(f"Skill 已删除: {skill.name} v{skill.version}") + + return ok + + async def hard_delete(self, skill_id: str) -> bool: + """按 skill_id 物理删除 Skill 当前版本文件。谨慎使用。""" + skill = git_store.get_skill_by_id(skill_id, include_deleted=True) + if not skill: + return False + + ok = git_store.delete_skill( + skill.name, + version=skill.version, + hard=True, + author="repository", + reason="hard deleted by SkillWikiManager.hard_delete", + commit=True, + ) + + if ok: + logger.info(f"Skill 已物理删除: {skill.name} v{skill.version}") + + return ok + + async def delete_by_name( + self, + name: str, + version: Optional[str] = None, + *, + hard: bool = False, + reason: Optional[str] = None, + ) -> bool: + """按 name 和可选 version 删除 Skill。""" + ok = git_store.delete_skill( + name, + version=version, + hard=hard, + author="repository", + reason=reason, + commit=True, + ) + + if ok: + logger.info(f"Skill 已删除: {name} {version or 'all'}") + return ok # ------------------------------------------------------------------ @@ -178,27 +363,70 @@ async def create_new_version( if not source: raise ValueError(f"源 Skill 不存在: {source_skill_id}") - new_skill = source.model_copy(deep=True) - new_skill.skill_id = str(uuid.uuid4()) - new_skill.bump_version(bump) - new_skill.state = SkillState.DRAFT - new_skill.created_at = datetime.utcnow() - new_skill.updated_at = datetime.utcnow() - new_skill.released_at = None - new_skill.deprecated_at = None - new_skill.metrics = new_skill.metrics.__class__() # 重置指标 + created = git_store.create_new_version( + source.name, + source_version=source.version, + bump=bump, # type: ignore[arg-type] + overrides=overrides, + author="repository", + commit=True, + ) - for k, v in overrides.items(): - setattr(new_skill, k, v) + logger.info( + f"Skill 新版本已创建: {created.name} " + f"{source.version} -> {created.version}" + ) - return await self.create(new_skill) + return created async def get_version_history(self, name: str) -> List[Skill]: - """获取同名 Skill 的所有版本,按版本号排序。""" - skills = await self._repo.list(filters={"name_like": name}, limit=100) - exact = [s for s in skills if s.name == name] - exact.sort(key=lambda s: [int(x) for x in s.version.split(".")]) - return exact + """获取同名 Skill 的所有可见版本,按版本号排序。""" + return git_store.get_version_history(name, include_deleted=False) + + async def get_version_history_with_deleted(self, name: str) -> List[Skill]: + """获取同名 Skill 的所有版本,包括软删除版本。""" + return git_store.get_version_history(name, include_deleted=True) + + async def diff_versions(self, name: str, v1: str, v2: str) -> str: + """获取同一个 Skill 两个版本的 diff。""" + return git_store.diff_versions(name, v1, v2) + + async def git_history( + self, + name: str, + version: Optional[str] = None, + max_count: int = 20, + ) -> str: + """获取某个 Skill 或某个版本文件的 Git 提交历史。""" + return git_store.git_file_history(name, version, max_count=max_count) + + async def merge_versions( + self, + name: str, + base_version: str, + other_version: str, + new_version: Optional[str] = None, + strategy: str = "prefer_other", + manual_overrides: Optional[Dict[str, Any]] = None, + ) -> Skill: + """合并同名 Skill 的两个版本,生成新版本。""" + merged = git_store.merge_skills( + name, + base_version, + other_version, + new_version=new_version, + strategy=strategy, # type: ignore[arg-type] + manual_overrides=manual_overrides, + author="repository", + commit=True, + ) + + logger.info( + f"Skill 版本已合并: {name} " + f"{base_version}+{other_version} -> {merged.version}" + ) + + return merged # ------------------------------------------------------------------ # Lifecycle Transitions @@ -210,38 +438,52 @@ async def transition_state( new_state: SkillState, reason: Optional[str] = None, ) -> Skill: - """执行状态转换,持久化并更新缓存。""" + """执行状态转换,持久化到 Git 仓库。""" skill = await self.get(skill_id) if not skill: raise ValueError(f"Skill 不存在: {skill_id}") - skill.transition_to(new_state) - update_data: Dict[str, Any] = { - "state": new_state.value, - "updated_at": skill.updated_at, - } - if new_state == SkillState.RELEASED: - update_data["released_at"] = skill.released_at - elif new_state == SkillState.DEPRECATED: - update_data["deprecated_at"] = skill.deprecated_at - if reason: - update_data["deprecation_reason"] = reason - - updated = await self._repo.update(skill_id, update_data) - if updated and self._cache: - await self._cache.set(updated) - logger.info(f"Skill 状态转换: {skill.name} → {new_state.value}") - return updated or skill + updated = git_store.transition_skill_state( + skill.name, + skill.version, + new_state, + author="repository", + reason=reason, + commit=True, + ) + + logger.info(f"Skill 状态转换: {updated.name} → {new_state.value}") + return updated async def release(self, skill_id: str) -> Skill: return await self.transition_state(skill_id, SkillState.RELEASED) - async def deprecate(self, skill_id: str, reason: str, replacement_id: Optional[str] = None) -> Skill: - skill = await self.transition_state(skill_id, SkillState.DEPRECATED, reason=reason) + async def deprecate( + self, + skill_id: str, + reason: str, + replacement_id: Optional[str] = None, + ) -> Skill: + skill = await self.transition_state( + skill_id, + SkillState.DEPRECATED, + reason=reason, + ) + if replacement_id: - await self._repo.update(skill_id, {"replacement_skill_id": replacement_id}) + skill.replacement_skill_id = replacement_id + skill.updated_at = datetime.utcnow() + git_store.update_skill_version( + skill, + author="repository", + commit=True, + ) + return skill + async def archive(self, skill_id: str) -> Skill: + return await self.transition_state(skill_id, SkillState.ARCHIVED) + # ------------------------------------------------------------------ # Metrics # ------------------------------------------------------------------ @@ -252,38 +494,40 @@ async def record_execution( success: bool, latency_ms: float, ) -> None: - """记录执行结果,更新 DB 指标和缓存计数。""" + """记录执行结果,更新当前版本 JSON 文件。""" skill = await self.get(skill_id) if not skill: return + skill.record_execution(success, latency_ms) - await self._repo.update(skill_id, { - "usage_count": skill.metrics.usage_count, - "success_count": skill.metrics.success_count, - "failure_count": skill.metrics.failure_count, - "avg_latency_ms": skill.metrics.avg_latency_ms, - "last_used_at": skill.metrics.last_used_at, - }) - if self._cache: - await self._cache.set(skill) - if self._stats_cache: - await self._stats_cache.increment_usage(skill_id) + + git_store.update_skill_version( + skill, + author="repository", + commit=True, + ) + + logger.info( + f"Skill 执行记录已更新: {skill.name} v{skill.version}, " + f"success={success}, latency_ms={latency_ms}" + ) async def get_overview_stats(self) -> Dict[str, Any]: - """获取总览统计(优先从缓存读取)。""" - if self._stats_cache: - cached = await self._stats_cache.get_graph_stats() - if cached: - return cached - - total = await self._repo.count() - released = await self._repo.count(filters={"state": SkillState.RELEASED.value}) - draft = await self._repo.count(filters={"state": SkillState.DRAFT.value}) - atomic = await self._repo.count(filters={"skill_type": SkillType.ATOMIC.value}) - functional = await self._repo.count(filters={"skill_type": SkillType.FUNCTIONAL.value}) - strategic = await self._repo.count(filters={"skill_type": SkillType.STRATEGIC.value}) - - stats = { + """获取 Skill 仓库总览统计。""" + rows = git_store.list_skills( + latest_only=True, + include_deleted=False, + limit=100000, + ) + + total = len(rows) + released = len([r for r in rows if r.get("state") == SkillState.RELEASED.value]) + draft = len([r for r in rows if r.get("state") == SkillState.DRAFT.value]) + atomic = len([r for r in rows if r.get("skill_type") == SkillType.ATOMIC.value]) + functional = len([r for r in rows if r.get("skill_type") == SkillType.FUNCTIONAL.value]) + strategic = len([r for r in rows if r.get("skill_type") == SkillType.STRATEGIC.value]) + + return { "total_skills": total, "released": released, "draft": draft, @@ -291,7 +535,50 @@ async def get_overview_stats(self) -> Dict[str, Any]: "functional": functional, "strategic": strategic, "computed_at": datetime.utcnow().isoformat(), + "backend": "git", + "repo_status": git_store.repo_status(), } - if self._stats_cache: - await self._stats_cache.set_graph_stats(stats) - return stats + + # ------------------------------------------------------------------ + # Repository maintenance + # ------------------------------------------------------------------ + + async def rebuild_index(self) -> Dict[str, Any]: + """重建 SkillStorage 全局索引。""" + return git_store.rebuild_index(commit=True) + + async def repo_status(self) -> Dict[str, Any]: + """获取本地 Git 仓库状态。""" + return git_store.repo_status() + + async def read_events(self, limit: int = 100) -> List[Dict[str, Any]]: + """读取生命周期事件日志。""" + return git_store.read_events(limit=limit) + + async def push_to_remote( + self, + remote_name: Optional[str] = None, + branch: Optional[str] = None, + ) -> str: + """推送本地 Git 仓库到远程备份。""" + return git_store.push_to_remote(remote_name, branch) + + async def pull_from_remote( + self, + remote_name: Optional[str] = None, + branch: Optional[str] = None, + rebase: bool = True, + ) -> str: + """从远程拉取。 + + 当前设计中本地服务器仓库是事实源,因此建议只在初始化或灾备时调用。 + """ + return git_store.pull_from_remote( + remote_name=remote_name, + branch=branch, + rebase=rebase, + ) + + async def sync_to_remote(self) -> str: + """提交本地未提交变更并推送到远程备份。""" + return git_store.sync_to_remote() From 9740d3e626c7cf64d47ca6eac8300d1047cc631b Mon Sep 17 00:00:00 2001 From: RunChuan123 <3233096165@qq.com> Date: Mon, 4 May 2026 10:21:59 +0800 Subject: [PATCH 2/5] add git control --- .gitignore | 4 +- .../skillos/storage/skill_repo/__init__.py | 0 skillos/skillos/storage/skill_repo/common.py | 1447 +++++++++++++++++ .../skillos/storage/skill_repo/usecase.ipynb | 113 ++ 4 files changed, 1562 insertions(+), 2 deletions(-) create mode 100644 skillos/skillos/storage/skill_repo/__init__.py create mode 100644 skillos/skillos/storage/skill_repo/common.py create mode 100644 skillos/skillos/storage/skill_repo/usecase.ipynb diff --git a/.gitignore b/.gitignore index 305a8b2..1a89c65 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,5 @@ htmlcov/ *.bak idea.txt -skillos/skillos/storage/skill_repo/ -**/storage/skill_repo/ +skillos/skillos/storage/skill_repo/SkillStorage +**/storage/skill_repo/SkillStorage diff --git a/skillos/skillos/storage/skill_repo/__init__.py b/skillos/skillos/storage/skill_repo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skillos/skillos/storage/skill_repo/common.py b/skillos/skillos/storage/skill_repo/common.py new file mode 100644 index 0000000..a1c2719 --- /dev/null +++ b/skillos/skillos/storage/skill_repo/common.py @@ -0,0 +1,1447 @@ +"""Git-backed Skill storage common utilities. + +Path suggestion: + skillos/skillos/storage/skill_repo/common.py + +Run from project package root, for example: + cd /Users/liubingshuo/Desktop/code/py/skill/skillos/skillos + python -m skillos.storage.skill_repo.common init --reset + python -m skillos.storage.skill_repo.common seed + python -m skillos.storage.skill_repo.common list + +Repository layout: + skillos/storage/skill_repo/SkillStorage/ + ├── .git/ + ├── README.md + ├── skill_repo_config.json + ├── skills/ + │ └── {skill_name}/ + │ ├── versions.json + │ ├── 1.0.0.json + │ ├── 1.0.1.json + │ └── ... + └── metadata/ + ├── skills_index.json + └── events.jsonl + +Design: +- Local Git repository is the server-side source of truth. +- Remote Git is only backup/sync storage. +- One skill = one directory. +- One version = one immutable JSON file by default. +- updates to an existing version require overwrite=True. +""" + +from __future__ import annotations + +import difflib +import json +import os +import shutil +import subprocess +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional, Tuple + +from ...models.skill_model import ( + MetaSkillCategory, + Skill, + SkillImplementation, + SkillInterface, + SkillMetrics, + SkillProvenance, + SkillState, + SkillType, +) + + +BASE_DIR = Path(os.getenv("SKILLOS_SKILL_STORAGE_DIR", "skillos/storage/skill_repo/SkillStorage")) + +SKILLS_DIR = BASE_DIR / "skills" +METADATA_DIR = BASE_DIR / "metadata" +INDEX_FILE = METADATA_DIR / "skills_index.json" +EVENTS_FILE = METADATA_DIR / "events.jsonl" +CONFIG_FILE = BASE_DIR / "skill_repo_config.json" +README_FILE = BASE_DIR / "README.md" + +VERSION_MANIFEST = "versions.json" + +VersionBump = Literal["major", "minor", "patch"] +MergeStrategy = Literal["prefer_other", "prefer_base", "append_lists"] + + +DEFAULT_CONFIG: Dict[str, Any] = { + "repo_name": "SkillStorage", + "local_repo_path": str(BASE_DIR), + "default_branch": "main", + "remote_name": "origin", + "remote_url": "", + "git_user_name": "SkillOS Bot", + "git_user_email": "skillos-bot@example.com", + "auto_commit": True, + "auto_push": False, + "sync_interval_seconds": 3600, + "storage_layout_version": 1, +} + + +# --------------------------------------------------------------------------- +# Basic helpers +# --------------------------------------------------------------------------- + +def _utc_now() -> str: + return datetime.utcnow().isoformat(timespec="seconds") + "Z" + + +def _ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _read_json(path: Path, default: Any) -> Any: + if not path.exists(): + return default + try: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + return default + + +def _write_json(path: Path, data: Any) -> None: + _ensure_parent(path) + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2, default=str) + f.write("\n") + + +def _append_event(event: Dict[str, Any]) -> None: + _ensure_parent(EVENTS_FILE) + event.setdefault("event_id", str(uuid.uuid4())) + event.setdefault("time", _utc_now()) + with EVENTS_FILE.open("a", encoding="utf-8") as f: + json.dump(event, f, ensure_ascii=False, default=str) + f.write("\n") + + +def _run_git( + args: List[str], + *, + check: bool = True, + capture: bool = False, +) -> subprocess.CompletedProcess[str]: + BASE_DIR.mkdir(parents=True, exist_ok=True) + return subprocess.run( + ["git", *args], + cwd=BASE_DIR, + check=check, + capture_output=capture, + text=True, + ) + + +def _is_git_repo() -> bool: + return (BASE_DIR / ".git").exists() + + +def _has_git_changes() -> bool: + if not _is_git_repo(): + return False + result = _run_git(["status", "--porcelain"], check=False, capture=True) + return bool(result.stdout.strip()) + + +def _git_commit(message: str, allow_empty: bool = False) -> bool: + if not _is_git_repo(): + return False + + _run_git(["add", "."], check=True) + + if not allow_empty and not _has_git_changes(): + return False + + result = _run_git(["commit", "-m", message], check=False, capture=True) + if result.returncode != 0: + output = (result.stdout or "") + (result.stderr or "") + if "nothing to commit" in output.lower(): + return False + raise RuntimeError(output) + + return True + + +def _parse_semver(version: str) -> Tuple[int, int, int]: + parts = version.split(".") + if len(parts) != 3: + raise ValueError(f"非法版本号: {version!r}") + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def _semver_key(version: str) -> Tuple[int, int, int]: + return _parse_semver(version) + + +def _next_version(version: str, bump: VersionBump = "patch") -> str: + major, minor, patch = _parse_semver(version) + if bump == "major": + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +def _skill_dir(skill_name: str) -> Path: + return SKILLS_DIR / skill_name + + +def _skill_file(skill_name: str, version: str) -> Path: + return _skill_dir(skill_name) / f"{version}.json" + + +def _manifest_file(skill_name: str) -> Path: + return _skill_dir(skill_name) / VERSION_MANIFEST + + +def _skill_to_dict(skill: Skill) -> Dict[str, Any]: + if hasattr(skill, "model_dump"): + return skill.model_dump(mode="json") + return skill.dict() + + +def _dict_to_skill(data: Dict[str, Any]) -> Skill: + if hasattr(Skill, "model_validate"): + return Skill.model_validate(data) + return Skill.parse_obj(data) + + +def _state_value(state: SkillState | str) -> str: + return state.value if hasattr(state, "value") else str(state) + + +def _skill_type_value(skill_type: SkillType | str) -> str: + return skill_type.value if hasattr(skill_type, "value") else str(skill_type) + + +def _latest_version_from_versions_dict(versions: Dict[str, Any]) -> Optional[str]: + valid = [v for v, meta in versions.items() if not meta.get("deleted", False)] + if not valid: + return None + return sorted(valid, key=_semver_key)[-1] + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +def load_config() -> Dict[str, Any]: + config = dict(DEFAULT_CONFIG) + config.update(_read_json(CONFIG_FILE, {})) + return config + + +def save_config(config: Dict[str, Any]) -> Dict[str, Any]: + merged = dict(DEFAULT_CONFIG) + merged.update(config) + merged["local_repo_path"] = str(BASE_DIR) + _write_json(CONFIG_FILE, merged) + return merged + + +# --------------------------------------------------------------------------- +# Manifest / index +# --------------------------------------------------------------------------- + +def _load_manifest(skill_name: str) -> Dict[str, Any]: + return _read_json( + _manifest_file(skill_name), + { + "name": skill_name, + "latest_version": None, + "versions": {}, + "deleted": False, + "created_at": _utc_now(), + "updated_at": _utc_now(), + }, + ) + + +def _save_manifest(skill_name: str, manifest: Dict[str, Any]) -> None: + manifest["name"] = skill_name + manifest["updated_at"] = _utc_now() + _write_json(_manifest_file(skill_name), manifest) + + +def _load_index() -> Dict[str, Any]: + return _read_json(INDEX_FILE, {}) + + +def _save_index(index: Dict[str, Any]) -> None: + _write_json(INDEX_FILE, index) + + +def _version_meta_from_skill(skill: Skill, source: str, old_meta: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + old_meta = old_meta or {} + return { + "version": skill.version, + "skill_id": skill.skill_id, + "state": _state_value(skill.state), + "skill_type": _skill_type_value(skill.skill_type), + "domain": skill.domain, + "display_name": skill.display_name, + "description": skill.description, + "tags": skill.tags, + "file": f"{skill.version}.json", + "deleted": False, + "created_at": old_meta.get("created_at", _utc_now()), + "updated_at": _utc_now(), + "source": source, + } + + +def _rebuild_index() -> Dict[str, Any]: + index: Dict[str, Any] = {} + SKILLS_DIR.mkdir(parents=True, exist_ok=True) + + for skill_dir in sorted([p for p in SKILLS_DIR.iterdir() if p.is_dir()]): + skill_name = skill_dir.name + manifest = _load_manifest(skill_name) + versions = manifest.setdefault("versions", {}) + + for file_path in sorted(skill_dir.glob("*.json")): + if file_path.name == VERSION_MANIFEST: + continue + + version = file_path.stem + data = _read_json(file_path, {}) + if not data: + continue + + old_meta = versions.get(version, {}) + versions[version] = { + "version": version, + "skill_id": data.get("skill_id"), + "state": data.get("state"), + "skill_type": data.get("skill_type"), + "domain": data.get("domain"), + "display_name": data.get("display_name"), + "description": data.get("description"), + "tags": data.get("tags", []), + "file": file_path.name, + "deleted": old_meta.get("deleted", False), + "created_at": old_meta.get("created_at", data.get("created_at", _utc_now())), + "updated_at": data.get("updated_at", old_meta.get("updated_at", _utc_now())), + "source": old_meta.get("source", "file_scan"), + } + + manifest["versions"] = versions + manifest["latest_version"] = _latest_version_from_versions_dict(versions) + manifest.setdefault("deleted", False) + if manifest["latest_version"] is not None: + manifest["deleted"] = False + _save_manifest(skill_name, manifest) + + latest_version = manifest.get("latest_version") + latest_meta = versions.get(latest_version, {}) if latest_version else {} + deleted = manifest.get("deleted", False) or latest_version is None + + index[skill_name] = { + "name": skill_name, + "latest_version": latest_version, + "deleted": deleted, + "state": latest_meta.get("state"), + "skill_id": latest_meta.get("skill_id"), + "skill_type": latest_meta.get("skill_type"), + "domain": latest_meta.get("domain"), + "display_name": latest_meta.get("display_name"), + "description": latest_meta.get("description"), + "tags": latest_meta.get("tags", []), + "version_count": len([v for v, m in versions.items() if not m.get("deleted", False)]), + "versions": versions, + "updated_at": manifest.get("updated_at", _utc_now()), + } + + _save_index(index) + return index + + +def rebuild_index(*, commit: bool = True) -> Dict[str, Any]: + init_repo(initial_commit=False) + index = _rebuild_index() + _append_event({"action": "rebuild_index", "skill_count": len(index)}) + if commit: + _git_commit("Rebuild SkillStorage index") + return index + + +# --------------------------------------------------------------------------- +# Repository init / status / reset +# --------------------------------------------------------------------------- + +def init_repo( + *, + remote_url: Optional[str] = None, + default_branch: str = "main", + git_user_name: Optional[str] = None, + git_user_email: Optional[str] = None, + initial_commit: bool = True, + reset: bool = False, +) -> None: + """Initialize local SkillStorage repository. + + reset=True removes the whole SkillStorage directory first. + Use it only for tests or local reinitialization. + """ + if reset and BASE_DIR.exists(): + shutil.rmtree(BASE_DIR) + + BASE_DIR.mkdir(parents=True, exist_ok=True) + SKILLS_DIR.mkdir(parents=True, exist_ok=True) + METADATA_DIR.mkdir(parents=True, exist_ok=True) + + config = load_config() + config["default_branch"] = default_branch or config.get("default_branch", "main") + if remote_url is not None: + config["remote_url"] = remote_url + if git_user_name: + config["git_user_name"] = git_user_name + if git_user_email: + config["git_user_email"] = git_user_email + save_config(config) + + if not _is_git_repo(): + _run_git(["init"], check=True) + _run_git(["checkout", "-B", config["default_branch"]], check=False) + + if config.get("git_user_name"): + _run_git(["config", "user.name", config["git_user_name"]], check=False) + if config.get("git_user_email"): + _run_git(["config", "user.email", config["git_user_email"]], check=False) + + if config.get("remote_url"): + remote_name = config.get("remote_name", "origin") + remotes = _run_git(["remote"], check=False, capture=True).stdout.splitlines() + if remote_name in remotes: + _run_git(["remote", "set-url", remote_name, config["remote_url"]], check=False) + else: + _run_git(["remote", "add", remote_name, config["remote_url"]], check=False) + + if not INDEX_FILE.exists(): + _write_json(INDEX_FILE, {}) + + if not EVENTS_FILE.exists(): + EVENTS_FILE.touch() + + if not README_FILE.exists(): + README_FILE.write_text( + "# SkillStorage\n\n" + "Git-backed Skill storage repository for SkillOS.\n\n" + "## Layout\n\n" + "- `skills/{skill_name}/{version}.json`: skill version file\n" + "- `skills/{skill_name}/versions.json`: per-skill version manifest\n" + "- `metadata/skills_index.json`: global index\n" + "- `metadata/events.jsonl`: lifecycle event log\n", + encoding="utf-8", + ) + + if initial_commit: + _append_event({"action": "init_repo", "reset": reset}) + _git_commit("Initialize SkillStorage repository") + + +def repo_status() -> Dict[str, Any]: + init_repo(initial_commit=False) + config = load_config() + git_status = _run_git(["status", "--short"], check=False, capture=True).stdout + branch = _run_git(["branch", "--show-current"], check=False, capture=True).stdout.strip() + return { + "base_dir": str(BASE_DIR), + "is_git_repo": _is_git_repo(), + "branch": branch, + "remote_name": config.get("remote_name"), + "remote_url": config.get("remote_url"), + "dirty": bool(git_status.strip()), + "status": git_status, + } + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +def add_skill( + skill: Skill, + *, + author: str = "system", + commit: bool = True, + overwrite: bool = False, + event_action: str = "add", +) -> Skill: + """Add a normal skill version. + + Normal add behavior: + - if skill name does not exist, create skills/{name}/ + - if version does not exist, create {version}.json + - if same name+version already exists, raise unless overwrite=True + """ + init_repo(initial_commit=False) + + skill_dir = _skill_dir(skill.name) + skill_dir.mkdir(parents=True, exist_ok=True) + + skill_file = _skill_file(skill.name, skill.version) + if skill_file.exists() and not overwrite: + raise ValueError(f"Skill 已存在,不能重复 add: {skill.name} v{skill.version}") + + skill.updated_at = datetime.utcnow() + _write_json(skill_file, _skill_to_dict(skill)) + + manifest = _load_manifest(skill.name) + manifest["deleted"] = False + manifest.setdefault("versions", {}) + old_meta = manifest["versions"].get(skill.version) + manifest["versions"][skill.version] = _version_meta_from_skill(skill, event_action, old_meta) + manifest["latest_version"] = _latest_version_from_versions_dict(manifest["versions"]) + _save_manifest(skill.name, manifest) + + _rebuild_index() + + _append_event( + { + "action": event_action, + "skill": skill.name, + "version": skill.version, + "skill_id": skill.skill_id, + "state": _state_value(skill.state), + "author": author, + "file": str(skill_file.relative_to(BASE_DIR)), + "overwrite": overwrite, + } + ) + + if commit: + _git_commit(f"{event_action}: {skill.name} v{skill.version}") + + return skill + + +def update_skill_version( + skill: Skill, + *, + author: str = "system", + commit: bool = True, +) -> Skill: + """Update existing name+version file. This is not a new version.""" + return add_skill(skill, author=author, commit=commit, overwrite=True, event_action="update") + + +def get_skill_versions( + skill_name: str, + *, + include_deleted: bool = False, +) -> List[str]: + init_repo(initial_commit=False) + manifest = _load_manifest(skill_name) + versions = manifest.get("versions", {}) + + result = [ + version + for version, meta in versions.items() + if include_deleted or not meta.get("deleted", False) + ] + + if not result and _skill_dir(skill_name).exists(): + result = [ + p.stem + for p in _skill_dir(skill_name).glob("*.json") + if p.name != VERSION_MANIFEST + ] + + return sorted(result, key=_semver_key) + + +def get_skill( + skill_name: str, + version: Optional[str] = None, + *, + include_deleted: bool = False, +) -> Optional[Skill]: + init_repo(initial_commit=False) + + versions = get_skill_versions(skill_name, include_deleted=include_deleted) + if not versions: + return None + + target_version = version or versions[-1] + + manifest = _load_manifest(skill_name) + meta = manifest.get("versions", {}).get(target_version) + if meta and meta.get("deleted", False) and not include_deleted: + return None + + skill_file = _skill_file(skill_name, target_version) + if not skill_file.exists(): + return None + + data = _read_json(skill_file, None) + if data is None: + return None + + return _dict_to_skill(data) + + +def get_skill_by_id(skill_id: str, *, include_deleted: bool = False) -> Optional[Skill]: + for row in list_skills(include_deleted=include_deleted, latest_only=False, limit=100000): + if row.get("skill_id") == skill_id: + return get_skill(row["name"], row["version"], include_deleted=include_deleted) + return None + + +def list_skills( + *, + state: Optional[SkillState | str] = None, + skill_type: Optional[SkillType | str] = None, + domain: Optional[str] = None, + name_like: Optional[str] = None, + latest_only: bool = True, + include_deleted: bool = False, + limit: int = 100, + offset: int = 0, +) -> List[Dict[str, Any]]: + init_repo(initial_commit=False) + _rebuild_index() + + state_value = _state_value(state) if state is not None else None + skill_type_value = _skill_type_value(skill_type) if skill_type is not None else None + index = _load_index() + rows: List[Dict[str, Any]] = [] + + for skill_name, skill_meta in index.items(): + if skill_meta.get("deleted", False) and not include_deleted: + continue + + if latest_only: + version = skill_meta.get("latest_version") + if not version: + continue + versions = {version: skill_meta.get("versions", {}).get(version, {})} + else: + versions = skill_meta.get("versions", {}) + + for version, version_meta in versions.items(): + if version_meta.get("deleted", False) and not include_deleted: + continue + + row = { + "name": skill_name, + "version": version, + **skill_meta, + **version_meta, + } + + if state_value and row.get("state") != state_value: + continue + if skill_type_value and row.get("skill_type") != skill_type_value: + continue + if domain and row.get("domain") != domain: + continue + if name_like and name_like not in skill_name: + continue + + rows.append(row) + + rows.sort(key=lambda r: (r.get("name", ""), _semver_key(r.get("version", "0.0.0")))) + return rows[offset : offset + limit] + + +def delete_skill( + skill_name: str, + version: Optional[str] = None, + *, + hard: bool = False, + author: str = "system", + reason: Optional[str] = None, + commit: bool = True, +) -> bool: + """Delete skill. + + Default is soft delete. + hard=True physically removes files. + """ + init_repo(initial_commit=False) + + skill_dir = _skill_dir(skill_name) + if not skill_dir.exists(): + return False + + manifest = _load_manifest(skill_name) + versions = manifest.get("versions", {}) + + if version: + if version not in versions and not _skill_file(skill_name, version).exists(): + return False + + if hard: + file_path = _skill_file(skill_name, version) + if file_path.exists(): + file_path.unlink() + versions.pop(version, None) + else: + versions.setdefault(version, {}) + versions[version]["deleted"] = True + versions[version]["deleted_at"] = _utc_now() + versions[version]["delete_reason"] = reason + + manifest["versions"] = versions + manifest["latest_version"] = _latest_version_from_versions_dict(versions) + manifest["deleted"] = manifest["latest_version"] is None + _save_manifest(skill_name, manifest) + + else: + if hard: + shutil.rmtree(skill_dir) + else: + manifest["deleted"] = True + manifest["deleted_at"] = _utc_now() + manifest["delete_reason"] = reason + for item in versions.values(): + item["deleted"] = True + item["deleted_at"] = _utc_now() + item["delete_reason"] = reason + manifest["versions"] = versions + manifest["latest_version"] = None + _save_manifest(skill_name, manifest) + + _rebuild_index() + + _append_event( + { + "action": "delete", + "skill": skill_name, + "version": version, + "hard": hard, + "author": author, + "reason": reason, + } + ) + + if commit: + _git_commit(f"delete: {skill_name} {version or 'all'}") + + return True + + +# --------------------------------------------------------------------------- +# Version management +# --------------------------------------------------------------------------- + +def create_new_version( + skill_name: str, + source_version: Optional[str] = None, + *, + bump: VersionBump = "patch", + overrides: Optional[Dict[str, Any]] = None, + author: str = "system", + commit: bool = True, +) -> Skill: + source = get_skill(skill_name, source_version) + if not source: + raise ValueError(f"源 Skill 不存在: {skill_name} v{source_version or ''}") + + new_skill = source.model_copy(deep=True) + new_skill.skill_id = str(uuid.uuid4()) + new_skill.version = _next_version(source.version, bump) + new_skill.state = SkillState.DRAFT + new_skill.created_at = datetime.utcnow() + new_skill.updated_at = datetime.utcnow() + new_skill.released_at = None + new_skill.deprecated_at = None + new_skill.metrics = SkillMetrics() + + if source.provenance: + parent_ids = set(source.provenance.parent_skill_ids) + parent_ids.add(source.skill_id) + new_skill.provenance.parent_skill_ids = list(parent_ids) + new_skill.provenance.source_type = "adapt" + new_skill.provenance.creation_context.update({"source_version": source.version}) + else: + new_skill.provenance = SkillProvenance( + source_type="adapt", + parent_skill_ids=[source.skill_id], + creation_context={"source_version": source.version}, + ) + + for key, value in (overrides or {}).items(): + setattr(new_skill, key, value) + + return add_skill(new_skill, author=author, commit=commit, event_action="new_version") + + +def get_version_history(skill_name: str, *, include_deleted: bool = False) -> List[Skill]: + history: List[Skill] = [] + for version in get_skill_versions(skill_name, include_deleted=include_deleted): + skill = get_skill(skill_name, version, include_deleted=include_deleted) + if skill: + history.append(skill) + return history + + +def transition_skill_state( + skill_name: str, + version: str, + new_state: SkillState, + *, + author: str = "system", + reason: Optional[str] = None, + commit: bool = True, +) -> Skill: + skill = get_skill(skill_name, version) + if not skill: + raise ValueError(f"Skill 不存在: {skill_name} v{version}") + + old_state = skill.state + skill.transition_to(new_state) + + if new_state == SkillState.DEPRECATED and reason: + skill.deprecation_reason = reason + + updated = update_skill_version(skill, author=author, commit=False) + + _append_event( + { + "action": "transition", + "skill": skill_name, + "version": version, + "from_state": _state_value(old_state), + "to_state": _state_value(new_state), + "author": author, + "reason": reason, + } + ) + + if commit: + _git_commit(f"transition: {skill_name} v{version} {_state_value(old_state)}->{_state_value(new_state)}") + + return updated + + +# --------------------------------------------------------------------------- +# Diff / history +# --------------------------------------------------------------------------- + +def diff_versions( + skill_name: str, + v1: str, + v2: str, + *, + use_git: bool = True, + context_lines: int = 3, +) -> str: + init_repo(initial_commit=False) + + file1 = _skill_file(skill_name, v1) + file2 = _skill_file(skill_name, v2) + + if not file1.exists() or not file2.exists(): + raise FileNotFoundError(f"Skill version not found: {skill_name} {v1} or {v2}") + + if use_git: + result = _run_git( + [ + "diff", + "--no-index", + "--", + str(file1.relative_to(BASE_DIR)), + str(file2.relative_to(BASE_DIR)), + ], + check=False, + capture=True, + ) + if result.returncode in (0, 1): + return result.stdout + raise RuntimeError(result.stderr or result.stdout) + + a = file1.read_text(encoding="utf-8").splitlines(keepends=True) + b = file2.read_text(encoding="utf-8").splitlines(keepends=True) + + return "".join( + difflib.unified_diff( + a, + b, + fromfile=f"{skill_name}/{v1}.json", + tofile=f"{skill_name}/{v2}.json", + n=context_lines, + ) + ) + + +def git_file_history( + skill_name: str, + version: Optional[str] = None, + *, + max_count: int = 20, +) -> str: + init_repo(initial_commit=False) + + path = _skill_dir(skill_name) + if version: + path = _skill_file(skill_name, version) + + result = _run_git( + [ + "log", + f"--max-count={max_count}", + "--oneline", + "--", + str(path.relative_to(BASE_DIR)), + ], + check=False, + capture=True, + ) + + return result.stdout + + +# --------------------------------------------------------------------------- +# Merge +# --------------------------------------------------------------------------- + +def _merge_values(base_value: Any, other_value: Any, strategy: MergeStrategy) -> Any: + if strategy == "prefer_base": + return base_value if base_value not in (None, [], {}) else other_value + + if strategy == "append_lists" and isinstance(base_value, list) and isinstance(other_value, list): + result = list(base_value) + for item in other_value: + if item not in result: + result.append(item) + return result + + return other_value if other_value not in (None, [], {}) else base_value + + +def merge_skills( + skill_name: str, + base_version: str, + other_version: str, + new_version: Optional[str] = None, + *, + strategy: MergeStrategy = "prefer_other", + manual_overrides: Optional[Dict[str, Any]] = None, + author: str = "system", + commit: bool = True, +) -> Skill: + base = get_skill(skill_name, base_version) + other = get_skill(skill_name, other_version) + + if not base or not other: + raise ValueError(f"待合并 Skill 不存在: {skill_name} {base_version}, {other_version}") + + base_data = _skill_to_dict(base) + other_data = _skill_to_dict(other) + + merged_data = dict(base_data) + + for key, other_value in other_data.items(): + if key in { + "skill_id", + "version", + "created_at", + "updated_at", + "released_at", + "deprecated_at", + }: + continue + merged_data[key] = _merge_values(merged_data.get(key), other_value, strategy) + + if manual_overrides: + merged_data.update(manual_overrides) + + max_source_version = max(base.version, other.version, key=_semver_key) + + merged_data["skill_id"] = str(uuid.uuid4()) + merged_data["version"] = new_version or _next_version(max_source_version, "patch") + merged_data["state"] = SkillState.DRAFT.value + merged_data["created_at"] = _utc_now() + merged_data["updated_at"] = _utc_now() + merged_data["released_at"] = None + merged_data["deprecated_at"] = None + + merged_data["provenance"] = merged_data.get("provenance") or {} + merged_data["provenance"].update( + { + "source_type": "merge", + "parent_skill_ids": [base.skill_id, other.skill_id], + "creation_context": { + "base_version": base.version, + "other_version": other.version, + "strategy": strategy, + }, + } + ) + + merged = _dict_to_skill(merged_data) + added = add_skill(merged, author=author, commit=False, event_action="merge") + + _append_event( + { + "action": "merge", + "skill": skill_name, + "base_version": base_version, + "other_version": other_version, + "new_version": added.version, + "strategy": strategy, + "author": author, + } + ) + + if commit: + _git_commit(f"merge: {skill_name} {base_version}+{other_version} -> {added.version}") + + return added + + +# --------------------------------------------------------------------------- +# Remote sync +# --------------------------------------------------------------------------- + +def pull_from_remote( + *, + remote_name: Optional[str] = None, + branch: Optional[str] = None, + rebase: bool = True, +) -> str: + init_repo(initial_commit=False) + + config = load_config() + remote = remote_name or config.get("remote_name", "origin") + target_branch = branch or config.get("default_branch", "main") + + args = ["pull", remote, target_branch] + if rebase: + args.insert(1, "--rebase") + + result = _run_git(args, check=False, capture=True) + if result.returncode != 0: + raise RuntimeError(result.stderr or result.stdout) + + _append_event({"action": "pull", "remote": remote, "branch": target_branch}) + _git_commit("Record remote pull event") + return result.stdout + result.stderr + + +def push_to_remote( + remote_name: Optional[str] = None, + branch: Optional[str] = None, + *, + set_upstream: bool = True, +) -> str: + init_repo(initial_commit=False) + + config = load_config() + remote = remote_name or config.get("remote_name", "origin") + target_branch = branch or config.get("default_branch", "main") + + if not config.get("remote_url"): + raise ValueError("remote_url 为空,请先在 skill_repo_config.json 中配置远程仓库地址") + + args = ["push"] + if set_upstream: + args.append("-u") + args += [remote, target_branch] + + result = _run_git(args, check=False, capture=True) + if result.returncode != 0: + raise RuntimeError(result.stderr or result.stdout) + + _append_event({"action": "push", "remote": remote, "branch": target_branch}) + _git_commit("Record remote push event") + return result.stdout + result.stderr + + +def sync_to_remote() -> str: + init_repo(initial_commit=False) + _git_commit("Sync pending SkillStorage changes") + return push_to_remote() + + +# --------------------------------------------------------------------------- +# Events / sample data +# --------------------------------------------------------------------------- + +def read_events(limit: int = 100) -> List[Dict[str, Any]]: + if not EVENTS_FILE.exists(): + return [] + + lines = EVENTS_FILE.read_text(encoding="utf-8").splitlines() + selected = lines[-limit:] + events: List[Dict[str, Any]] = [] + + for line in selected: + if not line.strip(): + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + + return events + + +def create_sample_skills(*, commit: bool = True) -> List[Skill]: + """Create five sample skills for local testing.""" + + samples = [ + Skill( + name="click_element", + version="1.0.0", + description="点击页面上的指定元素", + skill_type=SkillType.ATOMIC, + domain="web", + granularity_level=1, + state=SkillState.RELEASED, + tags=["web", "click", "interaction"], + interface=SkillInterface( + input_schema={ + "type": "object", + "properties": { + "selector": {"type": "string", "description": "CSS 选择器或 XPath"}, + "timeout_ms": {"type": "integer", "default": 5000}, + }, + "required": ["selector"], + }, + output_schema={"type": "object", "properties": {"clicked": {"type": "boolean"}}}, + preconditions=["目标元素在页面上可见且可交互"], + postconditions=["元素已被点击,触发相应事件"], + ), + implementation=SkillImplementation( + language="python", + code='await page.click(input_data["selector"])', + tool_calls=["playwright"], + ), + ), + Skill( + name="type_text", + version="1.0.0", + description="在输入框中输入文本", + skill_type=SkillType.ATOMIC, + domain="web", + granularity_level=1, + state=SkillState.RELEASED, + tags=["web", "input", "text"], + interface=SkillInterface( + input_schema={ + "type": "object", + "properties": { + "selector": {"type": "string"}, + "text": {"type": "string"}, + "clear_first": {"type": "boolean", "default": True}, + }, + "required": ["selector", "text"], + }, + output_schema={"type": "object", "properties": {"typed": {"type": "boolean"}}}, + preconditions=["目标输入框存在且可编辑"], + postconditions=["文本已输入到指定输入框"], + ), + implementation=SkillImplementation( + language="python", + code='await page.fill(input_data["selector"], input_data["text"])', + tool_calls=["playwright"], + ), + ), + Skill( + name="locate_element", + version="1.0.0", + description="在页面上定位指定元素,返回元素信息", + skill_type=SkillType.ATOMIC, + domain="web", + granularity_level=1, + state=SkillState.RELEASED, + tags=["web", "locate", "dom"], + interface=SkillInterface( + input_schema={ + "type": "object", + "properties": { + "description": {"type": "string", "description": "元素的自然语言描述"}, + "selector_hint": {"type": "string"}, + }, + "required": ["description"], + }, + output_schema={ + "type": "object", + "properties": { + "selector": {"type": "string"}, + "found": {"type": "boolean"}, + "element_type": {"type": "string"}, + }, + }, + preconditions=["页面已加载完成"], + postconditions=["返回元素的 CSS 选择器"], + ), + implementation=SkillImplementation( + language="python", + prompt_template="在页面上找到描述为 '{description}' 的元素,返回其 CSS 选择器。", + ), + ), + Skill( + name="fill_form", + version="1.0.0", + description="填写页面上的结构化表单", + skill_type=SkillType.FUNCTIONAL, + domain="web", + granularity_level=2, + state=SkillState.RELEASED, + tags=["web", "form", "input", "functional"], + interface=SkillInterface( + input_schema={ + "type": "object", + "properties": { + "fields": { + "type": "object", + "description": "字段名到值的映射", + "additionalProperties": {"type": "string"}, + }, + "submit": {"type": "boolean", "default": False}, + }, + "required": ["fields"], + }, + output_schema={ + "type": "object", + "properties": { + "filled_count": {"type": "integer"}, + "submitted": {"type": "boolean"}, + }, + }, + preconditions=["页面上存在可编辑的表单字段"], + postconditions=["所有指定字段已填写完毕"], + side_effects=["如果 submit=true,表单将被提交"], + ), + implementation=SkillImplementation( + language="python", + sub_skill_ids=[], + prompt_template="对表单中的每个字段 {fields},先定位字段,再输入对应值。", + ), + ), + Skill( + name="skill_lifecycle_manager", + version="1.0.0", + description="管理 Skill 的完整生命周期,包括创建、验证、发布和废弃", + skill_type=SkillType.STRATEGIC, + meta_category=MetaSkillCategory.LIFECYCLE, + domain="skillos", + granularity_level=4, + state=SkillState.RELEASED, + tags=["meta", "lifecycle", "management"], + interface=SkillInterface( + input_schema={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "validate", "release", "deprecate", "archive"], + }, + "skill_id": {"type": "string"}, + "params": {"type": "object"}, + }, + "required": ["action"], + }, + output_schema={ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "new_state": {"type": "string"}, + "message": {"type": "string"}, + }, + }, + preconditions=["目标 Skill 存在于 SkillOS 中"], + postconditions=["Skill 状态已按照生命周期规则转换"], + ), + implementation=SkillImplementation( + language="python", + prompt_template="执行 Skill 生命周期操作 {action},遵循状态机规则。", + ), + ), + ] + + inserted: List[Skill] = [] + + for skill in samples: + existing = get_skill(skill.name, skill.version, include_deleted=True) + if existing: + inserted.append(existing) + continue + + inserted.append( + add_skill( + skill, + author="seed", + commit=False, + event_action="seed", + ) + ) + + if commit: + _git_commit("Seed sample skills") + + return inserted + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def _json_print(data: Any) -> None: + print(json.dumps(data, ensure_ascii=False, indent=2, default=str)) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Git-backed SkillStorage common utility") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_init = sub.add_parser("init") + p_init.add_argument("--reset", action="store_true") + p_init.add_argument("--remote-url", default=None) + p_init.add_argument("--branch", default="main") + + sub.add_parser("status") + sub.add_parser("seed") + sub.add_parser("rebuild-index") + sub.add_parser("events") + + p_list = sub.add_parser("list") + p_list.add_argument("--all-versions", action="store_true") + p_list.add_argument("--include-deleted", action="store_true") + p_list.add_argument("--state") + p_list.add_argument("--type") + p_list.add_argument("--domain") + p_list.add_argument("--name-like") + p_list.add_argument("--limit", type=int, default=100) + p_list.add_argument("--offset", type=int, default=0) + + p_get = sub.add_parser("get") + p_get.add_argument("name") + p_get.add_argument("--version") + p_get.add_argument("--include-deleted", action="store_true") + + p_versions = sub.add_parser("versions") + p_versions.add_argument("name") + p_versions.add_argument("--include-deleted", action="store_true") + + p_history = sub.add_parser("history") + p_history.add_argument("name") + p_history.add_argument("--version") + p_history.add_argument("--max-count", type=int, default=20) + + p_diff = sub.add_parser("diff") + p_diff.add_argument("name") + p_diff.add_argument("v1") + p_diff.add_argument("v2") + + p_new = sub.add_parser("new-version") + p_new.add_argument("name") + p_new.add_argument("--source-version") + p_new.add_argument("--bump", choices=["major", "minor", "patch"], default="patch") + + p_delete = sub.add_parser("delete") + p_delete.add_argument("name") + p_delete.add_argument("--version") + p_delete.add_argument("--hard", action="store_true") + p_delete.add_argument("--reason") + + p_merge = sub.add_parser("merge") + p_merge.add_argument("name") + p_merge.add_argument("base_version") + p_merge.add_argument("other_version") + p_merge.add_argument("--new-version") + p_merge.add_argument("--strategy", choices=["prefer_other", "prefer_base", "append_lists"], default="prefer_other") + + p_transition = sub.add_parser("transition") + p_transition.add_argument("name") + p_transition.add_argument("version") + p_transition.add_argument("state") + + p_push = sub.add_parser("push") + p_push.add_argument("--remote") + p_push.add_argument("--branch") + + p_pull = sub.add_parser("pull") + p_pull.add_argument("--remote") + p_pull.add_argument("--branch") + p_pull.add_argument("--no-rebase", action="store_true") + + args = parser.parse_args() + + if args.cmd == "init": + init_repo(reset=args.reset, remote_url=args.remote_url, default_branch=args.branch) + _json_print(repo_status()) + + elif args.cmd == "status": + _json_print(repo_status()) + + elif args.cmd == "seed": + init_repo() + skills = create_sample_skills() + print(f"seeded {len(skills)} skills") + + elif args.cmd == "rebuild-index": + _json_print(rebuild_index()) + + elif args.cmd == "events": + _json_print(read_events()) + + elif args.cmd == "list": + _json_print( + list_skills( + state=args.state, + skill_type=args.type, + domain=args.domain, + name_like=args.name_like, + latest_only=not args.all_versions, + include_deleted=args.include_deleted, + limit=args.limit, + offset=args.offset, + ) + ) + + elif args.cmd == "get": + skill = get_skill(args.name, args.version, include_deleted=args.include_deleted) + _json_print(_skill_to_dict(skill) if skill else None) + + elif args.cmd == "versions": + _json_print(get_skill_versions(args.name, include_deleted=args.include_deleted)) + + elif args.cmd == "history": + print(git_file_history(args.name, args.version, max_count=args.max_count)) + + elif args.cmd == "diff": + print(diff_versions(args.name, args.v1, args.v2)) + + elif args.cmd == "new-version": + skill = create_new_version(args.name, args.source_version, bump=args.bump) + _json_print(_skill_to_dict(skill)) + + elif args.cmd == "delete": + ok = delete_skill(args.name, args.version, hard=args.hard, reason=args.reason) + print("deleted" if ok else "not found") + + elif args.cmd == "merge": + skill = merge_skills( + args.name, + args.base_version, + args.other_version, + new_version=args.new_version, + strategy=args.strategy, + ) + _json_print(_skill_to_dict(skill)) + + elif args.cmd == "transition": + state = SkillState(args.state) + skill = transition_skill_state(args.name, args.version, state) + _json_print(_skill_to_dict(skill)) + + elif args.cmd == "push": + print(push_to_remote(args.remote, args.branch)) + + elif args.cmd == "pull": + print(pull_from_remote(remote_name=args.remote, branch=args.branch, rebase=not args.no_rebase)) diff --git a/skillos/skillos/storage/skill_repo/usecase.ipynb b/skillos/skillos/storage/skill_repo/usecase.ipynb new file mode 100644 index 0000000..340be0b --- /dev/null +++ b/skillos/skillos/storage/skill_repo/usecase.ipynb @@ -0,0 +1,113 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "ef8b5b7d", + "metadata": {}, + "outputs": [], + "source": [ + "from skillos.scripts.skill_repo_scripts import *" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "66b2f9c9", + "metadata": {}, + "outputs": [], + "source": [ + "init_repo()" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2d2b6cad", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'base_dir': 'skillos/storage/skill_repo/SkillStorage',\n", + " 'is_git_repo': True,\n", + " 'branch': 'main',\n", + " 'remote_name': 'origin',\n", + " 'remote_url': '',\n", + " 'dirty': False,\n", + " 'status': ''}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "repo_status()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "9585340c", + "metadata": {}, + "outputs": [], + "source": [ + "from skillos.models.skill_model import Skill, SkillState\n", + "\n", + "skill1 = Skill(name=\"search_web\", description=\"搜索网页\", version=\"1.0.0\", state=SkillState.DRAFT)\n", + "skill2 = Skill(name=\"nlp_translate\", description=\"文本翻译\", version=\"0.1.0\", state=SkillState.DRAFT)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "839ccb35", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[main e353127] Add/Update Skill nlp_translate v0.1.0\n", + " 3 files changed, 55 insertions(+)\n", + " create mode 100644 skills/nlp_translate/0.1.0/0.1.0.json\n" + ] + } + ], + "source": [ + "add_skill(skill2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "833bbc3a", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env_1", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 764ce97602275a8494b47797915f809a92ecc0b6 Mon Sep 17 00:00:00 2001 From: RunChuan123 <3233096165@qq.com> Date: Thu, 21 May 2026 17:03:50 +0800 Subject: [PATCH 3/5] v1 --- .gitignore | 2 + docs/modules/01-skill-repository.md | 18 + docs/modules/04-self-management-agents.md | 28 + docs/modules/05-frontend-api-pipeline.md | 8 +- skillos-frontend/src/api/client.ts | 66 +- skillos-frontend/src/api/types.ts | 107 +- .../src/components/AgentRuntimeMini.tsx | 223 ++ skillos-frontend/src/components/AppLayout.tsx | 51 +- skillos-frontend/src/hooks/useWebSocket.ts | 40 +- skillos-frontend/src/main.tsx | 16 +- skillos-frontend/src/pages/AgentExecution.tsx | 497 ++- skillos-frontend/src/pages/Dashboard.tsx | 173 +- skillos-frontend/src/pages/Evaluation.tsx | 291 ++ skillos-frontend/src/pages/Evolution.tsx | 234 -- .../src/pages/KnowledgeImport.tsx | 696 +++- skillos-frontend/src/pages/LifecycleDemo.tsx | 350 -- .../src/pages/SelfEvolutionDemo.tsx | 428 --- skillos-frontend/src/pages/SkillGraph.tsx | 253 +- skillos-frontend/src/pages/SkillManage.tsx | 627 ++++ skillos-frontend/src/pages/SkillWiki.tsx | 328 +- skillos-frontend/src/pages/VersionControl.tsx | 592 --- skillos-frontend/vite.config.ts | 4 +- skillos/skillos/api/deps.py | 17 +- skillos/skillos/api/main.py | 1747 ++++++++- skillos/skillos/api/memory_store.py | 160 +- skillos/skillos/api/repository_store.py | 10 +- skillos/skillos/api/routes/evolution.py | 149 +- skillos/skillos/api/routes/execution.py | 398 +- skillos/skillos/api/routes/graph.py | 76 +- skillos/skillos/api/routes/host_info.py | 391 ++ skillos/skillos/api/routes/ingest.py | 68 +- skillos/skillos/api/routes/lifecycle.py | 355 +- skillos/skillos/api/routes/skills.py | 51 +- skillos/skillos/api/schemas.py | 47 +- skillos/skillos/config/llm_config.py | 2 +- .../layers/input_knowledge/pipeline.py | 692 ++++ .../layers/skill_governance/reviewer.py | 16 +- .../layers/skill_management/__init__.py | 7 +- .../layers/skill_management/auditor.py | 27 +- .../layers/skill_management/builder.py | 175 +- .../layers/skill_management/librarian.py | 162 +- .../skill_management/meta_controller.py | 158 + .../layers/skill_repository/indexing.py | 315 +- .../layers/skill_repository/repository.py | 6 +- .../skillos/layers/skill_runtime/__init__.py | 4 + .../skillos/layers/skill_runtime/executor.py | 1220 +++++- .../layers/skill_runtime/host_agent.py | 3270 +++++++++++++++++ .../layers/skill_runtime/observation.py | 321 ++ .../skillos/layers/skill_runtime/planner.py | 36 +- skillos/skillos/models/__init__.py | 10 + skillos/skillos/models/graph_model.py | 132 +- skillos/skillos/models/skill_model.py | 53 +- skillos/skillos/utils/llm_client.py | 48 +- skillos/tests/test_llm_client.py | 40 + 54 files changed, 12991 insertions(+), 2204 deletions(-) create mode 100644 skillos-frontend/src/components/AgentRuntimeMini.tsx create mode 100644 skillos-frontend/src/pages/Evaluation.tsx delete mode 100644 skillos-frontend/src/pages/Evolution.tsx delete mode 100644 skillos-frontend/src/pages/LifecycleDemo.tsx delete mode 100644 skillos-frontend/src/pages/SelfEvolutionDemo.tsx create mode 100644 skillos-frontend/src/pages/SkillManage.tsx delete mode 100644 skillos-frontend/src/pages/VersionControl.tsx create mode 100644 skillos/skillos/api/routes/host_info.py create mode 100644 skillos/skillos/layers/skill_runtime/host_agent.py create mode 100644 skillos/skillos/layers/skill_runtime/observation.py diff --git a/.gitignore b/.gitignore index eb54f68..38733f1 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,8 @@ htmlcov/ # Database *.db *.sqlite3 +skillos/skillos/storage/embedding_cache/ +**/storage/embedding_cache/ # Docker .dockerignore diff --git a/docs/modules/01-skill-repository.md b/docs/modules/01-skill-repository.md index ee5aef4..d32707e 100644 --- a/docs/modules/01-skill-repository.md +++ b/docs/modules/01-skill-repository.md @@ -84,6 +84,24 @@ async def get_stats() -> Dict[str, Any] 第一阶段只保证基础边、子图、依赖链、执行顺序和统计可用;根据 `sub_skill_ids` 自动建边、相似关系发现、复杂图分析放到后续阶段。 +### Heterogeneous Graph + +当前开发主线已经从“只包含 Skill 节点的同质图”升级为“异构知识图”。旧的 `SkillEdge` / `SkillSubgraph` 仍保留,用来兼容执行层、治理层和已有测试;新的 demo 图会把以下实体作为一等节点保存: + +- `skill`:抽象出的可执行 / 可复用 Skill。 +- `task`:用户任务或历史任务。 +- `trajectory`:操作轨迹来源。 +- `document`:说明文档或操作规范。 +- `api_doc`:API 文档或接口说明。 +- `tool`:外部工具或系统能力。 +- `script`:代码脚本来源。 +- `test`:测试样例或验证结果。 +- `version`:Skill 版本节点。 +- `feedback`:执行反馈、反思或演化建议。 +- `agent`:系统内管理图和生命周期的 Agent。 + +异构边使用独立关系类型,例如 `derived_from`、`uses`、`requires`、`composes_with`、`verified_by`、`evolves_from`、`version_of`、`feeds_back_to`。启动 demo 时会通过静态脚本 seed 一组 source → skill → version/test/tool/feedback 的图数据,后续可以逐步替换为 Extractor / Normalizer / Summarizer / Indexer Agents 的真实输出。 + ## API 端点 现有 API 路径保持不变: diff --git a/docs/modules/04-self-management-agents.md b/docs/modules/04-self-management-agents.md index 70c2c1a..4d9d73b 100644 --- a/docs/modules/04-self-management-agents.md +++ b/docs/modules/04-self-management-agents.md @@ -146,6 +146,34 @@ EVENT_TYPES = { } ``` +### 固定输入 Pipeline 的内部 Agent 管理 + +当前 demo 已将 Knowledge Import 的 `parse-and-create` 接入 Self-Management Agents,而不是由 API 路由直接写 Skill / Graph。 + +固定研究输入的内部链路为: + +``` +ExperiencePipeline + Extractor → Normalizer → Summarizer → Indexer + │ + ▼ +MetaControllerAgent.manage_ingested_unit() + │ + ├── SkillBuilderAgent.build_from_experience_unit() + │ 从结构化 experience unit 生成 S1 Candidate Skill + │ + ├── SkillAuditorAgent.audit() + │ 本地规则审计 schema / safety,demo 固定输入不强制走 LLM + │ 通过后推动 Skill: S1 Candidate → S2 Draft → S3 Verified + │ + └── SkillLibrarianAgent + register_new() / update() + index_ingested_unit_graph() + 写入 source、tool、api_doc、test、version、skill 节点和关系边 +``` + +API 响应会返回 `agent_trace`,前端 Knowledge Import 页面用它展示内部 Agent 管理过程。 + --- ## 12 个 Meta-Skills(Strategic L3) diff --git a/docs/modules/05-frontend-api-pipeline.md b/docs/modules/05-frontend-api-pipeline.md index d441a8d..61fa805 100644 --- a/docs/modules/05-frontend-api-pipeline.md +++ b/docs/modules/05-frontend-api-pipeline.md @@ -36,7 +36,7 @@ | `/` | `Dashboard.tsx` | 系统概览:核心指标、类型分布、健康报告、Self-Evolution Metrics、实时事件 | | `/demo` | `SelfEvolutionDemo.tsx` | **EMNLP 核心展示**:完整自演化闭环(检索→规划→执行→记录→学习) | | `/wiki` | `SkillWiki.tsx` | Skill 目录:搜索、过滤、详情抽屉、发布/废弃操作 | -| `/graph` | `SkillGraph.tsx` | 交互式知识图谱(AntV G6):节点/边类型过滤、子图展开 | +| `/graph` | `SkillGraph.tsx` | 交互式异构知识图谱(AntV G6):Source / Skill / Tool / API / Test / Version / Feedback 节点展示与关系过滤 | | `/execution` | `AgentExecution.tsx` | 任务执行:检索到的 Skill 展示、步骤结果、经验记录反馈 | | `/evolution` | `Evolution.tsx` | 健康监控:degraded/critical Skill 列表、修复、演化周期 | | `/lifecycle` | `LifecycleDemo.tsx` | 状态机可视化:S0-S7 转换演示 | @@ -157,8 +157,8 @@ class AppState: | 方法 | 路径 | 功能 | |------|------|------| -| GET | `/graph` | 完整图谱 | -| POST | `/graph/subgraph` | 子图 | +| GET | `/graph` | 完整异构图谱;内存 demo 优先返回 source / skill / tool / api_doc / test / version / feedback 等节点 | +| POST | `/graph/subgraph` | 指定 Skill 周边的异构子图 | | POST | `/graph/edges` | 添加边 | | GET | `/graph/{id}/dependencies` | 依赖链 | | GET | `/graph/{id}/execution-order` | 执行顺序 | @@ -216,6 +216,8 @@ class AppState: | `api_doc` | API 文档/OpenAPI 规范 | REST API 描述 | | `script` | 代码脚本 | Python/JavaScript 函数 | +当前 demo 优先支持固定研究输入:前端 Knowledge Import 页面提供静态 JSON fixture,后端 pipeline 会跳过真实采集,直接解析 `skills[]` 数组并生成结构化经验单元。`parse-and-create` 现在通过 `MetaControllerAgent` 调度 `SkillBuilderAgent`、`SkillAuditorAgent` 和 `SkillLibrarianAgent`,创建或复用 Skill,并同步写入异构图节点与关系,包括 source、tool、api_doc、test、version 和 Skill 节点。响应中的 `agent_trace` 用于前端展示内部 Agent 管理过程。 + ### 关键数据结构 ```python diff --git a/skillos-frontend/src/api/client.ts b/skillos-frontend/src/api/client.ts index 8bd0352..3996834 100644 --- a/skillos-frontend/src/api/client.ts +++ b/skillos-frontend/src/api/client.ts @@ -3,22 +3,34 @@ import type { ExecutionResult, GraphData, HealthReport, + HostSurveyPreset, + HostSurveyResponse, + MergeUpdateResult, OverviewStats, + SkillReviewResult, SkillFull, SkillSearchResult, SkillState, SkillSummary, SkillType, + SkillVisibility, SystemHealth, } from './types' const http = axios.create({ baseURL: '/api/v1' }) +function compactParams>(params?: T): Partial | undefined { + if (!params) return undefined + return Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '') + ) as Partial +} + // ── Skills ──────────────────────────────────────────────────────────────────── export const skillsApi = { - list: (params?: { state?: SkillState; skill_type?: SkillType; limit?: number; offset?: number }) => - http.get('/skills', { params }).then(r => r.data), + list: (params?: { state?: SkillState; skill_type?: SkillType; visibility?: SkillVisibility | 'all'; limit?: number; offset?: number }) => + http.get('/skills', { params: compactParams(params) }).then(r => r.data), get: (id: string) => http.get(`/skills/${id}`).then(r => r.data), @@ -48,8 +60,8 @@ export const lifecycleApi = { transition: (id: string, new_state: SkillState, reason = '') => http.post(`/lifecycle/${id}/transition`, { new_state, reason }).then(r => r.data), - review: (id: string) => - http.post(`/lifecycle/${id}/review`).then(r => r.data), + review: (id: string, auto_apply = false) => + http.post(`/lifecycle/${id}/review`, null, { params: { auto_apply } }).then(r => r.data), reviewAndRelease: (id: string) => http.post(`/lifecycle/${id}/review-and-release`).then(r => r.data), @@ -62,6 +74,7 @@ newVersion: ( tags?: string[] interface?: unknown implementation?: unknown | null + test_cases?: unknown[] domain?: string granularity_level?: number } @@ -70,6 +83,21 @@ newVersion: ( getDiff: (id: string, compare_to?: string) => http.get>(`/lifecycle/${id}/diff`, { params: compare_to ? { compare_to } : {} }).then(r => r.data), + + mergeUpdate: ( + id: string, + payload: { + source_skill_ids: string[] + bump?: 'major' | 'minor' | 'patch' + merge_strategy?: 'agent_generalize' | 'field_union' + description?: string + tags?: string[] + interface?: unknown + implementation?: unknown | null + test_cases?: unknown[] + } + ) => + http.post(`/lifecycle/${id}/merge-update`, payload).then(r => r.data), } // ── Graph ───────────────────────────────────────────────────────────────────── @@ -102,13 +130,16 @@ export const executionApi = { history: () => http.get[]>('/execution/history').then(r => r.data), + + activity: () => + http.get<{ time: string; event: string; data: unknown }[]>('/execution/activity').then(r => r.data), } // ── Evolution ───────────────────────────────────────────────────────────────── export const evolutionApi = { - systemHealth: () => - http.get('/evolution/health').then(r => r.data), + systemHealth: (params?: { visibility?: SkillVisibility | 'all' }) => + http.get('/evolution/health', { params: compactParams(params) }).then(r => r.data), skillHealth: (id: string) => http.get(`/evolution/health/${id}`).then(r => r.data), @@ -116,6 +147,9 @@ export const evolutionApi = { repair: (id: string) => http.post(`/evolution/repair/${id}`).then(r => r.data), + improve: (id: string) => + http.post(`/evolution/improve/${id}`).then(r => r.data), + runCycle: () => http.post('/evolution/cycle').then(r => r.data), } @@ -137,6 +171,16 @@ export interface IngestResponse { proposed_description?: string proposed_type?: string confidence: number + metadata: Record + }[] + created_skill_ids: string[] + graph_nodes_created: number + graph_edges_created: number + agent_trace: { + agent: string + action: string + status: string + details: Record }[] } @@ -148,6 +192,16 @@ export const ingestApi = { http.post('/ingest/parse-and-create', { source_type, content }).then(r => r.data), } +// ── Host Information Survey ────────────────────────────────────────────────── + +export const hostInfoApi = { + presets: () => + http.get('/host-info/presets').then(r => r.data), + + survey: (payload: { task_ids?: string[]; use_llm?: boolean; persist?: boolean; max_output_chars?: number }) => + http.post('/host-info/survey', payload).then(r => r.data), +} + // ── Stats ───────────────────────────────────────────────────────────────────── export interface EvolutionStats { diff --git a/skillos-frontend/src/api/types.ts b/skillos-frontend/src/api/types.ts index 378cbb5..2956ae2 100644 --- a/skillos-frontend/src/api/types.ts +++ b/skillos-frontend/src/api/types.ts @@ -2,6 +2,7 @@ export type SkillType = 'atomic' | 'functional' | 'strategic' export type SkillState = 'S0' | 'S1' | 'S2' | 'S3' | 'S4' | 'S5' | 'S6' | 'S7' +export type SkillVisibility = 'user' | 'kernel' export const STATE_LABELS: Record = { S0: 'Raw Experience', @@ -23,10 +24,13 @@ export interface SkillParameter { } export interface SkillInterface { - inputs: SkillParameter[] - outputs: SkillParameter[] + inputs?: SkillParameter[] + outputs?: SkillParameter[] + input_schema?: Record + output_schema?: Record preconditions: string[] postconditions: string[] + side_effects?: string[] } export interface SkillImplementation { @@ -35,6 +39,7 @@ export interface SkillImplementation { prompt_template?: string tool_calls: string[] sub_skill_ids: string[] + execution_order?: string[] } export interface SkillMetrics { @@ -58,7 +63,9 @@ export interface SkillSummary { skill_type: SkillType state: SkillState tags: string[] + visibility: SkillVisibility version: string + domain?: string granularity_level: number metrics: SkillMetrics created_at: string @@ -66,8 +73,48 @@ export interface SkillSummary { } export interface SkillFull extends SkillSummary { + display_name?: string + provenance?: Record + test_cases?: unknown[] + tool_refs?: unknown[] + trajectory_refs?: unknown[] + doc_refs?: unknown[] interface: SkillInterface implementation?: SkillImplementation + dependency_ids?: string[] + component_ids?: string[] +} + +export interface SkillReviewResult { + review_id: string + status: string + overall_score: number + score_ratio?: number + summary: string + comments: { + field: string + severity: string + message: string + suggestion?: string + }[] + auto_fix_suggestions: Record[] + is_approved: boolean + lifecycle_action: string + updated_skill?: SkillSummary | null +} + +export interface MergeUpdateResult { + success: boolean + updated_skill: SkillSummary + merged_skills: SkillSummary[] + rationale: string + summary: string + diff: { + field: string + type: string + old_value: string + new_value: string + }[] } export interface SkillSearchResult { @@ -77,6 +124,7 @@ export interface SkillSearchResult { skill_type: SkillType state: SkillState tags: string[] + visibility: SkillVisibility version: string score: number match_reason: string @@ -85,13 +133,20 @@ export interface SkillSearchResult { export interface GraphNodeData { id: string name: string + node_type: string + description: string skill_type: string state: string tags: string[] + labels: string[] version: string + domain: string granularity_level: number success_rate: number usage_count: number + source_type?: string + metadata: Record + visibility?: SkillVisibility } export interface GraphEdgeData { @@ -100,6 +155,9 @@ export interface GraphEdgeData { target: string edge_type: string weight: number + confidence: number + description: string + metadata: Record } export interface GraphData { @@ -131,10 +189,14 @@ export interface SystemHealth { export interface ExecutionStepResult { step_id: string + step_index?: number skill_id: string skill_name: string status: string outputs: Record + result?: Record + observations?: Record[] + step_judgment?: Record latency_ms: number error?: string } @@ -158,6 +220,12 @@ export interface ExecutionResult { retrieved_skills: RetrievedSkill[] experience_recorded: boolean suggested_skill?: Record + agent_trace?: { + agent: string + action: string + status: string + details: Record + }[] } export interface OverviewStats { @@ -168,3 +236,38 @@ export interface OverviewStats { avg_success_rate: number graph_stats: Record } + +export interface HostSurveyPreset { + task_id: string + name: string + description: string + labels: string[] + fallback_command: string[] +} + +export interface HostSurveyCommand { + task_id: string + name: string + description: string + command: string[] + command_source: string + status: string + summary: string + node_id?: string + stdout_preview: string + error?: string +} + +export interface HostSurveyResponse { + success: boolean + run_id: string + created_nodes: number + created_edges: number + commands: HostSurveyCommand[] + agent_trace: { + agent: string + action: string + status: string + details: Record + }[] +} diff --git a/skillos-frontend/src/components/AgentRuntimeMini.tsx b/skillos-frontend/src/components/AgentRuntimeMini.tsx new file mode 100644 index 0000000..24d02fb --- /dev/null +++ b/skillos-frontend/src/components/AgentRuntimeMini.tsx @@ -0,0 +1,223 @@ +import { useEffect, useMemo, useState } from 'react' +import { Button, Empty, Tag, Timeline, Tooltip } from 'antd' +import { ClearOutlined, WifiOutlined } from '@ant-design/icons' +import { motion } from 'framer-motion' +import { executionApi } from '@/api/client' +import { useAppStore } from '@/store/appStore' + +const PHASE_LABELS: Record = { + understand_task: 'Understanding', + read_graph_context: 'Graph Context', + select_skills: 'Selecting Skills', + execute_plan: 'Executing', + finish_execution: 'Finished', + idle: 'Idle', +} + +export default function AgentRuntimeMini({ collapsed = false }: { collapsed?: boolean }) { + const [polledEvents, setPolledEvents] = useState<{ time: string; event: string; data: unknown }[]>([]) + const { wsEvents, clearWsEvents } = useAppStore() + + useEffect(() => { + let cancelled = false + const refreshActivity = () => { + executionApi.activity() + .then(events => { + if (!cancelled) setPolledEvents(events) + }) + .catch(() => { + if (!cancelled) setPolledEvents([]) + }) + } + refreshActivity() + const timer = setInterval(refreshActivity, 1000) + return () => { + cancelled = true + clearInterval(timer) + } + }, []) + + const allEvents = useMemo( + () => [...wsEvents, ...polledEvents].filter(e => e.event !== 'pong' && e.event !== 'connected'), + [wsEvents, polledEvents], + ) + const agentEvents = allEvents.filter(e => e.event === 'agent_activity') + const currentAgentData = (agentEvents[0]?.data || {}) as Record + const phase = String(currentAgentData.phase || 'idle') + const message = String(currentAgentData.message || 'Waiting for task') + const selected = Array.isArray(currentAgentData.selected) + ? currentAgentData.selected as string[] + : Array.isArray(currentAgentData.steps) + ? currentAgentData.steps as string[] + : [] + const nodes = Array.isArray(currentAgentData.nodes) ? currentAgentData.nodes as string[] : [] + const active = phase !== 'idle' && phase !== 'finish_execution' + + if (collapsed) { + return ( + +
+ +
+ AI +
+
+
+
+ ) + } + + return ( +
+
+
+ {[0, 1].map(i => ( + + ))} + +
+ AI +
+
+
+
+ + {PHASE_LABELS[phase] || phase} + +
+ {message.length > 64 ? `${message.slice(0, 64)}...` : message} +
+
+
+ +
+ {selected.slice(0, 2).map(item => ( + + {item} + + ))} + {nodes.slice(0, 2).map(item => ( + + {item} + + ))} + {selected.length === 0 && nodes.length === 0 && No active context} +
+ +
+ {allEvents.length === 0 ? ( + No events} /> + ) : ( + ({ + color: e.event.includes('error') || e.event.includes('fail') ? 'red' : e.event.includes('complete') ? 'green' : 'blue', + children: ( +
+ {e.event} + {e.time} +
+ ), + }))} + /> + )} +
+ + +
+ ) +} diff --git a/skillos-frontend/src/components/AppLayout.tsx b/skillos-frontend/src/components/AppLayout.tsx index c0662dc..ec06af2 100644 --- a/skillos-frontend/src/components/AppLayout.tsx +++ b/skillos-frontend/src/components/AppLayout.tsx @@ -1,63 +1,31 @@ import { useState } from 'react' -import { Layout, Menu, Switch, Badge, Tooltip, Avatar, Tag } from 'antd' +import { Layout, Menu, Switch, Badge, Tooltip, Avatar } from 'antd' import { DashboardOutlined, BookOutlined, - ApartmentOutlined, PlayCircleOutlined, - SyncOutlined, ExperimentOutlined, CloudUploadOutlined, - BranchesOutlined, BulbOutlined, BulbFilled, WifiOutlined, - RocketOutlined, + ControlOutlined, } from '@ant-design/icons' import { useNavigate, useLocation } from 'react-router-dom' import { motion } from 'framer-motion' import { useAppStore } from '@/store/appStore' import { useWebSocket } from '@/hooks/useWebSocket' +import AgentRuntimeMini from './AgentRuntimeMini' const { Header, Sider, Content } = Layout const menuItems = [ - { - type: 'group' as const, - label: 'Overview', - children: [ - { key: '/', icon: , label: 'Dashboard' }, - ], - }, - { - type: 'group' as const, - label: 'Skill Management', - children: [ - { key: '/wiki', icon: , label: 'Skill Wiki' }, - { key: '/graph', icon: , label: 'Knowledge Graph' }, - { key: '/versions', icon: , label: 'Version Control' }, - { key: '/lifecycle', icon: , label: 'Lifecycle' }, - ], - }, - { - type: 'group' as const, - label: 'Agent & Evolution', - children: [ - { - key: '/demo', - icon: , - label: ( - - Self-Evolution Demo - DEMO - - ), - }, - { key: '/execution', icon: , label: 'Agent Execution' }, - { key: '/evolution', icon: , label: 'Evolution' }, - { key: '/ingest', icon: , label: 'Knowledge Import' }, - ], - }, + { key: '/', icon: , label: 'Dashboard' }, + { key: '/ingest', icon: , label: 'Knowledge Import' }, + { key: '/wiki', icon: , label: 'Skill Wiki' }, + { key: '/manage', icon: , label: 'Skill Manage' }, + { key: '/evaluation', icon: , label: 'Evaluation' }, + { key: '/execution', icon: , label: 'Execution' }, ] export default function AppLayout({ children }: { children: React.ReactNode }) { @@ -118,6 +86,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { onClick={({ key }) => navigate(key)} style={{ borderRight: 0, marginTop: 8 }} /> + diff --git a/skillos-frontend/src/hooks/useWebSocket.ts b/skillos-frontend/src/hooks/useWebSocket.ts index 2ef430e..ad8b9d2 100644 --- a/skillos-frontend/src/hooks/useWebSocket.ts +++ b/skillos-frontend/src/hooks/useWebSocket.ts @@ -6,28 +6,46 @@ export function useWebSocket() { const pushWsEvent = useAppStore(s => s.pushWsEvent) useEffect(() => { + let closedByEffect = false + let reconnectTimer: ReturnType | null = null const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' - const ws = new WebSocket(`${protocol}://${window.location.host}/ws`) - wsRef.current = ws - - ws.onmessage = (e) => { - try { - const msg = JSON.parse(e.data) - pushWsEvent(msg.event, msg.data) - } catch { - // ignore + const connect = () => { + const ws = new WebSocket(`${protocol}://${window.location.host}/ws`) + wsRef.current = ws + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + pushWsEvent(msg.event, msg.data) + } catch { + // ignore + } + } + + ws.onclose = () => { + if (!closedByEffect) { + reconnectTimer = setTimeout(connect, 1500) + } + } + + ws.onerror = () => { + ws.close() } } + connect() const ping = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { + const ws = wsRef.current + if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })) } }, 30_000) return () => { + closedByEffect = true clearInterval(ping) - ws.close() + if (reconnectTimer) clearTimeout(reconnectTimer) + wsRef.current?.close() } }, [pushWsEvent]) diff --git a/skillos-frontend/src/main.tsx b/skillos-frontend/src/main.tsx index ccf8b22..5dec95c 100644 --- a/skillos-frontend/src/main.tsx +++ b/skillos-frontend/src/main.tsx @@ -7,13 +7,10 @@ import { useAppStore } from '@/store/appStore' import AppLayout from '@/components/AppLayout' import Dashboard from '@/pages/Dashboard' import SkillWiki from '@/pages/SkillWiki' -import SkillGraph from '@/pages/SkillGraph' import AgentExecution from '@/pages/AgentExecution' -import Evolution from '@/pages/Evolution' -import LifecycleDemo from '@/pages/LifecycleDemo' import KnowledgeImport from '@/pages/KnowledgeImport' -import VersionControl from '@/pages/VersionControl' -import SelfEvolutionDemo from '@/pages/SelfEvolutionDemo' +import SkillManage from '@/pages/SkillManage' +import Evaluation from '@/pages/Evaluation' import './index.css' function App() { @@ -35,14 +32,11 @@ function App() { } /> + } /> } /> - } /> + } /> + } /> } /> - } /> - } /> - } /> - } /> - } /> diff --git a/skillos-frontend/src/pages/AgentExecution.tsx b/skillos-frontend/src/pages/AgentExecution.tsx index a2ac3a1..02b748c 100644 --- a/skillos-frontend/src/pages/AgentExecution.tsx +++ b/skillos-frontend/src/pages/AgentExecution.tsx @@ -1,11 +1,12 @@ import { useState } from 'react' import { Card, Input, Button, Tag, Alert, Spin, Divider, - Typography, Space, Badge, Statistic, Row, Col, Progress, Tooltip, + Typography, Space, Statistic, Row, Col, Progress, Tooltip, Collapse, Timeline, } from 'antd' import { PlayCircleOutlined, ThunderboltOutlined, CheckCircleOutlined, CloseCircleOutlined, SearchOutlined, DatabaseOutlined, + BranchesOutlined, NodeIndexOutlined, } from '@ant-design/icons' import { motion, AnimatePresence } from 'framer-motion' import { executionApi } from '@/api/client' @@ -26,8 +27,215 @@ const SKILL_TYPE_COLOR: Record = { atomic: '#1677ff', functional: '#722ed1', strategic: '#faad14', } +const NODE_TYPE_COLOR: Record = { + host_information: 'geekblue', + document: 'green', + trajectory: 'red', + task: 'orange', + tool: 'cyan', + api_doc: 'purple', + script: 'gold', + agent: 'lime', +} + +const TRACE_LABEL: Record = { + interpret_without_graph_or_skills: 'Task-only interpretation', + read_graph_context: 'Graph / host information retrieval', + decompose_task_layers: 'Three-layer decomposition', + retrieve_and_judge_skill_candidates: 'Skill retrieval and grounded judgment', + predict_expected_outcome: 'Expected outcome prediction', + build_execution_plan: 'Execution plan generation', + bind_step_inputs: 'Runtime input binding', + configure_observation_loop: 'Observation loop configuration', + observe_runtime_steps: 'Runtime step observations', + browser_observe_decide_act_loop: 'Browser observe-decide-act loop', + validate_expected_outcome: 'Outcome validation', + retry_after_mismatch: 'Mismatch repair retry', + execute_on_host_runtime: 'Host runtime execution', + reflect_and_update_skill_memory: 'Execution learning', +} + +const DEFAULT_EXECUTION_CONTEXT = { + username: 'demo@example.com', + password: 'pass123', + form_data: { + email: 'demo@example.com', + password: 'pass123', + }, +} + +function findTrace(result: ExecutionResult | null, action: string) { + return result?.agent_trace?.find(trace => trace.action === action) +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {} +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function traceStatusColor(status: string) { + if (['success', 'completed', 'created', 'reused'].includes(status)) return 'green' + if (['partial', 'empty', 'skipped', 'mismatch'].includes(status)) return 'gold' + if (['failed', 'error'].includes(status)) return 'red' + return 'blue' +} + +function JsonBlock({ value, maxHeight = 280 }: { value: unknown; maxHeight?: number }) { + return ( +
+      {JSON.stringify(value, null, 2)}
+    
+ ) +} + +function getTrace(result: ExecutionResult | null, action: string) { + return result?.agent_trace?.find(trace => trace.action === action) +} + +function getHostInformationUsed(result: ExecutionResult | null) { + const all: Record> = {} + for (const trace of result?.agent_trace || []) { + const details = asRecord(trace.details) + for (const item of asArray(details.host_information_used)) { + const node = asRecord(item) + const id = String(node.id || node.name || Math.random()) + all[id] = node + } + for (const item of asArray(details.graph_context)) { + const node = asRecord(item) + if (node.node_type === 'host_information') { + const id = String(node.id || node.name || Math.random()) + all[id] = node + } + } + } + return Object.values(all) +} + +function getBrowserLoopTrace(result: ExecutionResult | null) { + return result?.agent_trace?.find(trace => trace.action === 'browser_observe_decide_act_loop') || null +} + +function compactValue(value: unknown, max = 90) { + if (value === undefined || value === null || value === '') return '' + if (typeof value === 'object') return JSON.stringify(value).slice(0, max) + return String(value).slice(0, max) +} + +function BrowserLoopPanel({ result }: { result: ExecutionResult | null }) { + const browserTrace = getBrowserLoopTrace(result) + const details = asRecord(browserTrace?.details) + const finalState = asRecord(result?.final_state) + const observations = asArray(details.observations || finalState.observations) + const actions = asArray(details.actions || finalState.actions) + if (!browserTrace && observations.length === 0 && actions.length === 0) return null + + return ( + Browser Observation Loop} + bordered={false} + size="small" + style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }} + > + + + + + + + + + + query: {compactValue(details.query || finalState.query, 80)} + entry: {compactValue(details.entry_url || finalState.entry_url, 120)} + {details.success ? 'success' : 'not finished'} + + + + + { + const action = asRecord(item) + return { + color: action.status === 'success' ? 'green' : action.status === 'blocked' ? 'red' : 'blue', + children: ( +
+ + round {String(action.round ?? index)} + {String(action.action || 'action')} + +
{String(action.reason || '')}
+ {compactValue(action.target, 140)} +
+ ), + } + })} + /> +
+ + + + { + const obs = asRecord(item) + return { + key: `browser-loop-${index}`, + label: `round ${String(obs.round ?? index)} · ${String(obs.observation_type || obs.type || 'observation')}`, + children: , + } + })} + /> + + +
+
+
+ ) +} + +function observationColor(type: string) { + if (type === 'filesystem') return 'gold' + if (type === 'terminal') return 'green' + if (type === 'browser') return 'blue' + if (type === 'application') return 'purple' + if (type === 'runtime') return 'default' + return 'cyan' +} + +function getExecutionContextForGoal(goalText: string) { + const normalized = goalText.toLowerCase() + const needsFormFixture = normalized.includes('login') + || normalized.includes('form') + || goalText.includes('登录') + || goalText.includes('表单') + return needsFormFixture ? DEFAULT_EXECUTION_CONTEXT : {} +} + function StepCard({ step, index }: { step: ExecutionStepResult; index: number }) { const [expanded, setExpanded] = useState(false) + const observations = step.observations || [] + const judgment = step.step_judgment || {} return ( {step.status} + {observations.length > 0 && obs {observations.length}} {step.latency_ms.toFixed(0)}ms {expanded && (
{step.error && } + {Object.keys(judgment).length > 0 && ( + + )} + {observations.length > 0 && ( + { + const packetRecord = asRecord(packet) + const packetObservations = asArray(packetRecord.observations) + return { + key: `${step.step_id}-obs-${packetIndex}`, + label: `${String(packetRecord.phase || 'observation')} observations (${packetObservations.length})`, + children: ( + + {packetObservations.map((item, obsIndex) => { + const obs = asRecord(item) + return ( + + + {String(obs.type || 'observation')} + {String(obs.source || 'ObservationProvider')} + {compactValue(obs.target, 120)} + + + + ) + })} + + ), + } + })} + /> + )} {Object.keys(step.outputs).length > 0 && (
                 {JSON.stringify(step.outputs, null, 2)}
@@ -84,7 +333,7 @@ export default function AgentExecution() {
     setError(null)
     setResult(null)
     try {
-      const res = await executionApi.executePlan(goal)
+      const res = await executionApi.executePlan(goal, getExecutionContextForGoal(goal))
       setResult(res)
     } catch (e: unknown) {
       const err = e as { response?: { data?: { detail?: string; error?: string } }; message?: string }
@@ -137,6 +386,12 @@ export default function AgentExecution() {
             '点击页面上的提交按钮',
             '填写登录表单并提交',
             '在搜索框中输入关键词并搜索',
+            'Please open the Chrome browser for me.',
+            'Open Chrome and go to the GPT conversation page.',
+            'Open GPT, ask today weather, and save the answer to Downloads.',
+            'Open my Downloads folder in Finder.',
+            '打开下载目录里的 abc.json 文件',
+            '打开终端执行 top 命令查看不同进程的实时动态,持续 10 秒',
           ].map(ex => (
             
             
 
+            {result.agent_trace && result.agent_trace.length > 0 && (
+              Full Agent Execution Trace}
+                bordered={false}
+                size="small"
+                style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}
+              >
+                {(() => {
+                  const retrieve = getTrace(result, 'retrieve_and_judge_skill_candidates')
+                  const graphTrace = getTrace(result, 'read_graph_context')
+                  const decompositionTrace = findTrace(result, 'decompose_task_layers')
+                  const planTrace = findTrace(result, 'build_execution_plan')
+                  const bindTrace = getTrace(result, 'bind_step_inputs')
+                  const validationTrace = getTrace(result, 'validate_expected_outcome')
+                  const details = asRecord(retrieve?.details)
+                  const graphDetails = asRecord(graphTrace?.details)
+                  const decompositionDetails = asRecord(decompositionTrace?.details)
+                  const planDetails = asRecord(planTrace?.details)
+                  const bindDetails = asRecord(bindTrace?.details)
+                  const validationDetails = asRecord(validationTrace?.details)
+                  const inferred = asRecord(details.inferred_context)
+                  const graphContext = asArray(graphDetails.graph_context || details.graph_context).slice(0, 8)
+                  const hostInfo = getHostInformationUsed(result)
+                  const layers = asArray(decompositionDetails.layers)
+                  const groundedDecision = asRecord(details.grounded_decision)
+                  const rejected = asArray(groundedDecision.rejected_skills)
+                  const candidates = asArray(details.candidate_skills)
+                  return (
+                    
+ + {layers.length > 0 && ( + + + {layers.map((layer, index) => { + const item = asRecord(layer) + const matched = asArray(item.matched_skills) + return ( + +
+ + {String(item.layer)} · {String(item.expected_skill_type)} + +
{String(item.intent)}
+ {String(item.description)} +
+ {matched.length === 0 ? ( + agent fallback + ) : matched.slice(0, 3).map(skill => ( + {String(skill)} + ))} +
+
+ + ) + })} +
+
+ )} + + + + {Object.keys(inferred).length === 0 ? ( + No inferred parameters. + ) : ( + + {Object.entries(inferred).map(([key, value]) => ( + {key}: {String(value).slice(0, 60)} + ))} + + )} + + + + + + {graphContext.length === 0 && No graph evidence.} + {graphContext.map((node, index) => { + const item = asRecord(node) + return ( +
+ {String(item.node_type || 'node')} + {String(item.name || item.id)} +
+ ) + })} +
+
+ + + + + step count: {String(planDetails.step_count || result.steps.length)} + {result.steps.map(step => ( + {step.skill_name} + ))} + + + +
+ Host Information Used} + style={{ marginTop: 12 }} + > + {hostInfo.length === 0 ? ( + No host information nodes were used for this execution. + ) : ( + + {hostInfo.map((node, index) => ( + +
+ + host_information + {String(node.name || node.id)} + +
+ + {String(node.description || '').slice(0, 180)} + +
+
+ {asArray(node.labels).slice(0, 5).map(label => {String(label)})} +
+ {Boolean(node.command) && ( + {compactValue(node.command, 120)} + )} +
+ + ))} +
+ )} +
+ + + + Skill Candidate Judgment} style={{ height: '100%' }}> + + action: {String(groundedDecision.skill_action || 'unknown')} + coverage: {compactValue(asRecord(groundedDecision.coverage).coverage_score || 'n/a')} +
+ Selected +
+ {asArray(details.selected).length === 0 + ? none + : asArray(details.selected).map(name => {String(name)})} +
+
+
+ Rejected +
+ {rejected.length === 0 + ? none + : rejected.slice(0, 6).map((item, i) => { + const reject = asRecord(item) + return {String(reject.name || item)} + })} +
+
+ , + }]} + /> +
+
+ + + Plan, Inputs, Validation} style={{ height: '100%' }}> + + step count: {String(planDetails.step_count || result.steps.length)} + validation: {String(validationTrace?.status || 'not available')} + + {result.steps.map(step => {step.skill_name})} + + }, + { key: 'validation', label: 'Expected-vs-actual validation', children: }, + ]} + /> + + + +
+
+ ) + })()} + + ({ + color: traceStatusColor(trace.status), + children: ( +
+ + {trace.status} + {TRACE_LABEL[trace.action] || trace.action.replace(/_/g, ' ')} + {trace.agent} + +
+ , + }]} + /> +
+
+ ), + }))} + /> +
+ )} + + + {/* 执行步骤 */} 执行步骤} diff --git a/skillos-frontend/src/pages/Dashboard.tsx b/skillos-frontend/src/pages/Dashboard.tsx index c9242a6..565a9a8 100644 --- a/skillos-frontend/src/pages/Dashboard.tsx +++ b/skillos-frontend/src/pages/Dashboard.tsx @@ -1,12 +1,10 @@ import { useEffect, useState } from 'react' -import { Card, Col, Row, Statistic, Progress, Tag, Table, Badge, Spin, Timeline, Button, Empty, Divider } from 'antd' +import { Card, Col, Row, Statistic, Progress, Tag, Badge, Spin, Divider, Table, Segmented } from 'antd' import { RocketOutlined, CheckCircleOutlined, WarningOutlined, ThunderboltOutlined, - ClearOutlined, - WifiOutlined, StarOutlined, SyncOutlined, BulbOutlined, @@ -14,8 +12,7 @@ import { import { motion } from 'framer-motion' import { statsApi, evolutionApi } from '@/api/client' import type { EvolutionStats } from '@/api/client' -import { useAppStore } from '@/store/appStore' -import type { OverviewStats, HealthReport } from '@/api/types' +import type { HealthReport, OverviewStats, SkillVisibility } from '@/api/types' const stateColors: Record = { S4: 'green', S2: 'blue', S3: 'cyan', S1: 'orange', @@ -37,29 +34,45 @@ const healthColors: Record = { export default function Dashboard() { const [stats, setStats] = useState(null) const [reports, setReports] = useState([]) + const [healthVisibility, setHealthVisibility] = useState('user') const [evoStats, setEvoStats] = useState(null) - const [loading, setLoading] = useState(true) - const { wsEvents, clearWsEvents } = useAppStore() + const [statsLoading, setStatsLoading] = useState(true) + const [healthLoading, setHealthLoading] = useState(true) + const [evoLoading, setEvoLoading] = useState(true) useEffect(() => { - Promise.all([ - statsApi.overview(), - evolutionApi.systemHealth(), - statsApi.evolutionStats().catch(() => null), - ]) - .then(([s, h, evo]) => { - setStats(s) - setReports(h.skill_reports.slice(0, 10)) - if (evo) setEvoStats(evo) - }) - .finally(() => setLoading(false)) + setStatsLoading(true) + statsApi.overview() + .then(setStats) + .finally(() => setStatsLoading(false)) }, []) - if (loading) return
- if (!stats) return null + useEffect(() => { + setHealthLoading(true) + evolutionApi.systemHealth({ visibility: healthVisibility }) + .then(h => setReports(h.skill_reports.slice(0, 6))) + .finally(() => setHealthLoading(false)) + }, [healthVisibility]) + + useEffect(() => { + setEvoLoading(true) + statsApi.evolutionStats() + .then(setEvoStats) + .catch(() => setEvoStats(null)) + .finally(() => setEvoLoading(false)) + }, []) - const healthyPct = stats.total_skills > 0 - ? Math.round((stats.by_state['S4'] || 0) / stats.total_skills * 100) + const emptyStats: OverviewStats = { + total_skills: 0, + by_state: {}, + by_type: {}, + total_executions: 0, + avg_success_rate: 1, + graph_stats: {}, + } + const displayStats = stats || emptyStats + const healthyPct = displayStats.total_skills > 0 + ? Math.round((displayStats.by_state['S4'] || 0) / displayStats.total_skills * 100) : 0 const cardVariants = { @@ -80,10 +93,10 @@ export default function Dashboard() { {/* 核心指标 */} {[ - { title: 'Total Skills', value: stats.total_skills, icon: , color: '#1677ff', i: 0 }, - { title: 'Released', value: stats.by_state['S4'] || 0, icon: , color: '#52c41a', i: 1 }, - { title: 'Degraded', value: stats.by_state['S5'] || 0, icon: , color: '#faad14', i: 2 }, - { title: 'Total Executions', value: stats.total_executions, icon: , color: '#722ed1', i: 3 }, + { title: 'Total Skills', value: displayStats.total_skills, icon: , color: '#1677ff', i: 0 }, + { title: 'Released', value: displayStats.by_state['S4'] || 0, icon: , color: '#52c41a', i: 1 }, + { title: 'Degraded', value: displayStats.by_state['S5'] || 0, icon: , color: '#faad14', i: 2 }, + { title: 'Total Executions', value: displayStats.total_executions, icon: , color: '#722ed1', i: 3 }, ].map(({ title, value, icon, color, i }) => ( @@ -91,6 +104,7 @@ export default function Dashboard() { {icon}} valueStyle={{ color, fontWeight: 700 }} /> @@ -105,7 +119,11 @@ export default function Dashboard() { - {Object.entries(stats.by_type).map(([type, count]) => ( + + {Object.entries(displayStats.by_type).length === 0 && !statsLoading && ( +
No type data yet.
+ )} + {Object.entries(displayStats.by_type).map(([type, count]) => (
@@ -114,13 +132,14 @@ export default function Dashboard() { {count}
))} +
@@ -129,12 +148,17 @@ export default function Dashboard() { - {Object.entries(stats.by_state).map(([state, count]) => ( + + {Object.entries(displayStats.by_state).length === 0 && !statsLoading && ( +
No state data yet.
+ )} + {Object.entries(displayStats.by_state).map(([state, count]) => (
{stateLabels[state] || state}
))} +
@@ -153,20 +177,37 @@ export default function Dashboard() {
0.8 ? '#52c41a' : '#faad14' }} + loading={statsLoading} + valueStyle={{ color: displayStats.avg_success_rate > 0.8 ? '#52c41a' : '#faad14' }} />
- {/* 健康报告表格 */} - + setHealthVisibility(value as SkillVisibility | 'all')} + options={[ + { label: 'User', value: 'user' }, + { label: 'Kernel', value: 'kernel' }, + { label: 'All', value: 'all' }, + ]} + /> + } + > ( - - ), + render: (s: string) => , }, { title: 'Success Rate', @@ -194,21 +233,17 @@ export default function Dashboard() { /> ), }, - { - title: 'Executions', - dataIndex: 'usage_count', - }, - { - title: 'Avg Latency', - dataIndex: 'avg_latency_ms', - render: (ms: number) => `${ms.toFixed(0)}ms`, - }, + { title: 'Executions', dataIndex: 'usage_count' }, ]} /> +
+ Full review reports and real runtime evaluation live in the Evaluation page. +
{/* 演化指标面板 */} + {evoStats && ( )} + - {/* Agent 动态 Feed */} - - Agent 实时动态} - bordered={false} - style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }} - extra={ - - } - > - {wsEvents.filter(e => e.event !== 'pong' && e.event !== 'connected').length === 0 ? ( - - ) : ( -
- e.event !== 'pong' && e.event !== 'connected') - .slice(0, 30) - .map(e => { - const isError = e.event.includes('error') || e.event.includes('fail') - const isSuccess = e.event.includes('success') || e.event.includes('complete') || e.event.includes('release') - return { - color: isError ? 'red' : isSuccess ? 'green' : 'blue', - children: ( -
-
- - {e.event} - - {e.time} -
- {e.data !== null && e.data !== undefined && typeof e.data === 'object' && ( -
- {JSON.stringify(e.data as Record).slice(0, 120)} -
- )} -
- ), - } - })} - /> -
- )} -
-
) } diff --git a/skillos-frontend/src/pages/Evaluation.tsx b/skillos-frontend/src/pages/Evaluation.tsx new file mode 100644 index 0000000..1b40e28 --- /dev/null +++ b/skillos-frontend/src/pages/Evaluation.tsx @@ -0,0 +1,291 @@ +import { useEffect, useState } from 'react' +import { + Alert, Badge, Button, Card, Col, Input, List, message, Progress, Row, Space, Statistic, Table, Tag, Typography, + Segmented, +} from 'antd' +import { BarChartOutlined, PlayCircleOutlined, ReloadOutlined, ToolOutlined } from '@ant-design/icons' +import { motion } from 'framer-motion' +import { evolutionApi, executionApi, skillsApi } from '@/api/client' +import type { ExecutionResult, HealthReport, SkillVisibility, SystemHealth } from '@/api/types' + +const { Text, Paragraph } = Typography +const { TextArea } = Input + +const STATUS_COLOR: Record = { + healthy: '#52c41a', + degraded: '#faad14', + critical: '#ff4d4f', + stale: '#8c8c8c', + unknown: '#bfbfbf', +} + +export default function Evaluation() { + const [health, setHealth] = useState(null) + const [visibility, setVisibility] = useState('user') + const [visibilityCounts, setVisibilityCounts] = useState({ user: 0, kernel: 0 }) + const [evalGoal, setEvalGoal] = useState('Open Chrome and navigate to the URL resolved from the user task.') + const [runtimeResult, setRuntimeResult] = useState(null) + const [loading, setLoading] = useState(false) + const [runningEval, setRunningEval] = useState(false) + const [improving, setImproving] = useState(null) + + const load = async () => { + setLoading(true) + try { + const [nextHealth, userSkills, kernelSkills] = await Promise.all([ + evolutionApi.systemHealth({ visibility }), + skillsApi.list({ limit: 1000, visibility: 'user' }), + skillsApi.list({ limit: 1000, visibility: 'kernel' }), + ]) + setHealth(nextHealth) + setVisibilityCounts({ user: userSkills.length, kernel: kernelSkills.length }) + } finally { + setLoading(false) + } + } + + useEffect(() => { load() }, [visibility]) + + const improve = async (skillId: string) => { + setImproving(skillId) + try { + const result = await evolutionApi.improve(skillId) as { action?: string; reason?: string } + message.success(`${result.action || 'evaluated'}: ${result.reason || 'Agent completed evaluation improvement.'}`) + await load() + } catch (error) { + message.error('Agent improvement failed') + } finally { + setImproving(null) + } + } + + const runRuntimeEvaluation = async () => { + if (!evalGoal.trim()) { + message.warning('Please enter a real evaluation task.') + return + } + setRunningEval(true) + setRuntimeResult(null) + try { + const result = await executionApi.executePlan(evalGoal, { evaluation_mode: true }) + setRuntimeResult(result) + message.success(`Runtime evaluation finished: ${result.status}`) + await load() + } catch { + message.error('Runtime evaluation failed') + } finally { + setRunningEval(false) + } + } + + const reports = health?.skill_reports || [] + + return ( +
+ +
+
+

Evaluation

+

+ Skill health, execution quality, and readiness signals collected from the repository and runtime records. +

+
+ + setVisibility(value as SkillVisibility | 'all')} + options={[ + { label: 'User Skills', value: 'user' }, + { label: 'Kernel Skills', value: 'kernel' }, + { label: 'All', value: 'all' }, + ]} + /> + + +
+
+ + + {[ + { label: 'Total Skills', value: health?.total_skills || 0, color: '#1677ff' }, + { label: 'Healthy', value: health?.healthy_count || 0, color: '#52c41a' }, + { label: 'Degraded', value: health?.degraded_count || 0, color: '#faad14' }, + { label: 'Critical', value: health?.critical_count || 0, color: '#ff4d4f' }, + { label: 'Stale', value: health?.stale_count || 0, color: '#8c8c8c' }, + ].map(item => ( +
+ + + + + ))} + + + +
Health Ratio
+
+ + + + + + + + Visible product capabilities shown to users. + + + + + + Internal governance tools for merge, update, review, and graph maintenance. + + + + + Real Runtime Evaluation} + bordered={false} + style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }} + > + +