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.
- DolphinLanguageSDK/__init__.py +58 -0
- dolphin/__init__.py +62 -0
- dolphin/cli/__init__.py +20 -0
- dolphin/cli/args/__init__.py +9 -0
- dolphin/cli/args/parser.py +567 -0
- dolphin/cli/builtin_agents/__init__.py +22 -0
- dolphin/cli/commands/__init__.py +4 -0
- dolphin/cli/interrupt/__init__.py +8 -0
- dolphin/cli/interrupt/handler.py +205 -0
- dolphin/cli/interrupt/keyboard.py +82 -0
- dolphin/cli/main.py +49 -0
- dolphin/cli/multimodal/__init__.py +34 -0
- dolphin/cli/multimodal/clipboard.py +327 -0
- dolphin/cli/multimodal/handler.py +249 -0
- dolphin/cli/multimodal/image_processor.py +214 -0
- dolphin/cli/multimodal/input_parser.py +149 -0
- dolphin/cli/runner/__init__.py +8 -0
- dolphin/cli/runner/runner.py +989 -0
- dolphin/cli/ui/__init__.py +10 -0
- dolphin/cli/ui/console.py +2795 -0
- dolphin/cli/ui/input.py +340 -0
- dolphin/cli/ui/layout.py +425 -0
- dolphin/cli/ui/stream_renderer.py +302 -0
- dolphin/cli/utils/__init__.py +8 -0
- dolphin/cli/utils/helpers.py +135 -0
- dolphin/cli/utils/version.py +49 -0
- dolphin/core/__init__.py +107 -0
- dolphin/core/agent/__init__.py +10 -0
- dolphin/core/agent/agent_state.py +69 -0
- dolphin/core/agent/base_agent.py +970 -0
- dolphin/core/code_block/__init__.py +0 -0
- dolphin/core/code_block/agent_init_block.py +0 -0
- dolphin/core/code_block/assign_block.py +98 -0
- dolphin/core/code_block/basic_code_block.py +1865 -0
- dolphin/core/code_block/explore_block.py +1327 -0
- dolphin/core/code_block/explore_block_v2.py +712 -0
- dolphin/core/code_block/explore_strategy.py +672 -0
- dolphin/core/code_block/judge_block.py +220 -0
- dolphin/core/code_block/prompt_block.py +32 -0
- dolphin/core/code_block/skill_call_deduplicator.py +291 -0
- dolphin/core/code_block/tool_block.py +129 -0
- dolphin/core/common/__init__.py +17 -0
- dolphin/core/common/constants.py +176 -0
- dolphin/core/common/enums.py +1173 -0
- dolphin/core/common/exceptions.py +133 -0
- dolphin/core/common/multimodal.py +539 -0
- dolphin/core/common/object_type.py +165 -0
- dolphin/core/common/output_format.py +432 -0
- dolphin/core/common/types.py +36 -0
- dolphin/core/config/__init__.py +16 -0
- dolphin/core/config/global_config.py +1289 -0
- dolphin/core/config/ontology_config.py +133 -0
- dolphin/core/context/__init__.py +12 -0
- dolphin/core/context/context.py +1580 -0
- dolphin/core/context/context_manager.py +161 -0
- dolphin/core/context/var_output.py +82 -0
- dolphin/core/context/variable_pool.py +356 -0
- dolphin/core/context_engineer/__init__.py +41 -0
- dolphin/core/context_engineer/config/__init__.py +5 -0
- dolphin/core/context_engineer/config/settings.py +402 -0
- dolphin/core/context_engineer/core/__init__.py +7 -0
- dolphin/core/context_engineer/core/budget_manager.py +327 -0
- dolphin/core/context_engineer/core/context_assembler.py +583 -0
- dolphin/core/context_engineer/core/context_manager.py +637 -0
- dolphin/core/context_engineer/core/tokenizer_service.py +260 -0
- dolphin/core/context_engineer/example/incremental_example.py +267 -0
- dolphin/core/context_engineer/example/traditional_example.py +334 -0
- dolphin/core/context_engineer/services/__init__.py +5 -0
- dolphin/core/context_engineer/services/compressor.py +399 -0
- dolphin/core/context_engineer/utils/__init__.py +6 -0
- dolphin/core/context_engineer/utils/context_utils.py +441 -0
- dolphin/core/context_engineer/utils/message_formatter.py +270 -0
- dolphin/core/context_engineer/utils/token_utils.py +139 -0
- dolphin/core/coroutine/__init__.py +15 -0
- dolphin/core/coroutine/context_snapshot.py +154 -0
- dolphin/core/coroutine/context_snapshot_profile.py +922 -0
- dolphin/core/coroutine/context_snapshot_store.py +268 -0
- dolphin/core/coroutine/execution_frame.py +145 -0
- dolphin/core/coroutine/execution_state_registry.py +161 -0
- dolphin/core/coroutine/resume_handle.py +101 -0
- dolphin/core/coroutine/step_result.py +101 -0
- dolphin/core/executor/__init__.py +18 -0
- dolphin/core/executor/debug_controller.py +630 -0
- dolphin/core/executor/dolphin_executor.py +1063 -0
- dolphin/core/executor/executor.py +624 -0
- dolphin/core/flags/__init__.py +27 -0
- dolphin/core/flags/definitions.py +49 -0
- dolphin/core/flags/manager.py +113 -0
- dolphin/core/hook/__init__.py +95 -0
- dolphin/core/hook/expression_evaluator.py +499 -0
- dolphin/core/hook/hook_dispatcher.py +380 -0
- dolphin/core/hook/hook_types.py +248 -0
- dolphin/core/hook/isolated_variable_pool.py +284 -0
- dolphin/core/interfaces.py +53 -0
- dolphin/core/llm/__init__.py +0 -0
- dolphin/core/llm/llm.py +495 -0
- dolphin/core/llm/llm_call.py +100 -0
- dolphin/core/llm/llm_client.py +1285 -0
- dolphin/core/llm/message_sanitizer.py +120 -0
- dolphin/core/logging/__init__.py +20 -0
- dolphin/core/logging/logger.py +526 -0
- dolphin/core/message/__init__.py +8 -0
- dolphin/core/message/compressor.py +749 -0
- dolphin/core/parser/__init__.py +8 -0
- dolphin/core/parser/parser.py +405 -0
- dolphin/core/runtime/__init__.py +10 -0
- dolphin/core/runtime/runtime_graph.py +926 -0
- dolphin/core/runtime/runtime_instance.py +446 -0
- dolphin/core/skill/__init__.py +14 -0
- dolphin/core/skill/context_retention.py +157 -0
- dolphin/core/skill/skill_function.py +686 -0
- dolphin/core/skill/skill_matcher.py +282 -0
- dolphin/core/skill/skillkit.py +700 -0
- dolphin/core/skill/skillset.py +72 -0
- dolphin/core/trajectory/__init__.py +10 -0
- dolphin/core/trajectory/recorder.py +189 -0
- dolphin/core/trajectory/trajectory.py +522 -0
- dolphin/core/utils/__init__.py +9 -0
- dolphin/core/utils/cache_kv.py +212 -0
- dolphin/core/utils/tools.py +340 -0
- dolphin/lib/__init__.py +93 -0
- dolphin/lib/debug/__init__.py +8 -0
- dolphin/lib/debug/visualizer.py +409 -0
- dolphin/lib/memory/__init__.py +28 -0
- dolphin/lib/memory/async_processor.py +220 -0
- dolphin/lib/memory/llm_calls.py +195 -0
- dolphin/lib/memory/manager.py +78 -0
- dolphin/lib/memory/sandbox.py +46 -0
- dolphin/lib/memory/storage.py +245 -0
- dolphin/lib/memory/utils.py +51 -0
- dolphin/lib/ontology/__init__.py +12 -0
- dolphin/lib/ontology/basic/__init__.py +0 -0
- dolphin/lib/ontology/basic/base.py +102 -0
- dolphin/lib/ontology/basic/concept.py +130 -0
- dolphin/lib/ontology/basic/object.py +11 -0
- dolphin/lib/ontology/basic/relation.py +63 -0
- dolphin/lib/ontology/datasource/__init__.py +27 -0
- dolphin/lib/ontology/datasource/datasource.py +66 -0
- dolphin/lib/ontology/datasource/oracle_datasource.py +338 -0
- dolphin/lib/ontology/datasource/sql.py +845 -0
- dolphin/lib/ontology/mapping.py +177 -0
- dolphin/lib/ontology/ontology.py +733 -0
- dolphin/lib/ontology/ontology_context.py +16 -0
- dolphin/lib/ontology/ontology_manager.py +107 -0
- dolphin/lib/skill_results/__init__.py +31 -0
- dolphin/lib/skill_results/cache_backend.py +559 -0
- dolphin/lib/skill_results/result_processor.py +181 -0
- dolphin/lib/skill_results/result_reference.py +179 -0
- dolphin/lib/skill_results/skillkit_hook.py +324 -0
- dolphin/lib/skill_results/strategies.py +328 -0
- dolphin/lib/skill_results/strategy_registry.py +150 -0
- dolphin/lib/skillkits/__init__.py +44 -0
- dolphin/lib/skillkits/agent_skillkit.py +155 -0
- dolphin/lib/skillkits/cognitive_skillkit.py +82 -0
- dolphin/lib/skillkits/env_skillkit.py +250 -0
- dolphin/lib/skillkits/mcp_adapter.py +616 -0
- dolphin/lib/skillkits/mcp_skillkit.py +771 -0
- dolphin/lib/skillkits/memory_skillkit.py +650 -0
- dolphin/lib/skillkits/noop_skillkit.py +31 -0
- dolphin/lib/skillkits/ontology_skillkit.py +89 -0
- dolphin/lib/skillkits/plan_act_skillkit.py +452 -0
- dolphin/lib/skillkits/resource/__init__.py +52 -0
- dolphin/lib/skillkits/resource/models/__init__.py +6 -0
- dolphin/lib/skillkits/resource/models/skill_config.py +109 -0
- dolphin/lib/skillkits/resource/models/skill_meta.py +127 -0
- dolphin/lib/skillkits/resource/resource_skillkit.py +393 -0
- dolphin/lib/skillkits/resource/skill_cache.py +215 -0
- dolphin/lib/skillkits/resource/skill_loader.py +395 -0
- dolphin/lib/skillkits/resource/skill_validator.py +406 -0
- dolphin/lib/skillkits/resource_skillkit.py +11 -0
- dolphin/lib/skillkits/search_skillkit.py +163 -0
- dolphin/lib/skillkits/sql_skillkit.py +274 -0
- dolphin/lib/skillkits/system_skillkit.py +509 -0
- dolphin/lib/skillkits/vm_skillkit.py +65 -0
- dolphin/lib/utils/__init__.py +9 -0
- dolphin/lib/utils/data_process.py +207 -0
- dolphin/lib/utils/handle_progress.py +178 -0
- dolphin/lib/utils/security.py +139 -0
- dolphin/lib/utils/text_retrieval.py +462 -0
- dolphin/lib/vm/__init__.py +11 -0
- dolphin/lib/vm/env_executor.py +895 -0
- dolphin/lib/vm/python_session_manager.py +453 -0
- dolphin/lib/vm/vm.py +610 -0
- dolphin/sdk/__init__.py +60 -0
- dolphin/sdk/agent/__init__.py +12 -0
- dolphin/sdk/agent/agent_factory.py +236 -0
- dolphin/sdk/agent/dolphin_agent.py +1106 -0
- dolphin/sdk/api/__init__.py +4 -0
- dolphin/sdk/runtime/__init__.py +8 -0
- dolphin/sdk/runtime/env.py +363 -0
- dolphin/sdk/skill/__init__.py +10 -0
- dolphin/sdk/skill/global_skills.py +706 -0
- dolphin/sdk/skill/traditional_toolkit.py +260 -0
- kweaver_dolphin-0.1.0.dist-info/METADATA +521 -0
- kweaver_dolphin-0.1.0.dist-info/RECORD +199 -0
- kweaver_dolphin-0.1.0.dist-info/WHEEL +5 -0
- kweaver_dolphin-0.1.0.dist-info/entry_points.txt +27 -0
- kweaver_dolphin-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
- 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
|
+
)
|