kweaver-dolphin 0.1.0__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 (199) hide show
  1. DolphinLanguageSDK/__init__.py +58 -0
  2. dolphin/__init__.py +62 -0
  3. dolphin/cli/__init__.py +20 -0
  4. dolphin/cli/args/__init__.py +9 -0
  5. dolphin/cli/args/parser.py +567 -0
  6. dolphin/cli/builtin_agents/__init__.py +22 -0
  7. dolphin/cli/commands/__init__.py +4 -0
  8. dolphin/cli/interrupt/__init__.py +8 -0
  9. dolphin/cli/interrupt/handler.py +205 -0
  10. dolphin/cli/interrupt/keyboard.py +82 -0
  11. dolphin/cli/main.py +49 -0
  12. dolphin/cli/multimodal/__init__.py +34 -0
  13. dolphin/cli/multimodal/clipboard.py +327 -0
  14. dolphin/cli/multimodal/handler.py +249 -0
  15. dolphin/cli/multimodal/image_processor.py +214 -0
  16. dolphin/cli/multimodal/input_parser.py +149 -0
  17. dolphin/cli/runner/__init__.py +8 -0
  18. dolphin/cli/runner/runner.py +989 -0
  19. dolphin/cli/ui/__init__.py +10 -0
  20. dolphin/cli/ui/console.py +2795 -0
  21. dolphin/cli/ui/input.py +340 -0
  22. dolphin/cli/ui/layout.py +425 -0
  23. dolphin/cli/ui/stream_renderer.py +302 -0
  24. dolphin/cli/utils/__init__.py +8 -0
  25. dolphin/cli/utils/helpers.py +135 -0
  26. dolphin/cli/utils/version.py +49 -0
  27. dolphin/core/__init__.py +107 -0
  28. dolphin/core/agent/__init__.py +10 -0
  29. dolphin/core/agent/agent_state.py +69 -0
  30. dolphin/core/agent/base_agent.py +970 -0
  31. dolphin/core/code_block/__init__.py +0 -0
  32. dolphin/core/code_block/agent_init_block.py +0 -0
  33. dolphin/core/code_block/assign_block.py +98 -0
  34. dolphin/core/code_block/basic_code_block.py +1865 -0
  35. dolphin/core/code_block/explore_block.py +1327 -0
  36. dolphin/core/code_block/explore_block_v2.py +712 -0
  37. dolphin/core/code_block/explore_strategy.py +672 -0
  38. dolphin/core/code_block/judge_block.py +220 -0
  39. dolphin/core/code_block/prompt_block.py +32 -0
  40. dolphin/core/code_block/skill_call_deduplicator.py +291 -0
  41. dolphin/core/code_block/tool_block.py +129 -0
  42. dolphin/core/common/__init__.py +17 -0
  43. dolphin/core/common/constants.py +176 -0
  44. dolphin/core/common/enums.py +1173 -0
  45. dolphin/core/common/exceptions.py +133 -0
  46. dolphin/core/common/multimodal.py +539 -0
  47. dolphin/core/common/object_type.py +165 -0
  48. dolphin/core/common/output_format.py +432 -0
  49. dolphin/core/common/types.py +36 -0
  50. dolphin/core/config/__init__.py +16 -0
  51. dolphin/core/config/global_config.py +1289 -0
  52. dolphin/core/config/ontology_config.py +133 -0
  53. dolphin/core/context/__init__.py +12 -0
  54. dolphin/core/context/context.py +1580 -0
  55. dolphin/core/context/context_manager.py +161 -0
  56. dolphin/core/context/var_output.py +82 -0
  57. dolphin/core/context/variable_pool.py +356 -0
  58. dolphin/core/context_engineer/__init__.py +41 -0
  59. dolphin/core/context_engineer/config/__init__.py +5 -0
  60. dolphin/core/context_engineer/config/settings.py +402 -0
  61. dolphin/core/context_engineer/core/__init__.py +7 -0
  62. dolphin/core/context_engineer/core/budget_manager.py +327 -0
  63. dolphin/core/context_engineer/core/context_assembler.py +583 -0
  64. dolphin/core/context_engineer/core/context_manager.py +637 -0
  65. dolphin/core/context_engineer/core/tokenizer_service.py +260 -0
  66. dolphin/core/context_engineer/example/incremental_example.py +267 -0
  67. dolphin/core/context_engineer/example/traditional_example.py +334 -0
  68. dolphin/core/context_engineer/services/__init__.py +5 -0
  69. dolphin/core/context_engineer/services/compressor.py +399 -0
  70. dolphin/core/context_engineer/utils/__init__.py +6 -0
  71. dolphin/core/context_engineer/utils/context_utils.py +441 -0
  72. dolphin/core/context_engineer/utils/message_formatter.py +270 -0
  73. dolphin/core/context_engineer/utils/token_utils.py +139 -0
  74. dolphin/core/coroutine/__init__.py +15 -0
  75. dolphin/core/coroutine/context_snapshot.py +154 -0
  76. dolphin/core/coroutine/context_snapshot_profile.py +922 -0
  77. dolphin/core/coroutine/context_snapshot_store.py +268 -0
  78. dolphin/core/coroutine/execution_frame.py +145 -0
  79. dolphin/core/coroutine/execution_state_registry.py +161 -0
  80. dolphin/core/coroutine/resume_handle.py +101 -0
  81. dolphin/core/coroutine/step_result.py +101 -0
  82. dolphin/core/executor/__init__.py +18 -0
  83. dolphin/core/executor/debug_controller.py +630 -0
  84. dolphin/core/executor/dolphin_executor.py +1063 -0
  85. dolphin/core/executor/executor.py +624 -0
  86. dolphin/core/flags/__init__.py +27 -0
  87. dolphin/core/flags/definitions.py +49 -0
  88. dolphin/core/flags/manager.py +113 -0
  89. dolphin/core/hook/__init__.py +95 -0
  90. dolphin/core/hook/expression_evaluator.py +499 -0
  91. dolphin/core/hook/hook_dispatcher.py +380 -0
  92. dolphin/core/hook/hook_types.py +248 -0
  93. dolphin/core/hook/isolated_variable_pool.py +284 -0
  94. dolphin/core/interfaces.py +53 -0
  95. dolphin/core/llm/__init__.py +0 -0
  96. dolphin/core/llm/llm.py +495 -0
  97. dolphin/core/llm/llm_call.py +100 -0
  98. dolphin/core/llm/llm_client.py +1285 -0
  99. dolphin/core/llm/message_sanitizer.py +120 -0
  100. dolphin/core/logging/__init__.py +20 -0
  101. dolphin/core/logging/logger.py +526 -0
  102. dolphin/core/message/__init__.py +8 -0
  103. dolphin/core/message/compressor.py +749 -0
  104. dolphin/core/parser/__init__.py +8 -0
  105. dolphin/core/parser/parser.py +405 -0
  106. dolphin/core/runtime/__init__.py +10 -0
  107. dolphin/core/runtime/runtime_graph.py +926 -0
  108. dolphin/core/runtime/runtime_instance.py +446 -0
  109. dolphin/core/skill/__init__.py +14 -0
  110. dolphin/core/skill/context_retention.py +157 -0
  111. dolphin/core/skill/skill_function.py +686 -0
  112. dolphin/core/skill/skill_matcher.py +282 -0
  113. dolphin/core/skill/skillkit.py +700 -0
  114. dolphin/core/skill/skillset.py +72 -0
  115. dolphin/core/trajectory/__init__.py +10 -0
  116. dolphin/core/trajectory/recorder.py +189 -0
  117. dolphin/core/trajectory/trajectory.py +522 -0
  118. dolphin/core/utils/__init__.py +9 -0
  119. dolphin/core/utils/cache_kv.py +212 -0
  120. dolphin/core/utils/tools.py +340 -0
  121. dolphin/lib/__init__.py +93 -0
  122. dolphin/lib/debug/__init__.py +8 -0
  123. dolphin/lib/debug/visualizer.py +409 -0
  124. dolphin/lib/memory/__init__.py +28 -0
  125. dolphin/lib/memory/async_processor.py +220 -0
  126. dolphin/lib/memory/llm_calls.py +195 -0
  127. dolphin/lib/memory/manager.py +78 -0
  128. dolphin/lib/memory/sandbox.py +46 -0
  129. dolphin/lib/memory/storage.py +245 -0
  130. dolphin/lib/memory/utils.py +51 -0
  131. dolphin/lib/ontology/__init__.py +12 -0
  132. dolphin/lib/ontology/basic/__init__.py +0 -0
  133. dolphin/lib/ontology/basic/base.py +102 -0
  134. dolphin/lib/ontology/basic/concept.py +130 -0
  135. dolphin/lib/ontology/basic/object.py +11 -0
  136. dolphin/lib/ontology/basic/relation.py +63 -0
  137. dolphin/lib/ontology/datasource/__init__.py +27 -0
  138. dolphin/lib/ontology/datasource/datasource.py +66 -0
  139. dolphin/lib/ontology/datasource/oracle_datasource.py +338 -0
  140. dolphin/lib/ontology/datasource/sql.py +845 -0
  141. dolphin/lib/ontology/mapping.py +177 -0
  142. dolphin/lib/ontology/ontology.py +733 -0
  143. dolphin/lib/ontology/ontology_context.py +16 -0
  144. dolphin/lib/ontology/ontology_manager.py +107 -0
  145. dolphin/lib/skill_results/__init__.py +31 -0
  146. dolphin/lib/skill_results/cache_backend.py +559 -0
  147. dolphin/lib/skill_results/result_processor.py +181 -0
  148. dolphin/lib/skill_results/result_reference.py +179 -0
  149. dolphin/lib/skill_results/skillkit_hook.py +324 -0
  150. dolphin/lib/skill_results/strategies.py +328 -0
  151. dolphin/lib/skill_results/strategy_registry.py +150 -0
  152. dolphin/lib/skillkits/__init__.py +44 -0
  153. dolphin/lib/skillkits/agent_skillkit.py +155 -0
  154. dolphin/lib/skillkits/cognitive_skillkit.py +82 -0
  155. dolphin/lib/skillkits/env_skillkit.py +250 -0
  156. dolphin/lib/skillkits/mcp_adapter.py +616 -0
  157. dolphin/lib/skillkits/mcp_skillkit.py +771 -0
  158. dolphin/lib/skillkits/memory_skillkit.py +650 -0
  159. dolphin/lib/skillkits/noop_skillkit.py +31 -0
  160. dolphin/lib/skillkits/ontology_skillkit.py +89 -0
  161. dolphin/lib/skillkits/plan_act_skillkit.py +452 -0
  162. dolphin/lib/skillkits/resource/__init__.py +52 -0
  163. dolphin/lib/skillkits/resource/models/__init__.py +6 -0
  164. dolphin/lib/skillkits/resource/models/skill_config.py +109 -0
  165. dolphin/lib/skillkits/resource/models/skill_meta.py +127 -0
  166. dolphin/lib/skillkits/resource/resource_skillkit.py +393 -0
  167. dolphin/lib/skillkits/resource/skill_cache.py +215 -0
  168. dolphin/lib/skillkits/resource/skill_loader.py +395 -0
  169. dolphin/lib/skillkits/resource/skill_validator.py +406 -0
  170. dolphin/lib/skillkits/resource_skillkit.py +11 -0
  171. dolphin/lib/skillkits/search_skillkit.py +163 -0
  172. dolphin/lib/skillkits/sql_skillkit.py +274 -0
  173. dolphin/lib/skillkits/system_skillkit.py +509 -0
  174. dolphin/lib/skillkits/vm_skillkit.py +65 -0
  175. dolphin/lib/utils/__init__.py +9 -0
  176. dolphin/lib/utils/data_process.py +207 -0
  177. dolphin/lib/utils/handle_progress.py +178 -0
  178. dolphin/lib/utils/security.py +139 -0
  179. dolphin/lib/utils/text_retrieval.py +462 -0
  180. dolphin/lib/vm/__init__.py +11 -0
  181. dolphin/lib/vm/env_executor.py +895 -0
  182. dolphin/lib/vm/python_session_manager.py +453 -0
  183. dolphin/lib/vm/vm.py +610 -0
  184. dolphin/sdk/__init__.py +60 -0
  185. dolphin/sdk/agent/__init__.py +12 -0
  186. dolphin/sdk/agent/agent_factory.py +236 -0
  187. dolphin/sdk/agent/dolphin_agent.py +1106 -0
  188. dolphin/sdk/api/__init__.py +4 -0
  189. dolphin/sdk/runtime/__init__.py +8 -0
  190. dolphin/sdk/runtime/env.py +363 -0
  191. dolphin/sdk/skill/__init__.py +10 -0
  192. dolphin/sdk/skill/global_skills.py +706 -0
  193. dolphin/sdk/skill/traditional_toolkit.py +260 -0
  194. kweaver_dolphin-0.1.0.dist-info/METADATA +521 -0
  195. kweaver_dolphin-0.1.0.dist-info/RECORD +199 -0
  196. kweaver_dolphin-0.1.0.dist-info/WHEEL +5 -0
  197. kweaver_dolphin-0.1.0.dist-info/entry_points.txt +27 -0
  198. kweaver_dolphin-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
  199. kweaver_dolphin-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,215 @@
