Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
93 changes: 93 additions & 0 deletions scripts/cursor_import.py
Original file line number Diff line number Diff line change
@@ -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())
52 changes: 52 additions & 0 deletions scripts/cursor_setup.ps1
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion src/xskill/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
15 changes: 13 additions & 2 deletions src/xskill/git_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
68 changes: 59 additions & 9 deletions src/xskill/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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}")
Expand All @@ -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 = []

Expand All @@ -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})"
)


# ═══════════════════════════════════════════════════════════════════
Expand Down
20 changes: 19 additions & 1 deletion tests/test_llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Loading