diff --git a/examples/config.yaml.example b/examples/config.yaml.example index e037cb19..bc413054 100644 --- a/examples/config.yaml.example +++ b/examples/config.yaml.example @@ -23,9 +23,10 @@ llm: # ===== Embedding(向量检索用)===== embedding: base_url: https://ark.cn-beijing.volces.com/api/v3 - model: doubao-embedding-vision-251215 + model: doubao-embedding-vision-251215 # 或 doubao-embedding-large-text-250515 api_key: PUT_YOUR_EMBEDDING_API_KEY_HERE - dim: 0 # 0 = 自动探测 + dim: 0 # 0 = 自动探测;large-text 可写 2048 + # api: openai | multimodal # 可选;默认 vision 模型→multimodal,text 模型→/embeddings # ===== L2 沙箱评估(SWE-bench docker 跑 A/B/C;单测 / 评估精度脚本用)===== sandbox: diff --git a/scripts/cursor_import.py b/scripts/cursor_import.py new file mode 100644 index 00000000..9871f853 --- /dev/null +++ b/scripts/cursor_import.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Import Cursor agent-transcripts (*.jsonl) into xskill watch dir as traj_*.md.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from xskill.adapters import submit_trajectory + + +def _jsonl_to_markdown(jsonl_path: Path) -> str: + lines: list[str] = [ + "# Cursor Agent Trajectory", + "", + f"**source_file**: {jsonl_path}", + "", + ] + for raw in jsonl_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + raw = raw.strip() + if not raw: + continue + ev = json.loads(raw) + role = ev.get("role", "unknown") + msg = ev.get("message") or {} + parts = msg.get("content") or [] + chunks: list[str] = [] + for p in parts: + if not isinstance(p, dict): + continue + if p.get("type") == "text" and p.get("text"): + chunks.append(str(p["text"])) + elif p.get("type") == "tool_use": + name = p.get("name", "tool") + chunks.append(f"[tool_use: {name}]") + body = "\n".join(chunks).strip() + if not body: + continue + lines.append(f"## {str(role).capitalize()}") + lines.append("") + lines.append(body) + lines.append("") + return "\n".join(lines) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--src", + type=Path, + default=Path.home() + / ".cursor/projects/c-yzj-entrepreneurship-XSKILL-xskill/agent-transcripts", + help="Cursor agent-transcripts root (searched recursively for *.jsonl)", + ) + p.add_argument( + "--out", + type=Path, + default=Path.home() / ".xskill/cursor_import", + help="xskill watch directory (traj_*.md output)", + ) + args = p.parse_args() + src = args.src.expanduser().resolve() + out = args.out.expanduser().resolve() + out.mkdir(parents=True, exist_ok=True) + + jsonls = sorted(src.rglob("*.jsonl")) + if not jsonls: + print(f"no *.jsonl under {src}", file=sys.stderr) + return 1 + + for jsonl in jsonls: + md = _jsonl_to_markdown(jsonl) + sid = jsonl.stem + result = submit_trajectory( + content=md, + format="markdown", + metadata={ + "source": "cursor", + "ecosystem": "cursor", + "session_id": sid, + "source_jsonl": str(jsonl), + }, + traj_id=f"traj_cursor_{sid[:8]}", + traj_dir=out, + ) + print(f"imported {jsonl.name} -> {result['path']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cursor_setup.ps1 b/scripts/cursor_setup.ps1 new file mode 100644 index 00000000..afc0b060 --- /dev/null +++ b/scripts/cursor_setup.ps1 @@ -0,0 +1,52 @@ +# xskill + Cursor one-shot setup (dirs, junction, import, registry) +# Run from repo root: powershell -ExecutionPolicy Bypass -File scripts\cursor_setup.ps1 + +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path $PSScriptRoot -Parent + +$XskillHome = Join-Path $env:USERPROFILE ".xskill" +$SkillStore = Join-Path $XskillHome "skill" +$CursorImport = Join-Path $XskillHome "cursor_import" +$CursorSkills = Join-Path $env:USERPROFILE ".cursor\skills" +$ConfigPath = Join-Path $XskillHome "config.yaml" +$ExampleConfig = Join-Path $RepoRoot "examples\config.yaml.example" +$VenvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe" +$XskillExe = Join-Path $RepoRoot ".venv\Scripts\xskill.exe" + +Write-Host "`n[Step 1] Create directories" +New-Item -ItemType Directory -Force -Path $XskillHome, $SkillStore, $CursorImport | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $env:USERPROFILE ".cursor") | Out-Null + +Write-Host "`n[Step 2] Copy config.yaml if missing" +if (-not (Test-Path $ConfigPath)) { + Copy-Item $ExampleConfig $ConfigPath + Write-Host "Edit $ConfigPath and set llm.api_key / embedding.api_key" +} + +Write-Host "`n[Step 3] Junction: .cursor\skills -> .xskill\skill" +if (Test-Path $CursorSkills) { + $item = Get-Item $CursorSkills -Force + $reparse = ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 + if ($reparse) { + Write-Host "Already linked: $CursorSkills" + } else { + Write-Warning "$CursorSkills exists and is not a junction; remove it manually first." + } +} else { + cmd /c "mklink /J `"$CursorSkills`" `"$SkillStore`"" +} + +Write-Host "`n[Step 4] pip install -e .[dev]" +Set-Location $RepoRoot +if (-not (Test-Path $VenvPython)) { python -m venv .venv } +& $VenvPython -m pip install -q -e ".[dev]" + +Write-Host "`n[Step 5] Import Cursor agent-transcripts" +& $VenvPython (Join-Path $RepoRoot "scripts\cursor_import.py") + +Write-Host "`n[Step 6] registry add cursor_import" +& $XskillExe registry add $CursorImport --label cursor_import +& $XskillExe registry list + +Write-Host "`nDone. Next: edit config.yaml keys, then:" +Write-Host " .\.venv\Scripts\xskill.exe serve --host 127.0.0.1 --port 8000" diff --git a/src/xskill/config.py b/src/xskill/config.py index 1994c14c..de1d04b9 100644 --- a/src/xskill/config.py +++ b/src/xskill/config.py @@ -42,7 +42,7 @@ def load_config(path: Optional[Path] = None) -> dict: f"xskill config not found: {cfg_path}\n" f"Create it manually (see docs)." ) - with open(cfg_path) as f: + with open(cfg_path, encoding="utf-8") as f: _config = yaml.safe_load(f) or {} if not _config.get("llm", {}).get("api_key"): raise KeyError(f"llm.api_key missing in {cfg_path}") diff --git a/src/xskill/git_lock.py b/src/xskill/git_lock.py index f5b15fc4..2b081570 100644 --- a/src/xskill/git_lock.py +++ b/src/xskill/git_lock.py @@ -44,8 +44,19 @@ def run_git(args: list[str], cwd: str) -> tuple[int, str, str]: - r = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True) - return r.returncode, r.stdout.strip(), r.stderr.strip() + """Run git in *cwd*; always decode UTF-8 (Windows 默认 GBK 会在 git 输出含非 ASCII 时炸). + + subprocess 在解码失败时可能把 stdout/stderr 置为 None;调用方统一当空串处理。 + """ + r = subprocess.run( + ["git"] + args, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip() def init_skill_repo_on_baby(skill_dir: str, name: str, description: str) -> None: diff --git a/src/xskill/llm_client.py b/src/xskill/llm_client.py index e7b9c211..6cc8bb24 100644 --- a/src/xskill/llm_client.py +++ b/src/xskill/llm_client.py @@ -18,9 +18,12 @@ import os, json, logging, time from dataclasses import dataclass, field +from typing import Literal import numpy as np +EmbedApiStyle = Literal["multimodal", "openai"] + logger = logging.getLogger(__name__) @@ -143,12 +146,29 @@ def __repr__(self): # Embedding Client # ═══════════════════════════════════════════════════════════════════ +def _resolve_embed_api_style(cfg: dict, model: str) -> EmbedApiStyle: + """ARK 有两套 embedding 路径: + + - ``/embeddings`` — OpenAI 兼容,用于 ``doubao-embedding-large-text-*`` 等纯文本模型 + - ``/embeddings/multimodal`` — 用于 ``doubao-embedding-vision-*`` 等多模态模型 + + 可在 config 里显式写 ``embedding.api: openai | multimodal``;否则按模型名推断。 + """ + explicit = (cfg.get("api") or cfg.get("api_style") or "").strip().lower() + if explicit in ("multimodal", "openai"): + return explicit # type: ignore[return-value] + if "vision" in model.lower(): + return "multimodal" + return "openai" + + @dataclass class EmbedClient: base_url: str model: str api_key: str dim: int = 0 # 0 = 未探测 + api_style: EmbedApiStyle = "openai" _client: object = field(default=None, repr=False) @classmethod @@ -163,7 +183,10 @@ def from_config(cls, cfg: dict) -> "EmbedClient": dim = cfg.get("dim", 0) if not base_url or not model: raise ValueError("embedding.base_url 和 embedding.model 必须配置") - inst = cls(base_url=base_url, model=model, api_key=api_key, dim=dim) + api_style = _resolve_embed_api_style(cfg, model) + inst = cls( + base_url=base_url, model=model, api_key=api_key, dim=dim, api_style=api_style, + ) return inst def _get_session(self): @@ -175,26 +198,50 @@ def _get_session(self): logger.warning("T2S_SSL_VERIFY=false → Embedding HTTPS 证书验证已关闭") return self._client - def _call_api_single(self, text: str) -> list[float]: - """调用 ARK multimodal embedding 接口(单条)""" + def _post_json(self, path: str, body: dict) -> dict: session = self._get_session() - url = f"{self.base_url}/embeddings/multimodal" + url = f"{self.base_url}{path}" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", } - body = {"model": self.model, "input": [{"type": "text", "text": text}]} resp = session.post(url, json=body, headers=headers) resp.raise_for_status() - data = resp.json() + return resp.json() + + def _call_api_multimodal(self, text: str) -> list[float]: + """ARK multimodal:``doubao-embedding-vision-*`` 等""" + data = self._post_json( + "/embeddings/multimodal", + {"model": self.model, "input": [{"type": "text", "text": text}]}, + ) return data["data"]["embedding"] + def _call_api_openai(self, text: str) -> list[float]: + """ARK / OpenAI 兼容:``POST /embeddings``,``doubao-embedding-large-text-*`` 等""" + data = self._post_json( + "/embeddings", + {"model": self.model, "input": text}, + ) + items = data.get("data") or [] + if not items: + raise ValueError(f"embedding response missing data: {data!r}") + return items[0]["embedding"] + + def _call_api_single(self, text: str) -> list[float]: + if self.api_style == "multimodal": + return self._call_api_multimodal(text) + return self._call_api_openai(text) + def probe_dim(self) -> int: """发送测试文本,探测 embedding 维度""" if self.dim > 0: return self.dim - logger.info(f"探测 embedding 维度: {self.model} @ {self.base_url}") + logger.info( + "探测 embedding 维度: %s @ %s (api=%s)", + self.model, self.base_url, self.api_style, + ) vec = self._call_api_single("hello") self.dim = len(vec) logger.info(f"探测完成: dim={self.dim}") @@ -208,7 +255,7 @@ def encode(self, text: str) -> np.ndarray: return vec def encode_batch(self, texts: list[str]) -> np.ndarray: - """批量文本 → (n, dim) 矩阵,逐条调用 multimodal 端点""" + """批量文本 → (n, dim) 矩阵,逐条调用 embedding 端点""" from tqdm import tqdm all_vecs = [] @@ -229,7 +276,10 @@ def encode_batch(self, texts: list[str]) -> np.ndarray: return result def __repr__(self): - return f"EmbedClient(base_url={self.base_url}, model={self.model}, dim={self.dim})" + return ( + f"EmbedClient(base_url={self.base_url}, model={self.model}, " + f"dim={self.dim}, api_style={self.api_style})" + ) # ═══════════════════════════════════════════════════════════════════ diff --git a/tests/test_llm_client.py b/tests/test_llm_client.py index 9c53f193..fa8f4456 100644 --- a/tests/test_llm_client.py +++ b/tests/test_llm_client.py @@ -15,7 +15,7 @@ import pytest -from xskill.llm_client import LLMClient +from xskill.llm_client import LLMClient, EmbedClient, _resolve_embed_api_style class TestLLMClientDefaults: @@ -65,3 +65,21 @@ def test_missing_base_url_raises(self): def test_missing_model_raises(self): with pytest.raises(ValueError): LLMClient.from_config({"base_url": "http://x", "api_key": "k"}) + + +class TestEmbedApiStyle: + def test_text_model_defaults_openai(self): + assert _resolve_embed_api_style({}, "doubao-embedding-large-text-250515") == "openai" + + def test_vision_model_defaults_multimodal(self): + assert _resolve_embed_api_style({}, "doubao-embedding-vision-251215") == "multimodal" + + def test_explicit_api_override(self): + cfg = {"api": "multimodal"} + assert _resolve_embed_api_style(cfg, "doubao-embedding-large-text-250515") == "multimodal" + + def test_from_config_sets_api_style(self): + c = EmbedClient.from_config({ + "base_url": "http://x", "model": "doubao-embedding-large-text-250515", "api_key": "k", + }) + assert c.api_style == "openai"