yycode 0.3.2__py3-none-any.whl
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.
- agent/__init__.py +33 -0
- agent/acp/__init__.py +2 -0
- agent/acp/approval_adapter.py +134 -0
- agent/acp/content_adapter.py +45 -0
- agent/acp/jsonrpc.py +92 -0
- agent/acp/server.py +197 -0
- agent/acp/session_manager.py +193 -0
- agent/acp/update_adapter.py +192 -0
- agent/app_paths.py +25 -0
- agent/approval.py +169 -0
- agent/cancellation.py +52 -0
- agent/change_snapshot.py +186 -0
- agent/context_compressor.py +116 -0
- agent/graph.py +137 -0
- agent/llm_retry.py +434 -0
- agent/logger.py +97 -0
- agent/lsp/__init__.py +13 -0
- agent/lsp/client.py +151 -0
- agent/lsp/manager.py +234 -0
- agent/lsp/types.py +119 -0
- agent/message_context_manager.py +322 -0
- agent/message_format.py +105 -0
- agent/nodes/llm_node.py +58 -0
- agent/nodes/state.py +12 -0
- agent/nodes/task_guard_node.py +50 -0
- agent/nodes/tools_node.py +70 -0
- agent/plan_snapshot.py +70 -0
- agent/providers/__init__.py +13 -0
- agent/providers/anthropic_provider.py +268 -0
- agent/providers/base.py +52 -0
- agent/providers/openai_provider.py +279 -0
- agent/providers/text_tool_calls.py +118 -0
- agent/runtime/approval_service.py +184 -0
- agent/runtime/context.py +43 -0
- agent/runtime/tool_events.py +368 -0
- agent/runtime/tool_executor.py +208 -0
- agent/runtime/tool_output.py +261 -0
- agent/runtime/tool_registry.py +91 -0
- agent/runtime/tool_scheduler.py +35 -0
- agent/runtime/workflow_guard.py +217 -0
- agent/runtime/workspace.py +5 -0
- agent/runtime/workspace_tools.py +22 -0
- agent/session.py +787 -0
- agent/session_replay.py +95 -0
- agent/session_store.py +186 -0
- agent/skills.py +254 -0
- agent/streaming.py +248 -0
- agent/subagent.py +634 -0
- agent/task_memory.py +340 -0
- agent/todo_manager.py +304 -0
- agent/tool_retry.py +106 -0
- agent/tui/__init__.py +14 -0
- agent/tui/app.py +1325 -0
- agent/tui/approval.py +53 -0
- agent/tui/commands/__init__.py +6 -0
- agent/tui/commands/base.py +48 -0
- agent/tui/commands/clear.py +37 -0
- agent/tui/commands/help.py +27 -0
- agent/tui/commands/registry.py +94 -0
- agent/tui/help_content.py +108 -0
- agent/tui/renderers.py +1961 -0
- agent/tui/runner.py +439 -0
- agent/tui/state.py +653 -0
- main.py +465 -0
- tools/__init__.py +50 -0
- tools/apply_patch.py +305 -0
- tools/bash.py +76 -0
- tools/diff_utils.py +139 -0
- tools/edit_file.py +40 -0
- tools/git_diff.py +72 -0
- tools/git_show.py +65 -0
- tools/grep.py +149 -0
- tools/list_files.py +90 -0
- tools/list_skills.py +24 -0
- tools/load_skill.py +30 -0
- tools/lsp_definition.py +27 -0
- tools/lsp_diagnostics.py +32 -0
- tools/lsp_document_symbols.py +23 -0
- tools/lsp_hover.py +29 -0
- tools/lsp_references.py +37 -0
- tools/lsp_utils.py +38 -0
- tools/lsp_workspace_symbols.py +23 -0
- tools/read_file.py +61 -0
- tools/read_many_files.py +50 -0
- tools/safety.py +50 -0
- tools/subagent.py +57 -0
- tools/todo.py +89 -0
- tools/verify.py +107 -0
- tools/web_search.py +250 -0
- tools/workspace.py +36 -0
- tools/workspace_state.py +60 -0
- tools/write_file.py +88 -0
- utils/__init__.py +5 -0
- utils/retry.py +13 -0
- yycode-0.3.2.data/data/skills/code_review.md +61 -0
- yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
- yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
- yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
- yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
- yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
- yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
- yycode-0.3.2.data/data/skills/plan.md +115 -0
- yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
- yycode-0.3.2.dist-info/METADATA +12 -0
- yycode-0.3.2.dist-info/RECORD +131 -0
- yycode-0.3.2.dist-info/WHEEL +5 -0
- yycode-0.3.2.dist-info/entry_points.txt +2 -0
- yycode-0.3.2.dist-info/top_level.txt +4 -0
agent/session_replay.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Replay view model derived from canonical session messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
|
|
9
|
+
|
|
10
|
+
from agent.task_memory import is_task_summary_memory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ReplayRole = Literal["user", "assistant", "tool", "system"]
|
|
14
|
+
ReplayKind = Literal["message", "summary", "tool", "context"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ReplayEvent:
|
|
19
|
+
"""One UI/protocol replay event derived from a stored message."""
|
|
20
|
+
|
|
21
|
+
role: ReplayRole
|
|
22
|
+
kind: ReplayKind
|
|
23
|
+
content: str
|
|
24
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_session_replay(messages: list[BaseMessage]) -> list[ReplayEvent]:
|
|
28
|
+
"""Build a display-friendly replay model from canonical session messages."""
|
|
29
|
+
events: list[ReplayEvent] = []
|
|
30
|
+
for index, message in enumerate(messages):
|
|
31
|
+
metadata = {
|
|
32
|
+
"message_index": index,
|
|
33
|
+
"message_type": type(message).__name__,
|
|
34
|
+
}
|
|
35
|
+
metadata.update(getattr(message, "additional_kwargs", {}) or {})
|
|
36
|
+
if is_task_summary_memory(message):
|
|
37
|
+
events.append(
|
|
38
|
+
ReplayEvent(
|
|
39
|
+
role="system",
|
|
40
|
+
kind="summary",
|
|
41
|
+
content=_message_text(message),
|
|
42
|
+
metadata=metadata,
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
elif isinstance(message, HumanMessage):
|
|
46
|
+
events.append(
|
|
47
|
+
ReplayEvent(
|
|
48
|
+
role="user",
|
|
49
|
+
kind="message",
|
|
50
|
+
content=_message_text(message),
|
|
51
|
+
metadata=metadata,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
elif isinstance(message, AIMessage):
|
|
55
|
+
content = _message_text(message)
|
|
56
|
+
if not content.strip():
|
|
57
|
+
continue
|
|
58
|
+
events.append(
|
|
59
|
+
ReplayEvent(
|
|
60
|
+
role="assistant",
|
|
61
|
+
kind="message",
|
|
62
|
+
content=content,
|
|
63
|
+
metadata=metadata,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
elif isinstance(message, ToolMessage):
|
|
67
|
+
content = _message_text(message)
|
|
68
|
+
if not _should_replay_tool_message(content, metadata):
|
|
69
|
+
continue
|
|
70
|
+
metadata["tool_name"] = message.name or ""
|
|
71
|
+
metadata["tool_call_id"] = message.tool_call_id
|
|
72
|
+
events.append(
|
|
73
|
+
ReplayEvent(
|
|
74
|
+
role="tool",
|
|
75
|
+
kind="tool",
|
|
76
|
+
content=content,
|
|
77
|
+
metadata=metadata,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
return events
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _should_replay_tool_message(content: str, metadata: dict[str, Any]) -> bool:
|
|
84
|
+
if metadata.get("context_compressed"):
|
|
85
|
+
return True
|
|
86
|
+
return len(content) <= 2_000
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _message_text(message: BaseMessage) -> str:
|
|
90
|
+
content = getattr(message, "content", "")
|
|
91
|
+
if isinstance(content, str):
|
|
92
|
+
return content
|
|
93
|
+
if isinstance(content, list):
|
|
94
|
+
return "\n".join(str(item) for item in content)
|
|
95
|
+
return str(content)
|
agent/session_store.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Persistence for canonical session message history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SESSION_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SessionStoreError(RuntimeError):
|
|
21
|
+
"""Raised when persisted session data cannot be safely used."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class SessionRecord:
|
|
26
|
+
"""Small listing record for a persisted session."""
|
|
27
|
+
|
|
28
|
+
session_id: str
|
|
29
|
+
path: Path
|
|
30
|
+
updated_at: str
|
|
31
|
+
workdir: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SessionStore:
|
|
35
|
+
"""Abstract session message store."""
|
|
36
|
+
|
|
37
|
+
def load(self, session_id: str) -> list[BaseMessage]:
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
def save(
|
|
41
|
+
self,
|
|
42
|
+
session_id: str,
|
|
43
|
+
messages: list[BaseMessage],
|
|
44
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
def delete(self, session_id: str) -> None:
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def list_sessions(self) -> list[SessionRecord]:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FileSessionStore(SessionStore):
|
|
56
|
+
"""File-backed message store grouped by workspace hash."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
app_root: Path,
|
|
61
|
+
workdir: Path,
|
|
62
|
+
root: Path | str | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.app_root = app_root.expanduser().resolve()
|
|
65
|
+
self.workdir = workdir.expanduser().resolve()
|
|
66
|
+
raw_root = root or os.environ.get("YOYO_SESSION_DIR")
|
|
67
|
+
self.root = Path(raw_root).expanduser().resolve() if raw_root else self.app_root / "sessions"
|
|
68
|
+
self.workspace_hash = workspace_hash(self.workdir)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def workspace_dir(self) -> Path:
|
|
72
|
+
return self.root / self.workspace_hash
|
|
73
|
+
|
|
74
|
+
def load(self, session_id: str) -> list[BaseMessage]:
|
|
75
|
+
path = self._session_path(session_id)
|
|
76
|
+
if not path.exists():
|
|
77
|
+
return []
|
|
78
|
+
try:
|
|
79
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
80
|
+
except json.JSONDecodeError as exc:
|
|
81
|
+
raise SessionStoreError(f"Session file is not valid JSON: {path}") from exc
|
|
82
|
+
|
|
83
|
+
if payload.get("version") != SESSION_VERSION:
|
|
84
|
+
raise SessionStoreError(f"Unsupported session file version: {payload.get('version')}")
|
|
85
|
+
|
|
86
|
+
saved_workdir = Path(str(payload.get("workdir", ""))).expanduser().resolve()
|
|
87
|
+
if saved_workdir != self.workdir:
|
|
88
|
+
raise SessionStoreError(
|
|
89
|
+
f"Session workdir mismatch: saved {saved_workdir}, current {self.workdir}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
return list(messages_from_dict(payload.get("messages", [])))
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
raise SessionStoreError(f"Could not deserialize session messages: {path}") from exc
|
|
96
|
+
|
|
97
|
+
def save(
|
|
98
|
+
self,
|
|
99
|
+
session_id: str,
|
|
100
|
+
messages: list[BaseMessage],
|
|
101
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
path = self._session_path(session_id)
|
|
104
|
+
now = _utc_now()
|
|
105
|
+
existing = self._read_existing(path)
|
|
106
|
+
payload = {
|
|
107
|
+
"version": SESSION_VERSION,
|
|
108
|
+
"session_id": session_id,
|
|
109
|
+
"created_at": existing.get("created_at") or now,
|
|
110
|
+
"updated_at": now,
|
|
111
|
+
"workdir": str(self.workdir),
|
|
112
|
+
"workspace_hash": self.workspace_hash,
|
|
113
|
+
"app_root": str(self.app_root),
|
|
114
|
+
"model": (metadata or {}).get("model"),
|
|
115
|
+
"messages": messages_to_dict(messages),
|
|
116
|
+
}
|
|
117
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
_atomic_write_json(path, payload)
|
|
119
|
+
|
|
120
|
+
def delete(self, session_id: str) -> None:
|
|
121
|
+
path = self._session_path(session_id)
|
|
122
|
+
if path.exists():
|
|
123
|
+
path.unlink()
|
|
124
|
+
|
|
125
|
+
def list_sessions(self) -> list[SessionRecord]:
|
|
126
|
+
if not self.workspace_dir.exists():
|
|
127
|
+
return []
|
|
128
|
+
records: list[SessionRecord] = []
|
|
129
|
+
for path in sorted(self.workspace_dir.glob("*.json")):
|
|
130
|
+
payload = self._read_existing(path)
|
|
131
|
+
records.append(
|
|
132
|
+
SessionRecord(
|
|
133
|
+
session_id=str(payload.get("session_id") or path.stem),
|
|
134
|
+
path=path,
|
|
135
|
+
updated_at=str(payload.get("updated_at") or ""),
|
|
136
|
+
workdir=str(payload.get("workdir") or ""),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return records
|
|
140
|
+
|
|
141
|
+
def _session_path(self, session_id: str) -> Path:
|
|
142
|
+
safe_id = _safe_session_id(session_id)
|
|
143
|
+
return self.workspace_dir / f"{safe_id}.json"
|
|
144
|
+
|
|
145
|
+
def _read_existing(self, path: Path) -> dict[str, Any]:
|
|
146
|
+
if not path.exists():
|
|
147
|
+
return {}
|
|
148
|
+
try:
|
|
149
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
return {}
|
|
152
|
+
return payload if isinstance(payload, dict) else {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def workspace_hash(workdir: Path) -> str:
|
|
156
|
+
"""Return a short stable hash for a resolved workspace path."""
|
|
157
|
+
normalized = str(workdir.expanduser().resolve())
|
|
158
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _safe_session_id(session_id: str) -> str:
|
|
162
|
+
if not session_id or session_id in {".", ".."}:
|
|
163
|
+
raise SessionStoreError("Session id must not be empty")
|
|
164
|
+
if any(separator in session_id for separator in ("/", "\\")):
|
|
165
|
+
raise SessionStoreError("Session id must not contain path separators")
|
|
166
|
+
return session_id
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _utc_now() -> str:
|
|
170
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
174
|
+
data = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
175
|
+
with tempfile.NamedTemporaryFile(
|
|
176
|
+
"w",
|
|
177
|
+
encoding="utf-8",
|
|
178
|
+
dir=path.parent,
|
|
179
|
+
prefix=f".{path.name}.",
|
|
180
|
+
suffix=".tmp",
|
|
181
|
+
delete=False,
|
|
182
|
+
) as tmp:
|
|
183
|
+
tmp.write(data)
|
|
184
|
+
tmp.write("\n")
|
|
185
|
+
tmp_path = Path(tmp.name)
|
|
186
|
+
tmp_path.replace(path)
|
agent/skills.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Skill discovery and loading utilities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MAX_SKILL_CHARS = 8_000
|
|
11
|
+
ALL_SKILLS_TOKENS = {"all", "*"}
|
|
12
|
+
DEFAULT_SKILL_DIRS = ["skills"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class LoadedSkill:
|
|
17
|
+
"""A loaded skill document."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
description: str
|
|
21
|
+
path: Path
|
|
22
|
+
content: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_skill_paths(raw_paths: Optional[str]) -> list[str]:
|
|
26
|
+
"""Parse a comma, newline, or os.pathsep separated string."""
|
|
27
|
+
if not raw_paths:
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
paths = []
|
|
31
|
+
for chunk in raw_paths.replace("\n", ",").replace(os.pathsep, ",").split(","):
|
|
32
|
+
path = chunk.strip()
|
|
33
|
+
if path:
|
|
34
|
+
paths.append(path)
|
|
35
|
+
return paths
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SkillRegistry:
|
|
39
|
+
"""Discover and load skills for a workspace."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, workdir: Path, skill_dirs: Optional[Iterable[str]] = None):
|
|
42
|
+
self.workdir = workdir
|
|
43
|
+
dirs = list(skill_dirs) if skill_dirs is not None else DEFAULT_SKILL_DIRS
|
|
44
|
+
self.search_dirs = [_resolve_path(path, workdir) for path in dirs]
|
|
45
|
+
|
|
46
|
+
def list_skills(self) -> list[LoadedSkill]:
|
|
47
|
+
"""Return metadata for all discoverable skills."""
|
|
48
|
+
skills: list[LoadedSkill] = []
|
|
49
|
+
seen_names: set[str] = set()
|
|
50
|
+
seen_paths: set[Path] = set()
|
|
51
|
+
|
|
52
|
+
for skill_file in _iter_all_skill_files(self.search_dirs):
|
|
53
|
+
if skill_file in seen_paths:
|
|
54
|
+
continue
|
|
55
|
+
skill = _load_skill_file(skill_file)
|
|
56
|
+
if not skill or skill.name in seen_names:
|
|
57
|
+
continue
|
|
58
|
+
skills.append(skill)
|
|
59
|
+
seen_names.add(skill.name)
|
|
60
|
+
seen_paths.add(skill_file)
|
|
61
|
+
|
|
62
|
+
return sorted(skills, key=lambda skill: skill.name)
|
|
63
|
+
|
|
64
|
+
def load_skills(self, refs: Iterable[str]) -> list[LoadedSkill]:
|
|
65
|
+
"""Load skills by name, path, or all token."""
|
|
66
|
+
loaded: list[LoadedSkill] = []
|
|
67
|
+
seen_paths: set[Path] = set()
|
|
68
|
+
|
|
69
|
+
for ref in refs:
|
|
70
|
+
for skill_file in _resolve_skill_files(ref, self.workdir, self.search_dirs):
|
|
71
|
+
if skill_file in seen_paths:
|
|
72
|
+
continue
|
|
73
|
+
skill = _load_skill_file(skill_file)
|
|
74
|
+
if skill:
|
|
75
|
+
loaded.append(skill)
|
|
76
|
+
seen_paths.add(skill_file)
|
|
77
|
+
|
|
78
|
+
return loaded
|
|
79
|
+
|
|
80
|
+
def format_skill_list(self) -> str:
|
|
81
|
+
"""Format skill metadata for tool output."""
|
|
82
|
+
skills = self.list_skills()
|
|
83
|
+
if not skills:
|
|
84
|
+
return "No skills found."
|
|
85
|
+
|
|
86
|
+
lines = ["Available skills:"]
|
|
87
|
+
for skill in skills:
|
|
88
|
+
description = skill.description or "(no description)"
|
|
89
|
+
lines.append(f"- {skill.name}: {description}")
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
def format_skill_catalog_prompt(self) -> str:
|
|
93
|
+
"""Format skill metadata for prompt injection without full content."""
|
|
94
|
+
skills = self.list_skills()
|
|
95
|
+
if not skills:
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
lines = [
|
|
99
|
+
"Available local skills:",
|
|
100
|
+
"You only have skill names and descriptions by default.",
|
|
101
|
+
"Call load_skill to load the full instructions for any skill you want to use.",
|
|
102
|
+
]
|
|
103
|
+
for skill in skills:
|
|
104
|
+
description = skill.description or "(no description)"
|
|
105
|
+
lines.append(f"- {skill.name}: {description}")
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
def format_loaded_skills(self, refs: Iterable[str]) -> str:
|
|
109
|
+
"""Format full loaded skills for tool output."""
|
|
110
|
+
skills = self.load_skills(refs)
|
|
111
|
+
if not skills:
|
|
112
|
+
return "No matching skills found."
|
|
113
|
+
|
|
114
|
+
sections = ["Loaded skills:"]
|
|
115
|
+
for skill in skills:
|
|
116
|
+
content = skill.content
|
|
117
|
+
if len(content) > MAX_SKILL_CHARS:
|
|
118
|
+
content = content[:MAX_SKILL_CHARS] + "\n\n[Skill content truncated.]"
|
|
119
|
+
sections.append(f"## {skill.name}")
|
|
120
|
+
sections.append(f"Source: {skill.path}")
|
|
121
|
+
if skill.description:
|
|
122
|
+
sections.append(f"Description: {skill.description}")
|
|
123
|
+
sections.append("")
|
|
124
|
+
sections.append(content)
|
|
125
|
+
sections.append("")
|
|
126
|
+
return "\n".join(sections).rstrip()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_skills(
|
|
130
|
+
skill_paths: Iterable[str],
|
|
131
|
+
workdir: Path,
|
|
132
|
+
skill_dirs: Optional[Iterable[str]] = None,
|
|
133
|
+
) -> list[LoadedSkill]:
|
|
134
|
+
"""Backward-compatible wrapper for loading skills."""
|
|
135
|
+
return SkillRegistry(workdir, skill_dirs).load_skills(skill_paths)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def discover_skills(workdir: Path, skill_dirs: Optional[Iterable[str]] = None) -> list[LoadedSkill]:
|
|
139
|
+
"""Discover all skills visible to the workspace."""
|
|
140
|
+
return SkillRegistry(workdir, skill_dirs).list_skills()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_path(raw_path: str, workdir: Path) -> Path:
|
|
144
|
+
path = Path(raw_path).expanduser()
|
|
145
|
+
if not path.is_absolute():
|
|
146
|
+
path = workdir / path
|
|
147
|
+
return path.resolve()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _resolve_skill_files(raw_ref: str, workdir: Path, search_dirs: list[Path]) -> list[Path]:
|
|
151
|
+
ref = raw_ref.strip()
|
|
152
|
+
if ref.lower() in ALL_SKILLS_TOKENS:
|
|
153
|
+
return _iter_all_skill_files(search_dirs)
|
|
154
|
+
|
|
155
|
+
explicit_path = _resolve_path(ref, workdir)
|
|
156
|
+
if explicit_path.exists():
|
|
157
|
+
return _iter_skill_files(explicit_path)
|
|
158
|
+
|
|
159
|
+
for search_dir in search_dirs:
|
|
160
|
+
for candidate in _named_skill_candidates(ref, search_dir):
|
|
161
|
+
if candidate.exists():
|
|
162
|
+
return _iter_skill_files(candidate)
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _named_skill_candidates(name: str, search_dir: Path) -> list[Path]:
|
|
167
|
+
return [
|
|
168
|
+
search_dir / name,
|
|
169
|
+
search_dir / f"{name}.md",
|
|
170
|
+
search_dir / name / "SKILL.md",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _iter_all_skill_files(search_dirs: list[Path]) -> list[Path]:
|
|
175
|
+
skill_files: list[Path] = []
|
|
176
|
+
seen_paths: set[Path] = set()
|
|
177
|
+
|
|
178
|
+
for search_dir in search_dirs:
|
|
179
|
+
if not search_dir.is_dir():
|
|
180
|
+
continue
|
|
181
|
+
for skill_file in _iter_skill_files(search_dir):
|
|
182
|
+
if skill_file not in seen_paths:
|
|
183
|
+
skill_files.append(skill_file)
|
|
184
|
+
seen_paths.add(skill_file)
|
|
185
|
+
for child in sorted(search_dir.iterdir()):
|
|
186
|
+
if not child.is_dir():
|
|
187
|
+
continue
|
|
188
|
+
for skill_file in _iter_skill_files(child):
|
|
189
|
+
if skill_file not in seen_paths:
|
|
190
|
+
skill_files.append(skill_file)
|
|
191
|
+
seen_paths.add(skill_file)
|
|
192
|
+
return skill_files
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _iter_skill_files(path: Path) -> list[Path]:
|
|
196
|
+
if path.is_file():
|
|
197
|
+
return [path]
|
|
198
|
+
if not path.is_dir():
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
skill_md = path / "SKILL.md"
|
|
202
|
+
if skill_md.is_file():
|
|
203
|
+
return [skill_md]
|
|
204
|
+
return sorted(p for p in path.glob("*.md") if p.is_file())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _load_skill_file(path: Path) -> Optional[LoadedSkill]:
|
|
208
|
+
content = _read_skill_file(path)
|
|
209
|
+
if not content:
|
|
210
|
+
return None
|
|
211
|
+
metadata, body = _parse_skill_content(content)
|
|
212
|
+
return LoadedSkill(
|
|
213
|
+
name=metadata.get("name") or _skill_name(path),
|
|
214
|
+
description=metadata.get("description", ""),
|
|
215
|
+
path=path,
|
|
216
|
+
content=body,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _read_skill_file(path: Path) -> str:
|
|
221
|
+
try:
|
|
222
|
+
return path.read_text(encoding="utf-8", errors="backslashreplace").strip()
|
|
223
|
+
except OSError:
|
|
224
|
+
return ""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _skill_name(path: Path) -> str:
|
|
228
|
+
if path.name == "SKILL.md":
|
|
229
|
+
return path.parent.name
|
|
230
|
+
return path.stem
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_skill_content(content: str) -> tuple[dict[str, str], str]:
|
|
234
|
+
if not content.startswith("---\n"):
|
|
235
|
+
return {}, content
|
|
236
|
+
|
|
237
|
+
match = re.match(r"^---\n(.*?)\n---\n?(.*)$", content, re.DOTALL)
|
|
238
|
+
if not match:
|
|
239
|
+
return {}, content
|
|
240
|
+
|
|
241
|
+
raw_metadata, body = match.groups()
|
|
242
|
+
metadata: dict[str, str] = {}
|
|
243
|
+
for line in raw_metadata.splitlines():
|
|
244
|
+
if ":" not in line:
|
|
245
|
+
continue
|
|
246
|
+
key, value = line.split(":", 1)
|
|
247
|
+
metadata[key.strip()] = _strip_quotes(value.strip())
|
|
248
|
+
return metadata, body.strip()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _strip_quotes(value: str) -> str:
|
|
252
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
253
|
+
return value[1:-1]
|
|
254
|
+
return value
|