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/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
- return DATA_DIR / "sessions.json"
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
- **{key: item[key] for key in ["id", "name", "category", "install_mode", "description", "capabilities"]},
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 MCP_REGISTRY:
1070
+ for item in registry:
720
1071
  score = 0
721
1072
  hits = []
722
- for keyword in item["keywords"]:
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 MCP_REGISTRY
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
- item = next((entry for entry in MCP_REGISTRY if entry["id"] == mcp_id), None)
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: AgentState = AgentState.IDLE
2065
- self.plan: dict = {}
2066
- self.transcript: list = []
2067
- self.retry_count: int = 0
2068
- self.state_history: list = []
2069
- self.corrections: list = []
2070
- self.final_message: str = ""
2071
- self.rollback_log: list = []
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.generate(
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.generate(
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.generate(
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.IDLE:
4698
- ctx.state = AgentState.PLANNING
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 MCP_REGISTRY],
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
- return {"installed": [mcp_public_item(item, installed) for item in MCP_REGISTRY]}
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
- return connector_info(mcp_id)
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")