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.
Files changed (131) hide show
  1. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. yycode-0.3.2.dist-info/top_level.txt +4 -0
@@ -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