ltcai 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/docs/CHANGELOG.md +61 -0
- package/llm_router.py +13 -0
- package/package.json +1 -1
- package/server.py +638 -62
- package/static/chat.html +266 -2
- package/telegram_bot.py +185 -17
package/server.py
CHANGED
|
@@ -104,7 +104,8 @@ try:
|
|
|
104
104
|
except Exception:
|
|
105
105
|
keyring = None
|
|
106
106
|
|
|
107
|
-
from datetime import datetime
|
|
107
|
+
from datetime import datetime, timedelta
|
|
108
|
+
import httpx
|
|
108
109
|
|
|
109
110
|
def detect_language(text: str) -> str:
|
|
110
111
|
"""Detect language: 'ko' (Korean) or 'en' (English)."""
|
|
@@ -243,7 +244,9 @@ _SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
|
|
|
243
244
|
_sessions_lock = threading.Lock()
|
|
244
245
|
|
|
245
246
|
def _sessions_file() -> Path:
|
|
246
|
-
|
|
247
|
+
data_dir = Path(os.getenv("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
|
|
248
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
return data_dir / "sessions.json"
|
|
247
250
|
|
|
248
251
|
def _load_sessions() -> Dict[str, tuple]:
|
|
249
252
|
try:
|
|
@@ -369,6 +372,10 @@ class McpRecommendRequest(BaseModel):
|
|
|
369
372
|
class McpInstallRequest(BaseModel):
|
|
370
373
|
mcp_id: str
|
|
371
374
|
|
|
375
|
+
class SkillInstallRequest(BaseModel):
|
|
376
|
+
plugin: str
|
|
377
|
+
skill: str
|
|
378
|
+
|
|
372
379
|
class KnowledgeGraphIngestRequest(BaseModel):
|
|
373
380
|
type: str
|
|
374
381
|
content: str = ""
|
|
@@ -653,6 +660,341 @@ MCP_REGISTRY = [
|
|
|
653
660
|
},
|
|
654
661
|
]
|
|
655
662
|
|
|
663
|
+
# ── Remote MCP Registry (registry.modelcontextprotocol.io) ───────────────────
|
|
664
|
+
_REMOTE_REGISTRY_CACHE: List[Dict] = []
|
|
665
|
+
_REMOTE_REGISTRY_FETCHED_AT: Optional[datetime] = None
|
|
666
|
+
_REMOTE_REGISTRY_TTL = timedelta(hours=1)
|
|
667
|
+
_REMOTE_REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0/servers"
|
|
668
|
+
_LOCAL_IDS = {e["id"] for e in MCP_REGISTRY}
|
|
669
|
+
|
|
670
|
+
async def _fetch_remote_mcp_registry() -> List[Dict]:
|
|
671
|
+
global _REMOTE_REGISTRY_CACHE, _REMOTE_REGISTRY_FETCHED_AT
|
|
672
|
+
now = datetime.now()
|
|
673
|
+
if _REMOTE_REGISTRY_FETCHED_AT and (now - _REMOTE_REGISTRY_FETCHED_AT) < _REMOTE_REGISTRY_TTL:
|
|
674
|
+
return _REMOTE_REGISTRY_CACHE
|
|
675
|
+
try:
|
|
676
|
+
result: List[Dict] = []
|
|
677
|
+
cursor = None
|
|
678
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
679
|
+
while True:
|
|
680
|
+
params: Dict = {"limit": 100}
|
|
681
|
+
if cursor:
|
|
682
|
+
params["cursor"] = cursor
|
|
683
|
+
resp = await client.get(_REMOTE_REGISTRY_URL, params=params)
|
|
684
|
+
resp.raise_for_status()
|
|
685
|
+
data = resp.json()
|
|
686
|
+
for s in data.get("servers", []):
|
|
687
|
+
srv = s["server"]
|
|
688
|
+
meta = s.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {})
|
|
689
|
+
if not meta.get("isLatest", True):
|
|
690
|
+
continue
|
|
691
|
+
pkg = next(
|
|
692
|
+
(p for p in srv.get("packages", [])
|
|
693
|
+
if p.get("transport", {}).get("type") == "stdio"
|
|
694
|
+
and p.get("registryType") in ("npm", "pypi")),
|
|
695
|
+
None,
|
|
696
|
+
)
|
|
697
|
+
if not pkg:
|
|
698
|
+
continue
|
|
699
|
+
entry_id = srv["name"].replace("/", "-").replace(".", "-")
|
|
700
|
+
if entry_id in _LOCAL_IDS:
|
|
701
|
+
continue
|
|
702
|
+
result.append({
|
|
703
|
+
"id": entry_id,
|
|
704
|
+
"name": srv.get("title") or srv["name"],
|
|
705
|
+
"category": "MCP Registry",
|
|
706
|
+
"install_mode": pkg["registryType"],
|
|
707
|
+
"package": pkg["identifier"],
|
|
708
|
+
"package_version": pkg.get("version"),
|
|
709
|
+
"description": srv.get("description", ""),
|
|
710
|
+
"keywords": [],
|
|
711
|
+
"capabilities": [],
|
|
712
|
+
"source": "registry",
|
|
713
|
+
"homepage": (srv.get("repository") or {}).get("url"),
|
|
714
|
+
})
|
|
715
|
+
cursor = data.get("nextCursor")
|
|
716
|
+
if not cursor:
|
|
717
|
+
break
|
|
718
|
+
_REMOTE_REGISTRY_CACHE = result
|
|
719
|
+
_REMOTE_REGISTRY_FETCHED_AT = now
|
|
720
|
+
logging.info("Fetched %d stdio MCP servers from remote registry", len(result))
|
|
721
|
+
except Exception as e:
|
|
722
|
+
logging.warning("Failed to fetch remote MCP registry: %s", e)
|
|
723
|
+
return _REMOTE_REGISTRY_CACHE
|
|
724
|
+
|
|
725
|
+
async def _get_combined_registry() -> List[Dict]:
|
|
726
|
+
remote = await _fetch_remote_mcp_registry()
|
|
727
|
+
return MCP_REGISTRY + remote
|
|
728
|
+
|
|
729
|
+
# ── Anthropic Skills Marketplace (Apache 2.0) ─────────────────────────────────
|
|
730
|
+
_MARKETPLACE_RAW = "https://raw.githubusercontent.com/anthropics/claude-plugins-official/main"
|
|
731
|
+
_MARKETPLACE_API = "https://api.github.com/repos/anthropics/claude-plugins-official/contents"
|
|
732
|
+
|
|
733
|
+
# 검증된 서드파티 skills 소스 (Apache-2.0 / MIT)
|
|
734
|
+
_THIRD_PARTY_SKILL_SOURCES: List[Dict] = [
|
|
735
|
+
{
|
|
736
|
+
"plugin": "adobe-for-creativity", "author": "Adobe", "license": "Apache-2.0",
|
|
737
|
+
"repo": "adobe/skills", "branch": "main",
|
|
738
|
+
"plugin_path": "plugins/creative-cloud/adobe-for-creativity",
|
|
739
|
+
"category": "design",
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
"plugin": "airtable", "author": "Airtable", "license": "MIT",
|
|
743
|
+
"repo": "Airtable/skills", "branch": "main",
|
|
744
|
+
"plugin_path": "plugins/airtable",
|
|
745
|
+
"category": "productivity",
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
"plugin": "auth0", "author": "Auth0", "license": "Apache-2.0",
|
|
749
|
+
"repo": "auth0/agent-skills", "branch": "main",
|
|
750
|
+
"plugin_path": "plugins/auth0",
|
|
751
|
+
"category": "security",
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
"plugin": "expo", "author": "Expo", "license": "MIT",
|
|
755
|
+
"repo": "expo/skills", "branch": "main",
|
|
756
|
+
"plugin_path": "plugins/expo",
|
|
757
|
+
"category": "development",
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
"plugin": "logfire", "author": "Pydantic", "license": "MIT",
|
|
761
|
+
"repo": "pydantic/skills", "branch": "main",
|
|
762
|
+
"plugin_path": "plugins/logfire",
|
|
763
|
+
"category": "monitoring",
|
|
764
|
+
},
|
|
765
|
+
]
|
|
766
|
+
|
|
767
|
+
# 검증된 레포 라이선스 맵 (GitHub API 없이 빠르게 조회)
|
|
768
|
+
_KNOWN_REPO_LICENSES: Dict[str, str] = {
|
|
769
|
+
# Apache-2.0
|
|
770
|
+
"adobe/skills": "Apache-2.0", "awslabs/agent-plugins": "Apache-2.0",
|
|
771
|
+
"auth0/agent-skills": "Apache-2.0", "aws/agent-toolkit-for-aws": "Apache-2.0",
|
|
772
|
+
"carta/plugins": "Apache-2.0", "circlefin/skills": "Apache-2.0",
|
|
773
|
+
"clickhouse/clickhouse-docs": "Apache-2.0", "cloudflare/agents": "Apache-2.0",
|
|
774
|
+
"cockroachdb/claude-code": "Apache-2.0", "codspeed-hq/codspeed-claude": "Apache-2.0",
|
|
775
|
+
"DataDog/datadog-claude-code": "Apache-2.0", "datahub-project/datahub-skills": "Apache-2.0",
|
|
776
|
+
"neondatabase/agent-skills": "Apache-2.0", "PagerDuty/pd-ai-agents-plugins": "Apache-2.0",
|
|
777
|
+
"getpostman/postman-mcp-server": "Apache-2.0", "qdrant/qdrant-skills": "Apache-2.0",
|
|
778
|
+
"rootlyhq/rootly-plugins": "Apache-2.0", "snowflake-labs/snowflake-claude": "Apache-2.0",
|
|
779
|
+
"sumup/sumup-claude": "Apache-2.0", "zilliz-labs/zilliz-skills": "Apache-2.0",
|
|
780
|
+
"mercadopago/mercadopago-claude-marketplace": "Apache-2.0",
|
|
781
|
+
# MIT
|
|
782
|
+
"Airtable/skills": "MIT", "endorlabs/ai-plugins": "MIT",
|
|
783
|
+
"apollographql/apollo-claude-skills": "MIT", "appwrite/skills": "MIT",
|
|
784
|
+
"atlan-inc/claude-code-skills": "MIT", "boxer/boxerbox": "MIT",
|
|
785
|
+
"buildkite/claude-code": "MIT", "coderabbitai/coderabbit-skills": "MIT",
|
|
786
|
+
"CrowdStrike/crowdstrike-skills": "MIT", "microsoft/Dataverse-skills": "MIT",
|
|
787
|
+
"duckdb/duckdb-skills": "MIT", "expo/skills": "MIT",
|
|
788
|
+
"intercom/intercom-skills": "MIT", "pydantic/skills": "MIT",
|
|
789
|
+
"mapbox/mapbox-skills": "MIT", "mintlify/mintlify-skills": "MIT",
|
|
790
|
+
"miroapp/miro-ai": "MIT", "netlify/netlify-skills": "MIT",
|
|
791
|
+
"pinecone-io/pinecone-skills": "MIT", "railwayapp/railway-skills": "MIT",
|
|
792
|
+
"resend/resend-skills": "MIT", "sanity-io/sanity-skills": "MIT",
|
|
793
|
+
"getsentry/sentry-ai-skills": "MIT", "Shopify/liquid-skills": "MIT",
|
|
794
|
+
"slackapi/slack-skills": "MIT", "stripe/stripe-skills": "MIT",
|
|
795
|
+
"twilio-labs/twilio-skills": "MIT", "workos/workos-skills": "MIT",
|
|
796
|
+
"zoom/zoom-skills": "MIT", "aws-samples/sample-claude-code-plugins-for-startups": "MIT-0",
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
_SKILLS_MARKETPLACE_CACHE: List[Dict] = []
|
|
800
|
+
_SKILLS_MARKETPLACE_FETCHED_AT: Optional[datetime] = None
|
|
801
|
+
_SKILLS_MARKETPLACE_TTL = timedelta(hours=1)
|
|
802
|
+
|
|
803
|
+
def _extract_skill_desc(skill_md: str, fallback: str) -> str:
|
|
804
|
+
for line in skill_md.splitlines():
|
|
805
|
+
if line.startswith("description:"):
|
|
806
|
+
return line.split(":", 1)[1].strip()
|
|
807
|
+
return fallback
|
|
808
|
+
|
|
809
|
+
async def _fetch_plugin_skills(client: httpx.AsyncClient, source: Dict) -> List[Dict]:
|
|
810
|
+
"""단일 소스에서 skill 목록을 fetch해 반환"""
|
|
811
|
+
repo, branch, plugin_path = source["repo"], source["branch"], source["plugin_path"]
|
|
812
|
+
raw_base = f"https://raw.githubusercontent.com/{repo}/{branch}"
|
|
813
|
+
api_base = f"https://api.github.com/repos/{repo}/contents"
|
|
814
|
+
homepage_base = f"https://github.com/{repo}/tree/{branch}"
|
|
815
|
+
|
|
816
|
+
dir_resp = await client.get(f"{api_base}/{plugin_path}/skills")
|
|
817
|
+
if dir_resp.status_code != 200:
|
|
818
|
+
return []
|
|
819
|
+
skill_dirs = [f["name"] for f in dir_resp.json() if f["type"] == "dir"]
|
|
820
|
+
|
|
821
|
+
skills: List[Dict] = []
|
|
822
|
+
for skill_name in skill_dirs:
|
|
823
|
+
skill_md_url = f"{raw_base}/{plugin_path}/skills/{skill_name}/SKILL.md"
|
|
824
|
+
sm_resp = await client.get(skill_md_url)
|
|
825
|
+
if sm_resp.status_code != 200:
|
|
826
|
+
continue
|
|
827
|
+
skills.append({
|
|
828
|
+
"plugin": source["plugin"],
|
|
829
|
+
"skill": skill_name,
|
|
830
|
+
"category": source.get("category", "development"),
|
|
831
|
+
"description": _extract_skill_desc(sm_resp.text, source.get("description", "")),
|
|
832
|
+
"skill_md_url": skill_md_url,
|
|
833
|
+
"homepage": f"{homepage_base}/{plugin_path}/skills/{skill_name}",
|
|
834
|
+
"license": source["license"],
|
|
835
|
+
"author": source["author"],
|
|
836
|
+
})
|
|
837
|
+
return skills
|
|
838
|
+
|
|
839
|
+
async def _fetch_skills_marketplace() -> List[Dict]:
|
|
840
|
+
global _SKILLS_MARKETPLACE_CACHE, _SKILLS_MARKETPLACE_FETCHED_AT
|
|
841
|
+
now = datetime.now()
|
|
842
|
+
if _SKILLS_MARKETPLACE_FETCHED_AT and (now - _SKILLS_MARKETPLACE_FETCHED_AT) < _SKILLS_MARKETPLACE_TTL:
|
|
843
|
+
return _SKILLS_MARKETPLACE_CACHE
|
|
844
|
+
try:
|
|
845
|
+
result: List[Dict] = []
|
|
846
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
847
|
+
# ── Anthropic 공식 skills (Apache-2.0) ──────────────────────────
|
|
848
|
+
mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
|
|
849
|
+
mp_resp.raise_for_status()
|
|
850
|
+
marketplace_json = mp_resp.json()
|
|
851
|
+
anthropic_plugins = [
|
|
852
|
+
p for p in marketplace_json.get("plugins", [])
|
|
853
|
+
if (p.get("author") or {}).get("name") == "Anthropic"
|
|
854
|
+
and isinstance(p.get("source"), str)
|
|
855
|
+
and p["source"].startswith("./")
|
|
856
|
+
]
|
|
857
|
+
for plugin in anthropic_plugins:
|
|
858
|
+
plugin_path = plugin["source"].lstrip("./")
|
|
859
|
+
result.extend(await _fetch_plugin_skills(client, {
|
|
860
|
+
"plugin": plugin["name"],
|
|
861
|
+
"author": "Anthropic",
|
|
862
|
+
"license": "Apache-2.0",
|
|
863
|
+
"repo": "anthropics/claude-plugins-official",
|
|
864
|
+
"branch": "main",
|
|
865
|
+
"plugin_path": plugin_path,
|
|
866
|
+
"category": plugin.get("category", "development"),
|
|
867
|
+
"description": plugin.get("description", ""),
|
|
868
|
+
}))
|
|
869
|
+
# ── 검증된 서드파티 skills ────────────────────────────────────────
|
|
870
|
+
for source in _THIRD_PARTY_SKILL_SOURCES:
|
|
871
|
+
result.extend(await _fetch_plugin_skills(client, source))
|
|
872
|
+
|
|
873
|
+
_SKILLS_MARKETPLACE_CACHE = result
|
|
874
|
+
_SKILLS_MARKETPLACE_FETCHED_AT = now
|
|
875
|
+
logging.info("Fetched %d skills from marketplace (%d sources)",
|
|
876
|
+
len(result), len(anthropic_plugins) + len(_THIRD_PARTY_SKILL_SOURCES))
|
|
877
|
+
except Exception as e:
|
|
878
|
+
logging.warning("Failed to fetch skills marketplace: %s", e)
|
|
879
|
+
return _SKILLS_MARKETPLACE_CACHE
|
|
880
|
+
|
|
881
|
+
# ── Plugin Directory ──────────────────────────────────────────────────────────
|
|
882
|
+
_PLUGIN_DIRECTORY_CACHE: List[Dict] = []
|
|
883
|
+
_PLUGIN_DIRECTORY_FETCHED_AT: Optional[datetime] = None
|
|
884
|
+
_PLUGIN_DIRECTORY_TTL = timedelta(hours=1)
|
|
885
|
+
_OPEN_LICENSES = {"Apache-2.0", "MIT", "MIT-0", "CC-BY-4.0"}
|
|
886
|
+
_REPO_LICENSE_CACHE: Dict[str, str] = {}
|
|
887
|
+
|
|
888
|
+
async def _get_repo_license(client: httpx.AsyncClient, repo: str) -> str:
|
|
889
|
+
if repo in _REPO_LICENSE_CACHE:
|
|
890
|
+
return _REPO_LICENSE_CACHE[repo]
|
|
891
|
+
if repo in _KNOWN_REPO_LICENSES:
|
|
892
|
+
_REPO_LICENSE_CACHE[repo] = _KNOWN_REPO_LICENSES[repo]
|
|
893
|
+
return _KNOWN_REPO_LICENSES[repo]
|
|
894
|
+
try:
|
|
895
|
+
r = await client.get(f"https://api.github.com/repos/{repo}", timeout=5.0)
|
|
896
|
+
lic = (r.json().get("license") or {}).get("spdx_id", "") if r.status_code == 200 else ""
|
|
897
|
+
except Exception:
|
|
898
|
+
lic = ""
|
|
899
|
+
_REPO_LICENSE_CACHE[repo] = lic
|
|
900
|
+
return lic
|
|
901
|
+
|
|
902
|
+
async def _fetch_plugin_directory() -> List[Dict]:
|
|
903
|
+
global _PLUGIN_DIRECTORY_CACHE, _PLUGIN_DIRECTORY_FETCHED_AT
|
|
904
|
+
now = datetime.now()
|
|
905
|
+
if _PLUGIN_DIRECTORY_FETCHED_AT and (now - _PLUGIN_DIRECTORY_FETCHED_AT) < _PLUGIN_DIRECTORY_TTL:
|
|
906
|
+
return _PLUGIN_DIRECTORY_CACHE
|
|
907
|
+
try:
|
|
908
|
+
result: List[Dict] = []
|
|
909
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
910
|
+
mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
|
|
911
|
+
mp_resp.raise_for_status()
|
|
912
|
+
plugins = mp_resp.json().get("plugins", [])
|
|
913
|
+
|
|
914
|
+
for p in plugins:
|
|
915
|
+
author = (p.get("author") or {}).get("name", "")
|
|
916
|
+
src = p.get("source", {})
|
|
917
|
+
|
|
918
|
+
# Anthropic 같은 레포 플러그인 → Apache-2.0 확인됨
|
|
919
|
+
if isinstance(src, str) and src.startswith("./") and author == "Anthropic":
|
|
920
|
+
plugin_path = src.lstrip("./")
|
|
921
|
+
result.append({
|
|
922
|
+
"name": p["name"],
|
|
923
|
+
"description": p.get("description", ""),
|
|
924
|
+
"category": p.get("category", ""),
|
|
925
|
+
"author": author,
|
|
926
|
+
"license": "Apache-2.0",
|
|
927
|
+
"homepage": p.get("homepage") or f"https://github.com/anthropics/claude-plugins-official/tree/main/{plugin_path}",
|
|
928
|
+
"source_type": "anthropic",
|
|
929
|
+
})
|
|
930
|
+
continue
|
|
931
|
+
|
|
932
|
+
# 외부 레포 플러그인 → 라이선스 확인
|
|
933
|
+
if not isinstance(src, dict):
|
|
934
|
+
continue
|
|
935
|
+
repo_url = src.get("url", "").replace("https://github.com/", "").replace(".git", "").split("/tree/")[0]
|
|
936
|
+
if not repo_url:
|
|
937
|
+
continue
|
|
938
|
+
license_id = await _get_repo_license(client, repo_url)
|
|
939
|
+
if license_id not in _OPEN_LICENSES:
|
|
940
|
+
continue
|
|
941
|
+
result.append({
|
|
942
|
+
"name": p["name"],
|
|
943
|
+
"description": p.get("description", ""),
|
|
944
|
+
"category": p.get("category", ""),
|
|
945
|
+
"author": author or repo_url.split("/")[0],
|
|
946
|
+
"license": license_id,
|
|
947
|
+
"homepage": p.get("homepage") or f"https://github.com/{repo_url}",
|
|
948
|
+
"source_type": "third-party",
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
_PLUGIN_DIRECTORY_CACHE = result
|
|
952
|
+
_PLUGIN_DIRECTORY_FETCHED_AT = now
|
|
953
|
+
logging.info("Fetched plugin directory: %d open-source plugins", len(result))
|
|
954
|
+
except Exception as e:
|
|
955
|
+
logging.warning("Failed to fetch plugin directory: %s", e)
|
|
956
|
+
return _PLUGIN_DIRECTORY_CACHE
|
|
957
|
+
|
|
958
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
959
|
+
|
|
960
|
+
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
|
|
961
|
+
|
|
962
|
+
async def install_skill(plugin: str, skill: str) -> Dict:
|
|
963
|
+
marketplace = await _fetch_skills_marketplace()
|
|
964
|
+
entry = next((s for s in marketplace if s["plugin"] == plugin and s["skill"] == skill), None)
|
|
965
|
+
if not entry:
|
|
966
|
+
raise HTTPException(status_code=404, detail=f"Skill '{plugin}/{skill}' not found in marketplace")
|
|
967
|
+
skill_dir = SKILLS_DIR / skill
|
|
968
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
969
|
+
skill_md_path = skill_dir / "SKILL.md"
|
|
970
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
971
|
+
resp = await client.get(entry["skill_md_url"])
|
|
972
|
+
resp.raise_for_status()
|
|
973
|
+
content = resp.text
|
|
974
|
+
# 출처 표기 (Apache-2.0 / MIT 공통)
|
|
975
|
+
repo_hint = entry.get("homepage", "")
|
|
976
|
+
attribution = f"<!-- Source: {repo_hint}, {entry['license']} -->\n"
|
|
977
|
+
if not content.startswith("<!--"):
|
|
978
|
+
content = attribution + content
|
|
979
|
+
skill_md_path.write_text(content, encoding="utf-8")
|
|
980
|
+
risk_path = skill_dir / "risk.json"
|
|
981
|
+
if not risk_path.exists():
|
|
982
|
+
risk_path.write_text(json.dumps({
|
|
983
|
+
"risk": "read", "destructive": False,
|
|
984
|
+
"shell": False, "network": False,
|
|
985
|
+
"auto_approve": True, "sandbox": "workspace", "rollback": "none"
|
|
986
|
+
}, indent=2), encoding="utf-8")
|
|
987
|
+
return {
|
|
988
|
+
"status": "installed",
|
|
989
|
+
"plugin": plugin,
|
|
990
|
+
"skill": skill,
|
|
991
|
+
"path": str(skill_dir),
|
|
992
|
+
"license": entry["license"],
|
|
993
|
+
"author": entry["author"],
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
997
|
+
|
|
656
998
|
def load_users():
|
|
657
999
|
if not os.path.exists(USERS_FILE):
|
|
658
1000
|
return {}
|
|
@@ -703,26 +1045,42 @@ def mcp_public_item(item: Dict, installed_state: Dict) -> Dict:
|
|
|
703
1045
|
connector_pending = item["install_mode"] == "connector" and not state.get("authenticated")
|
|
704
1046
|
authenticated = item["install_mode"] != "connector" or bool(state.get("authenticated"))
|
|
705
1047
|
return {
|
|
706
|
-
|
|
1048
|
+
"id": item["id"],
|
|
1049
|
+
"name": item["name"],
|
|
1050
|
+
"category": item.get("category", ""),
|
|
1051
|
+
"install_mode": item["install_mode"],
|
|
1052
|
+
"description": item.get("description", ""),
|
|
1053
|
+
"capabilities": item.get("capabilities", []),
|
|
707
1054
|
"connector_url": item.get("connector_url"),
|
|
708
1055
|
"external_url": item.get("external_url"),
|
|
1056
|
+
"package": item.get("package"),
|
|
1057
|
+
"homepage": item.get("homepage"),
|
|
1058
|
+
"source": item.get("source", "local"),
|
|
709
1059
|
"installed": installed,
|
|
710
1060
|
"status": state.get("status") or ("active" if installed and not connector_pending else "needs_auth" if connector_pending else "available"),
|
|
711
1061
|
"authenticated": authenticated,
|
|
712
1062
|
"updated_at": state.get("updated_at"),
|
|
713
1063
|
}
|
|
714
1064
|
|
|
715
|
-
def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
|
|
1065
|
+
async def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
|
|
716
1066
|
text = (query or "").lower()
|
|
717
1067
|
installed = load_mcp_installs().get("installed", {})
|
|
1068
|
+
registry = await _get_combined_registry()
|
|
718
1069
|
scored = []
|
|
719
|
-
for item in
|
|
1070
|
+
for item in registry:
|
|
720
1071
|
score = 0
|
|
721
1072
|
hits = []
|
|
722
|
-
for keyword in item
|
|
1073
|
+
for keyword in item.get("keywords", []):
|
|
723
1074
|
if keyword.lower() in text:
|
|
724
1075
|
score += 3 if len(keyword) > 2 else 1
|
|
725
1076
|
hits.append(keyword)
|
|
1077
|
+
# description 키워드 매칭 (remote 항목 보완)
|
|
1078
|
+
if not hits and text:
|
|
1079
|
+
desc_words = item.get("description", "").lower().split()
|
|
1080
|
+
for word in text.split():
|
|
1081
|
+
if len(word) > 2 and word in desc_words:
|
|
1082
|
+
score += 1
|
|
1083
|
+
hits.append(word)
|
|
726
1084
|
if item["id"] == "filesystem" and any(word in text for word in ["만들", "구현", "build", "deploy", "코드", "앱"]):
|
|
727
1085
|
score += 2
|
|
728
1086
|
if score:
|
|
@@ -734,13 +1092,14 @@ def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
|
|
|
734
1092
|
fallback_ids = ["filesystem", "browser", "documents"]
|
|
735
1093
|
scored = [
|
|
736
1094
|
{**mcp_public_item(item, installed), "score": 1, "matched_keywords": []}
|
|
737
|
-
for item in
|
|
1095
|
+
for item in registry
|
|
738
1096
|
if item["id"] in fallback_ids
|
|
739
1097
|
]
|
|
740
1098
|
return sorted(scored, key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 24))]
|
|
741
1099
|
|
|
742
|
-
def install_mcp(mcp_id: str) -> Dict:
|
|
743
|
-
|
|
1100
|
+
async def install_mcp(mcp_id: str) -> Dict:
|
|
1101
|
+
registry = await _get_combined_registry()
|
|
1102
|
+
item = next((entry for entry in registry if entry["id"] == mcp_id), None)
|
|
744
1103
|
if not item:
|
|
745
1104
|
raise HTTPException(status_code=404, detail="MCP를 찾을 수 없습니다.")
|
|
746
1105
|
data = load_mcp_installs()
|
|
@@ -755,15 +1114,33 @@ def install_mcp(mcp_id: str) -> Dict:
|
|
|
755
1114
|
for pkg in packages:
|
|
756
1115
|
completed = subprocess.run(
|
|
757
1116
|
[sys.executable, "-m", "pip", "install", "--upgrade", pkg],
|
|
758
|
-
capture_output=True,
|
|
759
|
-
text=True,
|
|
760
|
-
timeout=900,
|
|
761
|
-
check=False,
|
|
1117
|
+
capture_output=True, text=True, timeout=900, check=False,
|
|
762
1118
|
)
|
|
763
1119
|
if completed.returncode != 0:
|
|
764
1120
|
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
|
|
765
|
-
status = "active"
|
|
766
1121
|
message = f"필수 패키지 설치 완료: {', '.join(packages)}"
|
|
1122
|
+
elif item["install_mode"] == "pypi":
|
|
1123
|
+
pkg = item.get("package", "")
|
|
1124
|
+
version = item.get("package_version")
|
|
1125
|
+
pkg_str = f"{pkg}=={version}" if version else pkg
|
|
1126
|
+
completed = subprocess.run(
|
|
1127
|
+
[sys.executable, "-m", "pip", "install", pkg_str],
|
|
1128
|
+
capture_output=True, text=True, timeout=300, check=False,
|
|
1129
|
+
)
|
|
1130
|
+
if completed.returncode != 0:
|
|
1131
|
+
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
|
|
1132
|
+
message = f"pip 패키지 설치 완료: {pkg_str}"
|
|
1133
|
+
elif item["install_mode"] == "npm":
|
|
1134
|
+
pkg = item.get("package", "")
|
|
1135
|
+
version = item.get("package_version")
|
|
1136
|
+
pkg_str = f"{pkg}@{version}" if version else pkg
|
|
1137
|
+
completed = subprocess.run(
|
|
1138
|
+
["npm", "install", "-g", pkg_str],
|
|
1139
|
+
capture_output=True, text=True, timeout=300, check=False,
|
|
1140
|
+
)
|
|
1141
|
+
if completed.returncode != 0:
|
|
1142
|
+
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
|
|
1143
|
+
message = f"npm 패키지 설치 완료: {pkg_str}"
|
|
767
1144
|
state[mcp_id] = {
|
|
768
1145
|
"installed": True,
|
|
769
1146
|
"status": status,
|
|
@@ -775,19 +1152,6 @@ def install_mcp(mcp_id: str) -> Dict:
|
|
|
775
1152
|
public["message"] = message
|
|
776
1153
|
return public
|
|
777
1154
|
|
|
778
|
-
def connector_info(mcp_id: str) -> Dict:
|
|
779
|
-
item = next((entry for entry in MCP_REGISTRY if entry["id"] == mcp_id), None)
|
|
780
|
-
if not item or item.get("install_mode") != "connector":
|
|
781
|
-
raise HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
|
|
782
|
-
installed = load_mcp_installs().get("installed", {})
|
|
783
|
-
public = mcp_public_item(item, installed)
|
|
784
|
-
public["instructions"] = [
|
|
785
|
-
"Codex 또는 ChatGPT 앱의 Connectors 설정을 엽니다.",
|
|
786
|
-
f"{item['name']} 항목을 선택하고 계정을 인증합니다.",
|
|
787
|
-
"인증 후 Lattice AI에서 이 MCP를 다시 활성화하면 작업에 사용할 수 있습니다.",
|
|
788
|
-
]
|
|
789
|
-
return public
|
|
790
|
-
|
|
791
1155
|
_history_lock = threading.Lock()
|
|
792
1156
|
|
|
793
1157
|
def get_audit_log() -> List[Dict]:
|
|
@@ -2033,6 +2397,20 @@ class AgentRequest(BaseModel):
|
|
|
2033
2397
|
temperature: float = 0.1
|
|
2034
2398
|
user_email: Optional[str] = None
|
|
2035
2399
|
user_nickname: Optional[str] = None
|
|
2400
|
+
# Multi-LLM pipeline: per-phase model override (None = use current loaded model)
|
|
2401
|
+
planning_model: Optional[str] = None
|
|
2402
|
+
executing_model: Optional[str] = None
|
|
2403
|
+
reviewing_model: Optional[str] = None
|
|
2404
|
+
# When True: pause after planning and wait for /agent/resume
|
|
2405
|
+
human_in_loop: bool = False
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
class AgentResumeRequest(BaseModel):
|
|
2409
|
+
context_id: str
|
|
2410
|
+
approved: bool = True
|
|
2411
|
+
modified_plan: Optional[dict] = None
|
|
2412
|
+
executing_model: Optional[str] = None
|
|
2413
|
+
reviewing_model: Optional[str] = None
|
|
2036
2414
|
|
|
2037
2415
|
|
|
2038
2416
|
class AgentEvalRequest(BaseModel):
|
|
@@ -2058,17 +2436,25 @@ AGENT_TERMINAL_STATES = frozenset({AgentState.DONE, AgentState.FAILED})
|
|
|
2058
2436
|
class AgentRunContext:
|
|
2059
2437
|
"""Mutable state carrier passed through all agent phases."""
|
|
2060
2438
|
__slots__ = ("state", "plan", "transcript", "retry_count",
|
|
2061
|
-
"state_history", "corrections", "final_message", "rollback_log"
|
|
2439
|
+
"state_history", "corrections", "final_message", "rollback_log",
|
|
2440
|
+
"executing_model", "reviewing_model")
|
|
2062
2441
|
|
|
2063
2442
|
def __init__(self) -> None:
|
|
2064
|
-
self.state:
|
|
2065
|
-
self.plan:
|
|
2066
|
-
self.transcript:
|
|
2067
|
-
self.retry_count:
|
|
2068
|
-
self.state_history:
|
|
2069
|
-
self.corrections:
|
|
2070
|
-
self.final_message:
|
|
2071
|
-
self.rollback_log:
|
|
2443
|
+
self.state: AgentState = AgentState.IDLE
|
|
2444
|
+
self.plan: dict = {}
|
|
2445
|
+
self.transcript: list = []
|
|
2446
|
+
self.retry_count: int = 0
|
|
2447
|
+
self.state_history: list = []
|
|
2448
|
+
self.corrections: list = []
|
|
2449
|
+
self.final_message: str = ""
|
|
2450
|
+
self.rollback_log: list = []
|
|
2451
|
+
self.executing_model: str | None = None
|
|
2452
|
+
self.reviewing_model: str | None = None
|
|
2453
|
+
|
|
2454
|
+
|
|
2455
|
+
# Pending agent contexts waiting for human approval: context_id → (ctx, req, lang_hint, current_user)
|
|
2456
|
+
_pending_agents: dict[str, tuple] = {}
|
|
2457
|
+
_pending_agents_lock = threading.Lock()
|
|
2072
2458
|
|
|
2073
2459
|
|
|
2074
2460
|
class ToolPathRequest(BaseModel):
|
|
@@ -4323,6 +4709,7 @@ def _extract_agent_action(raw: str) -> Dict:
|
|
|
4323
4709
|
|
|
4324
4710
|
async def _phase_plan(
|
|
4325
4711
|
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
|
|
4712
|
+
model_id: str | None = None,
|
|
4326
4713
|
) -> None:
|
|
4327
4714
|
"""PLAN: Planner role produces a structured plan JSON."""
|
|
4328
4715
|
context = (
|
|
@@ -4331,7 +4718,8 @@ async def _phase_plan(
|
|
|
4331
4718
|
f"Workspace root: {AGENT_ROOT}\n\n"
|
|
4332
4719
|
f"User request: {req.message}"
|
|
4333
4720
|
)
|
|
4334
|
-
raw = await router.
|
|
4721
|
+
raw = await router.generate_as(
|
|
4722
|
+
model_id,
|
|
4335
4723
|
message="Produce a JSON execution plan for this request.",
|
|
4336
4724
|
context=context, max_tokens=1024, temperature=0.1,
|
|
4337
4725
|
)
|
|
@@ -4377,7 +4765,7 @@ def _phase_approval(ctx: AgentRunContext, current_user: str) -> None:
|
|
|
4377
4765
|
|
|
4378
4766
|
async def _phase_execute(
|
|
4379
4767
|
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str,
|
|
4380
|
-
current_user: str, max_steps: int,
|
|
4768
|
+
current_user: str, max_steps: int, model_id: str | None = None,
|
|
4381
4769
|
) -> None:
|
|
4382
4770
|
"""EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
|
|
4383
4771
|
exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
|
|
@@ -4398,7 +4786,8 @@ async def _phase_execute(
|
|
|
4398
4786
|
f"User request: {req.message}{corrections_hint}\n\n"
|
|
4399
4787
|
f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
4400
4788
|
)
|
|
4401
|
-
raw = await router.
|
|
4789
|
+
raw = await router.generate_as(
|
|
4790
|
+
model_id,
|
|
4402
4791
|
message="Execute the next step.",
|
|
4403
4792
|
context=context, max_tokens=4096, temperature=req.temperature,
|
|
4404
4793
|
)
|
|
@@ -4492,7 +4881,7 @@ async def _phase_execute(
|
|
|
4492
4881
|
|
|
4493
4882
|
async def _phase_verify(
|
|
4494
4883
|
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
|
|
4495
|
-
max_retry: int = 3,
|
|
4884
|
+
max_retry: int = 3, model_id: str | None = None,
|
|
4496
4885
|
) -> None:
|
|
4497
4886
|
"""VERIFYING: Critic role evaluates transcript → DONE / EXECUTING (retry) / ROLLBACK / FAILED."""
|
|
4498
4887
|
context = (
|
|
@@ -4502,7 +4891,8 @@ async def _phase_verify(
|
|
|
4502
4891
|
f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
|
|
4503
4892
|
f"Full transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
4504
4893
|
)
|
|
4505
|
-
raw = await router.
|
|
4894
|
+
raw = await router.generate_as(
|
|
4895
|
+
model_id,
|
|
4506
4896
|
message="Review the execution transcript and return your verdict JSON.",
|
|
4507
4897
|
context=context, max_tokens=512, temperature=0.1,
|
|
4508
4898
|
)
|
|
@@ -4685,29 +5075,54 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
4685
5075
|
max_retry = 3
|
|
4686
5076
|
|
|
4687
5077
|
ctx = AgentRunContext()
|
|
5078
|
+
ctx.executing_model = req.executing_model
|
|
5079
|
+
ctx.reviewing_model = req.reviewing_model
|
|
5080
|
+
|
|
5081
|
+
# PLANNING phase
|
|
5082
|
+
ctx.state = AgentState.PLANNING
|
|
5083
|
+
ctx.state_history.append(ctx.state.value)
|
|
5084
|
+
await _phase_plan(ctx, req, router, lang_hint, current_user, model_id=req.planning_model)
|
|
5085
|
+
|
|
5086
|
+
# Human-in-the-loop: pause after planning, return plan to UI
|
|
5087
|
+
if req.human_in_loop:
|
|
5088
|
+
context_id = secrets.token_urlsafe(16)
|
|
5089
|
+
with _pending_agents_lock:
|
|
5090
|
+
_pending_agents[context_id] = (ctx, req, lang_hint, current_user)
|
|
5091
|
+
return {
|
|
5092
|
+
"status": "waiting_approval",
|
|
5093
|
+
"context_id": context_id,
|
|
5094
|
+
"plan": ctx.plan,
|
|
5095
|
+
"steps": ctx.transcript,
|
|
5096
|
+
"state_history": ctx.state_history,
|
|
5097
|
+
"planning_model": req.planning_model or router.current_model_id,
|
|
5098
|
+
"executing_model": req.executing_model or router.current_model_id,
|
|
5099
|
+
"reviewing_model": req.reviewing_model or router.current_model_id,
|
|
5100
|
+
}
|
|
5101
|
+
|
|
5102
|
+
# Auto-approve and run to completion (default behaviour)
|
|
5103
|
+
_phase_approval(ctx, current_user)
|
|
5104
|
+
return await _agent_run_to_completion(ctx, req, router, lang_hint, current_user, max_steps, max_retry)
|
|
4688
5105
|
|
|
5106
|
+
|
|
5107
|
+
async def _agent_run_to_completion(
|
|
5108
|
+
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str,
|
|
5109
|
+
current_user: str, max_steps: int, max_retry: int,
|
|
5110
|
+
) -> dict:
|
|
5111
|
+
"""Run EXECUTING → VERIFYING loop until terminal state."""
|
|
4689
5112
|
while ctx.state not in AGENT_TERMINAL_STATES:
|
|
4690
5113
|
ctx.state_history.append(ctx.state.value)
|
|
4691
|
-
# Hard guard against infinite state loops
|
|
4692
5114
|
if len(ctx.state_history) > 200:
|
|
4693
5115
|
ctx.final_message = "에이전트 상태 머신이 최대 반복(200)에 도달해 중단했습니다."
|
|
4694
5116
|
ctx.state = AgentState.FAILED
|
|
4695
5117
|
break
|
|
4696
5118
|
|
|
4697
|
-
if ctx.state == AgentState.
|
|
4698
|
-
ctx
|
|
4699
|
-
|
|
4700
|
-
elif ctx.state == AgentState.PLANNING:
|
|
4701
|
-
await _phase_plan(ctx, req, router, lang_hint, current_user)
|
|
4702
|
-
|
|
4703
|
-
elif ctx.state == AgentState.WAITING_APPROVAL:
|
|
4704
|
-
_phase_approval(ctx, current_user)
|
|
4705
|
-
|
|
4706
|
-
elif ctx.state == AgentState.EXECUTING:
|
|
4707
|
-
await _phase_execute(ctx, req, router, lang_hint, current_user, max_steps)
|
|
5119
|
+
if ctx.state == AgentState.EXECUTING:
|
|
5120
|
+
await _phase_execute(ctx, req, router, lang_hint, current_user, max_steps,
|
|
5121
|
+
model_id=ctx.executing_model)
|
|
4708
5122
|
|
|
4709
5123
|
elif ctx.state == AgentState.VERIFYING:
|
|
4710
|
-
await _phase_verify(ctx, req, router, lang_hint, current_user, max_retry
|
|
5124
|
+
await _phase_verify(ctx, req, router, lang_hint, current_user, max_retry,
|
|
5125
|
+
model_id=ctx.reviewing_model)
|
|
4711
5126
|
|
|
4712
5127
|
elif ctx.state == AgentState.ROLLBACK:
|
|
4713
5128
|
_phase_rollback(ctx, current_user)
|
|
@@ -4715,10 +5130,7 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
4715
5130
|
else:
|
|
4716
5131
|
ctx.state = AgentState.FAILED
|
|
4717
5132
|
|
|
4718
|
-
# Record terminal state in history for clients
|
|
4719
5133
|
ctx.state_history.append(ctx.state.value)
|
|
4720
|
-
|
|
4721
|
-
# Fire-and-forget memory update — does not block the response
|
|
4722
5134
|
asyncio.create_task(_phase_memory_update(ctx, req, router, current_user))
|
|
4723
5135
|
|
|
4724
5136
|
message = ctx.final_message or "작업을 완료했습니다."
|
|
@@ -4736,6 +5148,36 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
4736
5148
|
}
|
|
4737
5149
|
|
|
4738
5150
|
|
|
5151
|
+
@app.post("/agent/resume")
|
|
5152
|
+
async def agent_resume(req: AgentResumeRequest, request: Request):
|
|
5153
|
+
"""Resume a paused agent after human approval of the plan."""
|
|
5154
|
+
current_user = require_user(request)
|
|
5155
|
+
|
|
5156
|
+
with _pending_agents_lock:
|
|
5157
|
+
entry = _pending_agents.pop(req.context_id, None)
|
|
5158
|
+
if not entry:
|
|
5159
|
+
raise HTTPException(status_code=404, detail="Agent context not found or expired. Start a new request.")
|
|
5160
|
+
|
|
5161
|
+
ctx, orig_req, lang_hint, _orig_user = entry
|
|
5162
|
+
|
|
5163
|
+
if not req.approved:
|
|
5164
|
+
return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
|
|
5165
|
+
|
|
5166
|
+
if req.modified_plan:
|
|
5167
|
+
ctx.plan = req.modified_plan
|
|
5168
|
+
ctx.transcript[-1].update(ctx.plan) # keep transcript in sync
|
|
5169
|
+
|
|
5170
|
+
# Apply model overrides from resume request (takes priority over original request)
|
|
5171
|
+
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
5172
|
+
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
5173
|
+
|
|
5174
|
+
_phase_approval(ctx, current_user)
|
|
5175
|
+
|
|
5176
|
+
max_steps = max(1, min(orig_req.max_steps, 50))
|
|
5177
|
+
max_retry = 3
|
|
5178
|
+
return await _agent_run_to_completion(ctx, orig_req, router, lang_hint, current_user, max_steps, max_retry)
|
|
5179
|
+
|
|
5180
|
+
|
|
4739
5181
|
# ── Direct Tool API ───────────────────────────────────────────────────────────
|
|
4740
5182
|
|
|
4741
5183
|
def _tool_response(fn, *args):
|
|
@@ -5462,6 +5904,7 @@ async def tools_permissions(request: Request):
|
|
|
5462
5904
|
@app.get("/mcp/tools")
|
|
5463
5905
|
async def mcp_tools():
|
|
5464
5906
|
installed = load_mcp_installs().get("installed", {})
|
|
5907
|
+
registry = await _get_combined_registry()
|
|
5465
5908
|
tools = []
|
|
5466
5909
|
for name, description in _MCP_TOOL_DESCRIPTIONS.items():
|
|
5467
5910
|
policy = TOOL_GOVERNANCE.get(name, _TOOL_GOVERNANCE_DEFAULT)
|
|
@@ -5482,7 +5925,7 @@ async def mcp_tools():
|
|
|
5482
5925
|
return {
|
|
5483
5926
|
"status": "ok",
|
|
5484
5927
|
"workspace": str(AGENT_ROOT),
|
|
5485
|
-
"installed_mcps": [mcp_public_item(item, installed) for item in
|
|
5928
|
+
"installed_mcps": [mcp_public_item(item, installed) for item in registry],
|
|
5486
5929
|
"tools": tools,
|
|
5487
5930
|
}
|
|
5488
5931
|
|
|
@@ -5490,26 +5933,159 @@ async def mcp_tools():
|
|
|
5490
5933
|
@app.post("/mcp/recommend")
|
|
5491
5934
|
async def mcp_recommend(req: McpRecommendRequest, request: Request):
|
|
5492
5935
|
require_user(request)
|
|
5493
|
-
return {"recommendations": recommend_mcps(req.query, req.limit)}
|
|
5936
|
+
return {"recommendations": await recommend_mcps(req.query, req.limit)}
|
|
5494
5937
|
|
|
5495
5938
|
|
|
5496
5939
|
@app.post("/mcp/install")
|
|
5497
5940
|
async def mcp_install(req: McpInstallRequest, request: Request):
|
|
5498
5941
|
require_user(request)
|
|
5499
|
-
return install_mcp(req.mcp_id)
|
|
5942
|
+
return await install_mcp(req.mcp_id)
|
|
5500
5943
|
|
|
5501
5944
|
|
|
5502
5945
|
@app.get("/mcp/installed")
|
|
5503
5946
|
async def mcp_installed(request: Request):
|
|
5504
5947
|
require_user(request)
|
|
5505
5948
|
installed = load_mcp_installs().get("installed", {})
|
|
5506
|
-
|
|
5949
|
+
registry = await _get_combined_registry()
|
|
5950
|
+
return {"installed": [mcp_public_item(item, installed) for item in registry]}
|
|
5507
5951
|
|
|
5508
5952
|
|
|
5509
5953
|
@app.get("/mcp/connectors/{mcp_id}")
|
|
5510
5954
|
async def mcp_connector(mcp_id: str, request: Request):
|
|
5511
5955
|
require_user(request)
|
|
5512
|
-
|
|
5956
|
+
registry = await _get_combined_registry()
|
|
5957
|
+
item = next((e for e in registry if e["id"] == mcp_id), None)
|
|
5958
|
+
if not item or item.get("install_mode") != "connector":
|
|
5959
|
+
raise HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
|
|
5960
|
+
installed = load_mcp_installs().get("installed", {})
|
|
5961
|
+
public = mcp_public_item(item, installed)
|
|
5962
|
+
public["instructions"] = [
|
|
5963
|
+
"Codex 또는 ChatGPT 앱의 Connectors 설정을 엽니다.",
|
|
5964
|
+
f"{item['name']} 항목을 선택하고 계정을 인증합니다.",
|
|
5965
|
+
"인증 후 Lattice AI에서 이 MCP를 다시 활성화하면 작업에 사용할 수 있습니다.",
|
|
5966
|
+
]
|
|
5967
|
+
return public
|
|
5968
|
+
|
|
5969
|
+
|
|
5970
|
+
@app.post("/mcp/registry/refresh")
|
|
5971
|
+
async def mcp_registry_refresh(request: Request):
|
|
5972
|
+
require_user(request)
|
|
5973
|
+
global _REMOTE_REGISTRY_FETCHED_AT
|
|
5974
|
+
_REMOTE_REGISTRY_FETCHED_AT = None
|
|
5975
|
+
registry = await _get_combined_registry()
|
|
5976
|
+
return {"status": "ok", "total": len(registry), "remote": len(_REMOTE_REGISTRY_CACHE)}
|
|
5977
|
+
|
|
5978
|
+
|
|
5979
|
+
# ── Skills & Plugin Directory endpoints ───────────────────────────────────────
|
|
5980
|
+
|
|
5981
|
+
@app.get("/skills/marketplace")
|
|
5982
|
+
async def skills_marketplace(request: Request, category: Optional[str] = None, author: Optional[str] = None):
|
|
5983
|
+
"""Skills 마켓플레이스 (Anthropic Apache-2.0 + 검증된 서드파티 MIT/Apache-2.0)"""
|
|
5984
|
+
require_user(request)
|
|
5985
|
+
skills = await _fetch_skills_marketplace()
|
|
5986
|
+
installed_names = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()} if SKILLS_DIR.exists() else set()
|
|
5987
|
+
filtered = skills
|
|
5988
|
+
if category:
|
|
5989
|
+
filtered = [s for s in filtered if s.get("category", "").lower() == category.lower()]
|
|
5990
|
+
if author:
|
|
5991
|
+
filtered = [s for s in filtered if s.get("author", "").lower() == author.lower()]
|
|
5992
|
+
return {
|
|
5993
|
+
"skills": [{**s, "installed": s["skill"] in installed_names} for s in filtered],
|
|
5994
|
+
"total": len(filtered),
|
|
5995
|
+
"authors": sorted({s["author"] for s in skills}),
|
|
5996
|
+
"categories": sorted({s["category"] for s in skills}),
|
|
5997
|
+
}
|
|
5998
|
+
|
|
5999
|
+
|
|
6000
|
+
@app.post("/skills/install")
|
|
6001
|
+
async def skills_install(req: SkillInstallRequest, request: Request):
|
|
6002
|
+
"""skill을 로컬 skills 디렉터리에 설치 (Apache-2.0 / MIT)"""
|
|
6003
|
+
require_user(request)
|
|
6004
|
+
return await install_skill(req.plugin, req.skill)
|
|
6005
|
+
|
|
6006
|
+
|
|
6007
|
+
@app.get("/skills/list")
|
|
6008
|
+
async def skills_list(request: Request):
|
|
6009
|
+
"""로컬에 설치된 skills 목록"""
|
|
6010
|
+
require_user(request)
|
|
6011
|
+
if not SKILLS_DIR.exists():
|
|
6012
|
+
return {"skills": []}
|
|
6013
|
+
skills = []
|
|
6014
|
+
for skill_dir in sorted(SKILLS_DIR.iterdir()):
|
|
6015
|
+
if not skill_dir.is_dir():
|
|
6016
|
+
continue
|
|
6017
|
+
skill_md = skill_dir / "SKILL.md"
|
|
6018
|
+
if not skill_md.exists():
|
|
6019
|
+
continue
|
|
6020
|
+
lines = skill_md.read_text(encoding="utf-8").splitlines()
|
|
6021
|
+
desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
|
|
6022
|
+
comment = lines[0] if lines else ""
|
|
6023
|
+
if "anthropics/claude-plugins-official" in comment:
|
|
6024
|
+
source = "anthropic"
|
|
6025
|
+
elif "Source:" in comment:
|
|
6026
|
+
source = "third-party"
|
|
6027
|
+
else:
|
|
6028
|
+
source = "local"
|
|
6029
|
+
skills.append({"name": skill_dir.name, "description": desc, "source": source})
|
|
6030
|
+
return {"skills": skills, "total": len(skills)}
|
|
6031
|
+
|
|
6032
|
+
|
|
6033
|
+
@app.post("/skills/marketplace/refresh")
|
|
6034
|
+
async def skills_marketplace_refresh(request: Request):
|
|
6035
|
+
"""Skills 마켓플레이스 캐시 강제 갱신"""
|
|
6036
|
+
require_user(request)
|
|
6037
|
+
global _SKILLS_MARKETPLACE_FETCHED_AT
|
|
6038
|
+
_SKILLS_MARKETPLACE_FETCHED_AT = None
|
|
6039
|
+
skills = await _fetch_skills_marketplace()
|
|
6040
|
+
by_author = {}
|
|
6041
|
+
for s in skills:
|
|
6042
|
+
by_author[s["author"]] = by_author.get(s["author"], 0) + 1
|
|
6043
|
+
return {"status": "ok", "total": len(skills), "by_author": by_author}
|
|
6044
|
+
|
|
6045
|
+
|
|
6046
|
+
@app.get("/plugins/directory")
|
|
6047
|
+
async def plugins_directory(
|
|
6048
|
+
request: Request,
|
|
6049
|
+
category: Optional[str] = None,
|
|
6050
|
+
license: Optional[str] = None,
|
|
6051
|
+
q: Optional[str] = None,
|
|
6052
|
+
):
|
|
6053
|
+
"""오픈소스 플러그인 디렉터리 (Apache-2.0 / MIT / MIT-0, 런타임 fetch)"""
|
|
6054
|
+
require_user(request)
|
|
6055
|
+
plugins = await _fetch_plugin_directory()
|
|
6056
|
+
filtered = plugins
|
|
6057
|
+
if category:
|
|
6058
|
+
filtered = [p for p in filtered if p.get("category", "").lower() == category.lower()]
|
|
6059
|
+
if license:
|
|
6060
|
+
filtered = [p for p in filtered if p.get("license", "").lower() == license.lower()]
|
|
6061
|
+
if q:
|
|
6062
|
+
q_lower = q.lower()
|
|
6063
|
+
filtered = [
|
|
6064
|
+
p for p in filtered
|
|
6065
|
+
if q_lower in p.get("name", "").lower()
|
|
6066
|
+
or q_lower in p.get("description", "").lower()
|
|
6067
|
+
or q_lower in p.get("author", "").lower()
|
|
6068
|
+
]
|
|
6069
|
+
return {
|
|
6070
|
+
"plugins": filtered,
|
|
6071
|
+
"total": len(filtered),
|
|
6072
|
+
"categories": sorted({p["category"] for p in plugins if p.get("category")}),
|
|
6073
|
+
"licenses": sorted({p["license"] for p in plugins if p.get("license")}),
|
|
6074
|
+
}
|
|
6075
|
+
|
|
6076
|
+
|
|
6077
|
+
@app.post("/plugins/directory/refresh")
|
|
6078
|
+
async def plugins_directory_refresh(request: Request):
|
|
6079
|
+
"""플러그인 디렉터리 캐시 강제 갱신"""
|
|
6080
|
+
require_user(request)
|
|
6081
|
+
global _PLUGIN_DIRECTORY_FETCHED_AT
|
|
6082
|
+
_PLUGIN_DIRECTORY_FETCHED_AT = None
|
|
6083
|
+
plugins = await _fetch_plugin_directory()
|
|
6084
|
+
by_license = {}
|
|
6085
|
+
for p in plugins:
|
|
6086
|
+
lic = p.get("license", "unknown")
|
|
6087
|
+
by_license[lic] = by_license.get(lic, 0) + 1
|
|
6088
|
+
return {"status": "ok", "total": len(plugins), "by_license": by_license}
|
|
5513
6089
|
|
|
5514
6090
|
|
|
5515
6091
|
@app.post("/mcp/call")
|