1
+ """TTL/LRU cache implementation for ResourceSkillkit.
2
+
3
+ This module provides caching mechanisms for skill metadata (Level 1)
4
+ to optimize performance and reduce disk I/O.
5
+ """
6
+
7
+ import time
8
+ from collections import OrderedDict
9
+ from dataclasses import dataclass
10
+ from threading import RLock
11
+ from typing import Generic, TypeVar, Optional, Dict, Any
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ @dataclass
17
+ class CacheEntry(Generic[T]):
18
+ """A single cache entry with TTL support.
19
+
20
+ Attributes:
21
+ value: The cached value
22
+ created_at: Unix timestamp when entry was created
23
+ last_accessed: Unix timestamp when entry was last accessed
24
+ """
25
+
26
+ value: T
27
+ created_at: float
28
+ last_accessed: float
29
+
30
+
31
+ class TTLLRUCache(Generic[T]):
32
+ """Thread-safe TTL + LRU cache implementation.
33
+
34
+ This cache combines Time-To-Live (TTL) expiration with Least Recently Used (LRU)
35
+ eviction policy. Entries expire after TTL seconds, and when the cache is full,
36
+ the least recently used entries are evicted first.
37
+
38
+ Attributes:
39
+ ttl_seconds: Time-to-live for cache entries in seconds
40
+ max_size: Maximum number of entries in cache
41
+ """
42
+
43
+ def __init__(self, ttl_seconds: int = 300, max_size: int = 100):
44
+ """Initialize the cache.
45
+
46
+ Args:
47
+ ttl_seconds: TTL for entries (default 5 minutes)
48
+ max_size: Maximum cache size (default 100 entries)
49
+ """
50
+ self._ttl_seconds = ttl_seconds
51
+ self._max_size = max_size
52
+ self._cache: OrderedDict[str, CacheEntry[T]] = OrderedDict()
53
+ self._lock = RLock()
54
+
55
+ def get(self, key: str) -> Optional[T]:
56
+ """Get a value from cache.
57
+
58
+ Args:
59
+ key: The cache key
60
+
61
+ Returns:
62
+ The cached value, or None if not found or expired
63
+ """
64
+ with self._lock:
65
+ entry = self._cache.get(key)
66
+ if entry is None:
67
+ return None
68
+
69
+ # Check TTL expiration
70
+ if self._is_expired(entry):
71
+ del self._cache[key]
72
+ return None
73
+
74
+ # Update last accessed time and move to end (most recently used)
75
+ entry.last_accessed = time.time()
76
+ self._cache.move_to_end(key)
77
+ return entry.value
78
+
79
+ def set(self, key: str, value: T) -> None:
80
+ """Set a value in cache.
81
+
82
+ Args:
83
+ key: The cache key
84
+ value: The value to cache
85
+ """
86
+ with self._lock:
87
+ now = time.time()
88
+
89
+ # If key exists, update it
90
+ if key in self._cache:
91
+ self._cache[key] = CacheEntry(
92
+ value=value, created_at=now, last_accessed=now
93
+ )
94
+ self._cache.move_to_end(key)
95
+ else:
96
+ # Evict if at capacity
97
+ while len(self._cache) >= self._max_size:
98
+ self._cache.popitem(last=False) # Remove oldest (LRU)
99
+
100
+ self._cache[key] = CacheEntry(
101
+ value=value, created_at=now, last_accessed=now
102
+ )
103
+
104
+ def delete(self, key: str) -> bool:
105
+ """Delete an entry from cache.
106
+
107
+ Args:
108
+ key: The cache key to delete
109
+
110
+ Returns:
111
+ True if entry was deleted, False if not found
112
+ """
113
+ with self._lock:
114
+ if key in self._cache:
115
+ del self._cache[key]
116
+ return True
117
+ return False
118
+
119
+ def clear(self) -> None:
120
+ """Clear all entries from cache."""
121
+ with self._lock:
122
+ self._cache.clear()
123
+
124
+ def cleanup_expired(self) -> int:
125
+ """Remove all expired entries from cache.
126
+
127
+ Returns:
128
+ Number of entries removed
129
+ """
130
+ with self._lock:
131
+ expired_keys = [
132
+ key for key, entry in self._cache.items() if self._is_expired(entry)
133
+ ]
134
+ for key in expired_keys:
135
+ del self._cache[key]
136
+ return len(expired_keys)
137
+
138
+ def _is_expired(self, entry: CacheEntry[T]) -> bool:
139
+ """Check if a cache entry is expired.
140
+
141
+ Args:
142
+ entry: The cache entry to check
143
+
144
+ Returns:
145
+ True if expired, False otherwise
146
+ """
147
+ return time.time() - entry.created_at > self._ttl_seconds
148
+
149
+ def __len__(self) -> int:
150
+ """Return the number of entries in cache."""
151
+ with self._lock:
152
+ return len(self._cache)
153
+
154
+ def __contains__(self, key: str) -> bool:
155
+ """Check if key exists and is not expired."""
156
+ return self.get(key) is not None
157
+
158
+ def keys(self) -> list:
159
+ """Return list of all non-expired keys."""
160
+ with self._lock:
161
+ return [key for key, entry in self._cache.items() if not self._is_expired(entry)]
162
+
163
+ def stats(self) -> Dict[str, Any]:
164
+ """Get cache statistics.
165
+
166
+ Returns:
167
+ Dictionary with cache stats
168
+ """
169
+ with self._lock:
170
+ expired_count = sum(
171
+ 1 for entry in self._cache.values() if self._is_expired(entry)
172
+ )
173
+ return {
174
+ "size": len(self._cache),
175
+ "max_size": self._max_size,
176
+ "ttl_seconds": self._ttl_seconds,
177
+ "expired_entries": expired_count,
178
+ "active_entries": len(self._cache) - expired_count,
179
+ }
180
+
181
+
182
+ class SkillMetaCache(TTLLRUCache):
183
+ """Specialized cache for Level 1 skill metadata.
184
+
185
+ This cache stores SkillMeta objects for quick access during
186
+ system prompt generation.
187
+ """
188
+
189
+ def __init__(self, ttl_seconds: int = 300, max_size: int = 100):
190
+ """Initialize the skill metadata cache.
191
+
192
+ Args:
193
+ ttl_seconds: TTL for entries (default 5 minutes)
194
+ max_size: Maximum cache size (default 100 skills)
195
+ """
196
+ super().__init__(ttl_seconds=ttl_seconds, max_size=max_size)
197
+
198
+
199
+ class SkillContentCache(TTLLRUCache):
200
+ """Specialized cache for Level 2 skill content.
201
+
202
+ This cache stores SkillContent objects for skills that have
203
+ been fully loaded. Note that Level 2 content persistence is
204
+ primarily handled via history bucket, this cache is for
205
+ internal optimization.
206
+ """
207
+
208
+ def __init__(self, ttl_seconds: int = 600, max_size: int = 50):
209
+ """Initialize the skill content cache.
210
+
211
+ Args:
212
+ ttl_seconds: TTL for entries (default 10 minutes)
213
+ max_size: Maximum cache size (default 50 skills)
214
+ """
215
+ super().__init__(ttl_seconds=ttl_seconds, max_size=max_size)
@@ -0,0 +1,395 @@
1
+ """SKILL.md loader for ResourceSkillkit.
2
+
3
+ This module provides the SkillLoader class for loading and parsing
4
+ SKILL.md files with support for the three-level progressive loading:
5
+ - Level 1: Metadata (name, description)
6
+ - Level 2: Full SKILL.md content
7
+ - Level 3: Resource files (scripts/, references/)
8
+ """
9
+
10
+ import re
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Optional, List, Tuple, Dict, Any
14
+
15
+ import yaml
16
+
17
+ from .models.skill_meta import SkillMeta, SkillContent
18
+ from .models.skill_config import ResourceSkillConfig
19
+ from .skill_validator import SkillValidator, ValidationResult, resolve_safe_path
20
+ from dolphin.core.logging.logger import get_logger
21
+
22
+ logger = get_logger("resource_skillkit")
23
+
24
+
25
+ class SkillLoaderError(Exception):
26
+ """Exception raised for skill loading errors."""
27
+
28
+ pass
29
+
30
+
31
+ class SkillLoader:
32
+ """Loader for SKILL.md files supporting progressive loading.
33
+
34
+ This loader handles:
35
+ - Scanning directories for skill packages
36
+ - Parsing YAML frontmatter and markdown body
37
+ - Level 1/2/3 content loading
38
+ - Validation and error handling
39
+ """
40
+
41
+ # Regex pattern for YAML frontmatter
42
+ FRONTMATTER_PATTERN = re.compile(
43
+ r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n(.*)$",
44
+ re.DOTALL
45
+ )
46
+
47
+ def __init__(self, config: Optional[ResourceSkillConfig] = None):
48
+ """Initialize the loader.
49
+
50
+ Args:
51
+ config: Optional configuration, uses defaults if not provided
52
+ """
53
+ self.config = config or ResourceSkillConfig()
54
+ self.validator = SkillValidator(self.config)
55
+
56
+ def scan_directories(self, base_path: Optional[Path] = None) -> List[SkillMeta]:
57
+ """Scan configured directories for skill packages.
58
+
59
+ Scans all configured directories and returns Level 1 metadata
60
+ for each valid skill found. Higher priority directories take
61
+ precedence for duplicate skill names.
62
+
63
+ Args:
64
+ base_path: Optional base path for resolving relative directories
65
+
66
+ Returns:
67
+ List of SkillMeta objects for found skills
68
+ """
69
+ skills: Dict[str, SkillMeta] = {} # name -> meta, for deduplication
70
+ directories = self.config.get_resolved_directories(base_path)
71
+
72
+ for directory in directories:
73
+ if not directory.exists():
74
+ logger.debug(f"Skill directory does not exist: {directory}")
75
+ continue
76
+
77
+ if not directory.is_dir():
78
+ logger.warning(f"Skill path is not a directory: {directory}")
79
+ continue
80
+
81
+ max_depth = max(1, int(self.config.max_scan_depth))
82
+ for item in self._iter_skill_dirs(directory, max_depth=max_depth):
83
+ try:
84
+ meta = self.load_metadata(item)
85
+ if meta and meta.name not in skills:
86
+ # First occurrence wins (higher priority directory)
87
+ skills[meta.name] = meta
88
+ logger.debug(f"Found skill: {meta.name} at {item}")
89
+ except Exception as e:
90
+ logger.warning(f"Failed to load skill from {item}: {e}")
91
+
92
+ return list(skills.values())
93
+
94
+ def _iter_skill_dirs(self, root: Path, max_depth: int) -> List[Path]:
95
+ """Iterate directories that contain SKILL.md under root, up to max_depth."""
96
+ found: List[Path] = []
97
+ stack: List[Tuple[Path, int]] = [(root, 0)]
98
+
99
+ while stack:
100
+ current, depth = stack.pop()
101
+ skill_md = current / "SKILL.md"
102
+ if skill_md.is_file():
103
+ found.append(current)
104
+ continue
105
+
106
+ if depth >= max_depth:
107
+ continue
108
+
109
+ try:
110
+ subdirs = [
111
+ p
112
+ for p in current.iterdir()
113
+ if p.is_dir()
114
+ and not p.name.startswith(".")
115
+ and p.name not in {"__pycache__", "scripts", "references"}
116
+ ]
117
+ except PermissionError:
118
+ continue
119
+
120
+ for sub in sorted(subdirs, key=lambda p: p.name, reverse=True):
121
+ stack.append((sub, depth + 1))
122
+
123
+ return sorted(found, key=lambda p: str(p))
124
+
125
+ def load_metadata(self, skill_dir: Path) -> Optional[SkillMeta]:
126
+ """Load Level 1 metadata from a skill directory.
127
+
128
+ Args:
129
+ skill_dir: Path to the skill directory
130
+
131
+ Returns:
132
+ SkillMeta if successful, None if failed
133
+ """
134
+ skill_md = skill_dir / "SKILL.md"
135
+
136
+ if not skill_md.is_file():
137
+ logger.warning(f"SKILL.md not found in {skill_dir}")
138
+ return None
139
+
140
+ # Security: reject symlinks to prevent path traversal
141
+ if skill_md.is_symlink():
142
+ logger.warning(f"SKILL.md is a symlink, skipping: {skill_md}")
143
+ return None
144
+
145
+ try:
146
+ size_validation = self.validator.validate_size(skill_md)
147
+ if not size_validation.is_valid:
148
+ logger.warning(
149
+ f"SKILL.md too large in {skill_dir}: {size_validation.errors}"
150
+ )
151
+ return None
152
+
153
+ content = skill_md.read_text(encoding="utf-8")
154
+ frontmatter, _ = self._parse_frontmatter(content)
155
+
156
+ if not frontmatter:
157
+ logger.warning(f"No frontmatter found in {skill_md}")
158
+ return None
159
+
160
+ # Validate frontmatter
161
+ validation = self.validator.validate_frontmatter(frontmatter)
162
+ if not validation.is_valid:
163
+ logger.warning(
164
+ f"Invalid frontmatter in {skill_md}: {validation.errors}"
165
+ )
166
+ return None
167
+
168
+ for warning in validation.warnings:
169
+ logger.debug(f"Frontmatter warning for {skill_md}: {warning}")
170
+
171
+ return SkillMeta(
172
+ name=frontmatter.get("name", ""),
173
+ description=frontmatter.get("description", ""),
174
+ base_path=str(skill_dir.resolve()),
175
+ version=frontmatter.get("version"),
176
+ tags=frontmatter.get("tags", []),
177
+ )
178
+
179
+ except Exception as e:
180
+ logger.error(f"Error loading metadata from {skill_md}: {e}")
181
+ return None
182
+
183
+ def load_content(self, skill_dir: Path) -> Optional[SkillContent]:
184
+ """Load Level 2 full content from a skill directory.
185
+
186
+ Args:
187
+ skill_dir: Path to the skill directory
188
+
189
+ Returns:
190
+ SkillContent if successful, None if failed
191
+ """
192
+ skill_md = skill_dir / "SKILL.md"
193
+
194
+ if not skill_md.is_file():
195
+ logger.warning(f"SKILL.md not found in {skill_dir}")
196
+ return None
197
+
198
+ # Security: reject symlinks to prevent path traversal
199
+ if skill_md.is_symlink():
200
+ logger.warning(f"SKILL.md is a symlink, skipping: {skill_md}")
201
+ return None
202
+
203
+ try:
204
+ size_validation = self.validator.validate_size(skill_dir)
205
+ if not size_validation.is_valid:
206
+ logger.warning(
207
+ f"Skill package too large in {skill_dir}: {size_validation.errors}"
208
+ )
209
+ return None
210
+
211
+ content = skill_md.read_text(encoding="utf-8")
212
+ frontmatter, body = self._parse_frontmatter(content)
213
+
214
+ if frontmatter is None:
215
+ # No frontmatter, treat entire content as body
216
+ frontmatter = {}
217
+ body = content
218
+
219
+ # List available scripts and references
220
+ scripts = self._list_directory_files(skill_dir / "scripts")
221
+ references = self._list_directory_files(skill_dir / "references")
222
+
223
+ return SkillContent(
224
+ frontmatter=frontmatter,
225
+ body=body.strip(),
226
+ available_scripts=scripts,
227
+ available_references=references,
228
+ )
229
+
230
+ except Exception as e:
231
+ logger.error(f"Error loading content from {skill_md}: {e}")
232
+ return None
233
+
234
+ def load_resource(
235
+ self, skill_dir: Path, resource_path: str
236
+ ) -> Tuple[Optional[str], Optional[str]]:
237
+ """Load Level 3 resource file content.
238
+
239
+ Args:
240
+ skill_dir: Path to the skill directory
241
+ resource_path: Relative path to resource file (e.g., "scripts/etl.py")
242
+
243
+ Returns:
244
+ Tuple of (content, error_message). Content is None if error.
245
+ """
246
+ full_path, error = resolve_safe_path(resource_path, skill_dir)
247
+ if error:
248
+ return None, error
249
+ if full_path is None:
250
+ return None, f"Invalid path: '{resource_path}'"
251
+
252
+ # Validate file type
253
+ validation = self.validator.validate_file_type(full_path)
254
+ if not validation.is_valid:
255
+ return None, validation.errors[0]
256
+
257
+ # Validate size
258
+ size_validation = self.validator.validate_size(full_path)
259
+ if not size_validation.is_valid:
260
+ return None, size_validation.errors[0]
261
+
262
+ try:
263
+ flags = os.O_RDONLY
264
+ nofollow = getattr(os, "O_NOFOLLOW", 0)
265
+ if nofollow:
266
+ flags |= nofollow
267
+ fd = os.open(str(full_path), flags)
268
+ with os.fdopen(fd, "r", encoding="utf-8") as f:
269
+ return f.read(), None
270
+ except UnicodeDecodeError:
271
+ return None, f"Cannot read '{resource_path}': not a text file"
272
+ except OSError as e:
273
+ return None, f"Error reading '{resource_path}': {e}"
274
+ except Exception as e:
275
+ return None, f"Error reading '{resource_path}': {e}"
276
+
277
+ def _parse_frontmatter(self, content: str) -> Tuple[Optional[Dict[str, Any]], str]:
278
+ """Parse YAML frontmatter from SKILL.md content.
279
+
280
+ Args:
281
+ content: The full SKILL.md file content
282
+
283
+ Returns:
284
+ Tuple of (frontmatter_dict, body_content)
285
+ frontmatter_dict is None if no valid frontmatter found
286
+ """
287
+ match = self.FRONTMATTER_PATTERN.match(content)
288
+
289
+ if not match:
290
+ return None, content
291
+
292
+ yaml_content = match.group(1)
293
+ body = match.group(2)
294
+
295
+ try:
296
+ frontmatter = yaml.safe_load(yaml_content)
297
+ if not isinstance(frontmatter, dict):
298
+ logger.warning("Frontmatter is not a valid YAML dictionary")
299
+ return None, content
300
+ return frontmatter, body
301
+ except yaml.YAMLError as e:
302
+ logger.warning(f"Failed to parse YAML frontmatter: {e}")
303
+ return None, content
304
+
305
+ def _list_directory_files(
306
+ self, directory: Path, max_depth: int = 2
307
+ ) -> List[str]:
308
+ """List files in a directory recursively.
309
+
310
+ Args:
311
+ directory: Directory to list
312
+ max_depth: Maximum recursion depth
313
+
314
+ Returns:
315
+ List of relative file paths
316
+ """
317
+ if not directory.is_dir():
318
+ return []
319
+
320
+ files = []
321
+ base_name = directory.name
322
+
323
+ def _scan(current_dir: Path, depth: int, prefix: str):
324
+ if depth > max_depth:
325
+ return
326
+
327
+ try:
328
+ for item in sorted(current_dir.iterdir()):
329
+ rel_path = f"{prefix}/{item.name}" if prefix else item.name
330
+
331
+ if item.is_file():
332
+ # Validate file type
333
+ if self.validator.validate_file_type(item).is_valid:
334
+ files.append(f"{base_name}/{rel_path}")
335
+ elif item.is_dir() and not item.name.startswith("."):
336
+ _scan(item, depth + 1, rel_path)
337
+ except PermissionError:
338
+ logger.debug(f"Permission denied accessing {current_dir}")
339
+
340
+ _scan(directory, 1, "")
341
+ return files
342
+
343
+ def find_skill_by_name(
344
+ self, name: str, base_path: Optional[Path] = None
345
+ ) -> Optional[Path]:
346
+ """Find a skill directory by name.
347
+
348
+ Args:
349
+ name: The skill name to find
350
+ base_path: Optional base path for resolving directories
351
+
352
+ Returns:
353
+ Path to skill directory if found, None otherwise
354
+ """
355
+ directories = self.config.get_resolved_directories(base_path)
356
+
357
+ for directory in directories:
358
+ if not directory.is_dir():
359
+ continue
360
+
361
+ skill_dir = directory / name
362
+ if skill_dir.is_dir() and (skill_dir / "SKILL.md").is_file():
363
+ return skill_dir
364
+
365
+ return None
366
+
367
+
368
+ def truncate_content(content: str, max_tokens: int, chars_per_token: int = 4) -> str:
369
+ """Truncate content to approximately max_tokens.
370
+
371
+ Args:
372
+ content: The content to truncate
373
+ max_tokens: Maximum number of tokens
374
+ chars_per_token: Estimated characters per token
375
+
376
+ Returns:
377
+ Truncated content with notice if truncated
378
+ """
379
+ max_chars = max_tokens * chars_per_token
380
+
381
+ if len(content) <= max_chars:
382
+ return content
383
+
384
+ # Find a good break point (end of line near limit)
385
+ truncated = content[:max_chars]
386
+ last_newline = truncated.rfind("\n")
387
+
388
+ if last_newline > max_chars * 0.8: # If newline is in last 20%
389
+ truncated = truncated[:last_newline]
390
+
391
+ return (
392
+ truncated
393
+ + "\n\n---\n"
394
+ + "*[Content truncated. Use `_load_skill_resource()` to load specific files.]*"
395
+ )