skillpool 4.3.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.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""BudgetCropper — 按 token 预算裁剪 markdown 内容。
|
|
2
|
+
|
|
3
|
+
策略优先级(从低到高删除):
|
|
4
|
+
1. Version History(最低优先级)
|
|
5
|
+
2. Checklist 的 medium 项
|
|
6
|
+
3. Checklist 的 low 项
|
|
7
|
+
4. Description(截断)
|
|
8
|
+
5. Header + Dimension + Weight + Veto + Schema(最高优先级,保留)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BudgetCropper:
|
|
17
|
+
"""按 token 预算裁剪 markdown 内容。
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
max_tokens: 最大 token 数限制,默认 4096。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, max_tokens: int = 4096):
|
|
24
|
+
self.max_tokens = max_tokens
|
|
25
|
+
|
|
26
|
+
def crop(self, markdown: str) -> str:
|
|
27
|
+
"""裁剪 markdown 到 max_tokens 以内。"""
|
|
28
|
+
if self.estimate_tokens(markdown) <= self.max_tokens:
|
|
29
|
+
return markdown
|
|
30
|
+
|
|
31
|
+
# 策略 1: 删除 Version History
|
|
32
|
+
markdown = self._remove_section(markdown, "## Version History")
|
|
33
|
+
if self.estimate_tokens(markdown) <= self.max_tokens:
|
|
34
|
+
return markdown
|
|
35
|
+
|
|
36
|
+
# 策略 2: 删除 Checklist 的 medium 项
|
|
37
|
+
markdown = self._remove_checklist_items(markdown, "medium")
|
|
38
|
+
if self.estimate_tokens(markdown) <= self.max_tokens:
|
|
39
|
+
return markdown
|
|
40
|
+
|
|
41
|
+
# 策略 3: 删除 Checklist 的 low 项
|
|
42
|
+
markdown = self._remove_checklist_items(markdown, "low")
|
|
43
|
+
if self.estimate_tokens(markdown) <= self.max_tokens:
|
|
44
|
+
return markdown
|
|
45
|
+
|
|
46
|
+
# 策略 4: 截断 Description
|
|
47
|
+
markdown = self._truncate_description(markdown)
|
|
48
|
+
if self.estimate_tokens(markdown) <= self.max_tokens:
|
|
49
|
+
return markdown
|
|
50
|
+
|
|
51
|
+
# 最后手段:硬截断
|
|
52
|
+
return self._hard_truncate(markdown)
|
|
53
|
+
|
|
54
|
+
def estimate_tokens(self, text: str) -> int:
|
|
55
|
+
"""估算 token 数。简单实现:len(text) // 4。"""
|
|
56
|
+
return len(text) // 4
|
|
57
|
+
|
|
58
|
+
def _remove_section(self, markdown: str, header_prefix: str) -> str:
|
|
59
|
+
"""删除指定 section(标题匹配:检查标题行是否包含 header_prefix)。"""
|
|
60
|
+
lines = markdown.split("\n")
|
|
61
|
+
result = []
|
|
62
|
+
in_section = False
|
|
63
|
+
section_level = 0
|
|
64
|
+
|
|
65
|
+
for line in lines:
|
|
66
|
+
header_match = re.match(r"^(#{1,6})\s+", line)
|
|
67
|
+
if header_match:
|
|
68
|
+
current_level = len(header_match.group(1))
|
|
69
|
+
if in_section:
|
|
70
|
+
if current_level <= section_level:
|
|
71
|
+
in_section = False
|
|
72
|
+
else:
|
|
73
|
+
continue
|
|
74
|
+
# Match section by checking if header text contains the prefix
|
|
75
|
+
if header_prefix.lower() in line.lower():
|
|
76
|
+
in_section = True
|
|
77
|
+
section_level = current_level
|
|
78
|
+
continue
|
|
79
|
+
if in_section:
|
|
80
|
+
continue
|
|
81
|
+
result.append(line)
|
|
82
|
+
|
|
83
|
+
return "\n".join(result)
|
|
84
|
+
|
|
85
|
+
def _remove_checklist_items(self, markdown: str, priority: str) -> str:
|
|
86
|
+
"""删除 Checklist 中指定优先级的项。"""
|
|
87
|
+
lines = markdown.split("\n")
|
|
88
|
+
result = []
|
|
89
|
+
in_checklist = False
|
|
90
|
+
checklist_level = 0
|
|
91
|
+
|
|
92
|
+
for line in lines:
|
|
93
|
+
header_match = re.match(r"^(#{1,6})\s+", line)
|
|
94
|
+
if header_match:
|
|
95
|
+
current_level = len(header_match.group(1))
|
|
96
|
+
if in_checklist and current_level <= checklist_level:
|
|
97
|
+
in_checklist = False
|
|
98
|
+
if "checklist" in line.lower() or "检查项" in line:
|
|
99
|
+
in_checklist = True
|
|
100
|
+
checklist_level = current_level
|
|
101
|
+
result.append(line)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if in_checklist:
|
|
105
|
+
if re.match(r"^\s*[-*]\s+", line):
|
|
106
|
+
# Match [medium], [low], (medium), (low) formats
|
|
107
|
+
if re.search(rf"[\[(]{priority}[\])]", line, re.IGNORECASE):
|
|
108
|
+
continue
|
|
109
|
+
# Match "priority: medium" format
|
|
110
|
+
if re.search(rf"priority:\s*{priority}", line, re.IGNORECASE):
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
result.append(line)
|
|
114
|
+
|
|
115
|
+
return "\n".join(result)
|
|
116
|
+
|
|
117
|
+
def _truncate_description(self, markdown: str) -> str:
|
|
118
|
+
"""截断 Description 部分。"""
|
|
119
|
+
lines = markdown.split("\n")
|
|
120
|
+
result = []
|
|
121
|
+
in_description = False
|
|
122
|
+
description_level = 0
|
|
123
|
+
description_lines = []
|
|
124
|
+
|
|
125
|
+
for line in lines:
|
|
126
|
+
header_match = re.match(r"^(#{1,6})\s+", line)
|
|
127
|
+
if header_match:
|
|
128
|
+
current_level = len(header_match.group(1))
|
|
129
|
+
if in_description and current_level <= description_level:
|
|
130
|
+
truncated = self._truncate_lines(description_lines, 200)
|
|
131
|
+
result.extend(truncated)
|
|
132
|
+
in_description = False
|
|
133
|
+
description_lines = []
|
|
134
|
+
if "Description" in line or "概述" in line or "简介" in line:
|
|
135
|
+
in_description = True
|
|
136
|
+
description_level = current_level
|
|
137
|
+
result.append(line)
|
|
138
|
+
continue
|
|
139
|
+
result.append(line)
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if in_description:
|
|
143
|
+
description_lines.append(line)
|
|
144
|
+
else:
|
|
145
|
+
result.append(line)
|
|
146
|
+
|
|
147
|
+
if in_description and description_lines:
|
|
148
|
+
truncated = self._truncate_lines(description_lines, 200)
|
|
149
|
+
result.extend(truncated)
|
|
150
|
+
|
|
151
|
+
return "\n".join(result)
|
|
152
|
+
|
|
153
|
+
def _truncate_lines(self, lines: list, max_chars: int) -> list:
|
|
154
|
+
"""截断行列表到指定字符数。"""
|
|
155
|
+
result = []
|
|
156
|
+
total = 0
|
|
157
|
+
for line in lines:
|
|
158
|
+
if total + len(line) > max_chars:
|
|
159
|
+
remaining = max_chars - total
|
|
160
|
+
if remaining > 20:
|
|
161
|
+
result.append(line[:remaining] + "...")
|
|
162
|
+
break
|
|
163
|
+
result.append(line)
|
|
164
|
+
total += len(line)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
def _hard_truncate(self, markdown: str) -> str:
|
|
168
|
+
"""硬截断到 max_tokens * 4 字符。"""
|
|
169
|
+
max_chars = self.max_tokens * 4
|
|
170
|
+
if len(markdown) <= max_chars:
|
|
171
|
+
return markdown
|
|
172
|
+
truncated = markdown[:max_chars]
|
|
173
|
+
last_period = truncated.rfind(".")
|
|
174
|
+
last_newline = truncated.rfind("\n")
|
|
175
|
+
cut_point = max(last_period, last_newline)
|
|
176
|
+
if cut_point > max_chars * 0.8:
|
|
177
|
+
return truncated[: cut_point + 1] + "\n\n... (truncated)"
|
|
178
|
+
return truncated + "\n\n... (truncated)"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Shared CSDF loading utility for SkillPool MCP and LazySkillLoader.
|
|
2
|
+
|
|
3
|
+
Extracted from mcp_server.py and lazy_loader.py to eliminate code
|
|
4
|
+
duplication. Both modules use this single implementation.
|
|
5
|
+
|
|
6
|
+
Part of SkillPool — independent infrastructure, shared by all agents.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_csdf(
|
|
18
|
+
skill_id: str,
|
|
19
|
+
skills_dir: Path,
|
|
20
|
+
) -> dict[str, Any] | None:
|
|
21
|
+
"""Load CSDF data for a skill by ID.
|
|
22
|
+
|
|
23
|
+
Tries three lookup strategies in order:
|
|
24
|
+
1. Exact YAML match: {skills_dir}/{skill_id}.yaml
|
|
25
|
+
2. Prefix YAML match: {skills_dir}/{skill_id}_*.yaml
|
|
26
|
+
3. Directory-based: {skills_dir}/{skill_id}/SKILL.md
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
skill_id: Skill identifier (e.g., "S09", "scaffold-docs").
|
|
30
|
+
skills_dir: Path to the skills directory.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Dict with CSDF data, or None if not found.
|
|
34
|
+
"""
|
|
35
|
+
# 1. Exact match
|
|
36
|
+
exact = skills_dir / f"{skill_id}.yaml"
|
|
37
|
+
if exact.exists():
|
|
38
|
+
return _parse_yaml(exact)
|
|
39
|
+
|
|
40
|
+
# 2. Prefix match (e.g., S09-resilience-degradation.yaml)
|
|
41
|
+
for p in skills_dir.glob(f"{skill_id}-*.yaml"):
|
|
42
|
+
return _parse_yaml(p)
|
|
43
|
+
|
|
44
|
+
# 3. Directory-based skill
|
|
45
|
+
skill_dir = skills_dir / skill_id
|
|
46
|
+
if skill_dir.is_dir():
|
|
47
|
+
skill_md = skill_dir / "SKILL.md"
|
|
48
|
+
if skill_md.exists():
|
|
49
|
+
return _parse_directory_skill(skill_id, skill_md, skill_dir)
|
|
50
|
+
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_yaml(path: Path) -> dict[str, Any] | None:
|
|
55
|
+
"""Parse a CSDF YAML file."""
|
|
56
|
+
try:
|
|
57
|
+
with open(path, encoding="utf-8") as f:
|
|
58
|
+
data = yaml.safe_load(f)
|
|
59
|
+
if data and isinstance(data, dict):
|
|
60
|
+
return data
|
|
61
|
+
except (yaml.YAMLError, OSError):
|
|
62
|
+
pass
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_directory_skill(
|
|
67
|
+
skill_id: str,
|
|
68
|
+
skill_md: Path,
|
|
69
|
+
skill_dir: Path,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Parse a directory-based skill from SKILL.md frontmatter."""
|
|
72
|
+
try:
|
|
73
|
+
text = skill_md.read_text(encoding="utf-8")
|
|
74
|
+
except OSError:
|
|
75
|
+
return {"id": skill_id, "name": skill_id, "type": "directory"}
|
|
76
|
+
|
|
77
|
+
# Parse YAML frontmatter
|
|
78
|
+
frontmatter: dict[str, Any] = {"id": skill_id, "name": skill_id, "type": "directory"}
|
|
79
|
+
if text.startswith("---"):
|
|
80
|
+
end = text.find("---", 3)
|
|
81
|
+
if end > 0:
|
|
82
|
+
try:
|
|
83
|
+
fm = yaml.safe_load(text[3:end])
|
|
84
|
+
if isinstance(fm, dict):
|
|
85
|
+
frontmatter.update(fm)
|
|
86
|
+
except yaml.YAMLError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
frontmatter["id"] = frontmatter.get("id", skill_id)
|
|
90
|
+
frontmatter["name"] = frontmatter.get("name", skill_id)
|
|
91
|
+
frontmatter["type"] = "directory"
|
|
92
|
+
frontmatter["_skill_dir"] = str(skill_dir)
|
|
93
|
+
|
|
94
|
+
# Store the markdown body (content after frontmatter) for directory-based skills
|
|
95
|
+
# This allows skill_definition() to return the full SKILL.md content
|
|
96
|
+
if text.startswith("---"):
|
|
97
|
+
end = text.find("---", 3)
|
|
98
|
+
if end > 0:
|
|
99
|
+
# Extract body after the closing ---
|
|
100
|
+
body = text[end + 3 :].lstrip("\n")
|
|
101
|
+
if body:
|
|
102
|
+
frontmatter["_markdown_body"] = body
|
|
103
|
+
|
|
104
|
+
# Merge manifest.yaml if present (contains synergies, dependencies, etc.)
|
|
105
|
+
manifest_path = skill_dir / "manifest.yaml"
|
|
106
|
+
if manifest_path.exists():
|
|
107
|
+
manifest_data = _parse_yaml(manifest_path)
|
|
108
|
+
if manifest_data and isinstance(manifest_data, dict):
|
|
109
|
+
# manifest fields override frontmatter defaults, but don't clobber id/type
|
|
110
|
+
for k, v in manifest_data.items():
|
|
111
|
+
if k not in ("id", "type"):
|
|
112
|
+
frontmatter.setdefault(k, v)
|
|
113
|
+
|
|
114
|
+
return frontmatter
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""LazySkillLoader — tiered skill loading for token-efficient context delivery.
|
|
2
|
+
|
|
3
|
+
Three loading tiers:
|
|
4
|
+
L0 (metadata): id, name, version, dimension, weight, tags — ~50 tokens
|
|
5
|
+
L1 (summary): L0 + description + checklist summary — ~200 tokens
|
|
6
|
+
L2 (full def): complete SKILL.md via Materializer — full token cost
|
|
7
|
+
|
|
8
|
+
L0 reads YAML frontmatter only (no materialization).
|
|
9
|
+
L1 adds the description field from the CSDF dict.
|
|
10
|
+
L2 runs the full Materializer.materialize() pipeline.
|
|
11
|
+
|
|
12
|
+
Uses shared csdf_loader for CSDF loading (eliminates duplication with mcp_server).
|
|
13
|
+
|
|
14
|
+
Part of SkillPool — independent infrastructure, shared by all agents.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
from skillpool.config import get_data_dir
|
|
26
|
+
from skillpool.materializer import Materializer
|
|
27
|
+
from skillpool.materializer.csdf_loader import load_csdf
|
|
28
|
+
from skillpool.materializer.models import MaterializationResult
|
|
29
|
+
from skillpool.profile import CLAUDE_CODE_PROFILE, AgentCapabilityProfile
|
|
30
|
+
|
|
31
|
+
_SKILLS_DIR = get_data_dir() / "skills"
|
|
32
|
+
|
|
33
|
+
_VALID_TIERS = {"L0", "L1", "L2"}
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("skillpool.lazy_loader")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LazySkillLoader:
|
|
39
|
+
"""Tiered skill loader with in-memory cache and thread safety.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
loader = LazySkillLoader()
|
|
43
|
+
meta = loader.load("S09", tier="L0") # cheap metadata
|
|
44
|
+
summary = loader.upgrade("S09", "L0", "L1") # add description
|
|
45
|
+
full = loader.upgrade("S09", "L1", "L2") # full materialization
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
profile: Optional[AgentCapabilityProfile] = None,
|
|
51
|
+
skills_dir: Optional[Path] = None,
|
|
52
|
+
):
|
|
53
|
+
self._profile = profile or CLAUDE_CODE_PROFILE
|
|
54
|
+
self._skills_dir = skills_dir or _SKILLS_DIR
|
|
55
|
+
# Cache: {skill_id: {"L0": dict, "L1": dict, "L2": dict}}
|
|
56
|
+
self._cache: dict[str, dict[str, dict]] = {}
|
|
57
|
+
# Track file modification times for cache invalidation
|
|
58
|
+
self._mtimes: dict[str, float] = {}
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
|
|
61
|
+
def load(self, skill_id: str, tier: str = "L0") -> dict:
|
|
62
|
+
"""Load a skill at the specified tier.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
skill_id: Skill identifier (e.g., "S09", "scaffold-docs")
|
|
66
|
+
tier: Loading tier — "L0", "L1", or "L2"
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Dict with skill data at the requested tier.
|
|
70
|
+
Includes a "_tier" key indicating the loaded tier.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: If tier is invalid or skill_id not found.
|
|
74
|
+
"""
|
|
75
|
+
# Part of SkillPool — independent infrastructure, shared by all agents
|
|
76
|
+
self._validate_tier(tier)
|
|
77
|
+
|
|
78
|
+
with self._lock:
|
|
79
|
+
# Check for cache invalidation (file modified)
|
|
80
|
+
self._check_invalidation(skill_id)
|
|
81
|
+
|
|
82
|
+
# Return cached if available
|
|
83
|
+
if skill_id in self._cache and tier in self._cache[skill_id]:
|
|
84
|
+
return self._cache[skill_id][tier]
|
|
85
|
+
|
|
86
|
+
# Load CSDF outside lock (I/O bound)
|
|
87
|
+
csdf = load_csdf(skill_id, self._skills_dir)
|
|
88
|
+
if csdf is None:
|
|
89
|
+
raise ValueError(f"Skill not found: {skill_id}")
|
|
90
|
+
|
|
91
|
+
with self._lock:
|
|
92
|
+
# Ensure cache entry exists
|
|
93
|
+
if skill_id not in self._cache:
|
|
94
|
+
self._cache[skill_id] = {}
|
|
95
|
+
|
|
96
|
+
# Build tiers incrementally
|
|
97
|
+
self._ensure_tiers(skill_id, csdf, tier)
|
|
98
|
+
|
|
99
|
+
return self._cache[skill_id][tier]
|
|
100
|
+
|
|
101
|
+
def preload(self, skill_ids: list[str], tier: str = "L0") -> dict[str, dict]:
|
|
102
|
+
"""Batch-load multiple skills at the specified tier.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
skill_ids: List of skill identifiers
|
|
106
|
+
tier: Loading tier for all skills
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Dict mapping skill_id to its loaded data. Skills that fail
|
|
110
|
+
to load are omitted from the result.
|
|
111
|
+
"""
|
|
112
|
+
# Part of SkillPool — independent infrastructure, shared by all agents
|
|
113
|
+
self._validate_tier(tier)
|
|
114
|
+
results = {}
|
|
115
|
+
for sid in skill_ids:
|
|
116
|
+
try:
|
|
117
|
+
results[sid] = self.load(sid, tier=tier)
|
|
118
|
+
except ValueError:
|
|
119
|
+
logger.debug("preload skipped missing skill: %s", sid)
|
|
120
|
+
continue
|
|
121
|
+
return results
|
|
122
|
+
|
|
123
|
+
def upgrade(self, skill_id: str, from_tier: str, to_tier: str) -> dict:
|
|
124
|
+
"""Load more detail for an already-loaded skill.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
skill_id: Skill identifier
|
|
128
|
+
from_tier: Current tier (must already be loaded)
|
|
129
|
+
to_tier: Target tier (must be higher than from_tier)
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Dict with skill data at the target tier.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If skill not cached, from_tier not loaded,
|
|
136
|
+
or invalid tier ordering.
|
|
137
|
+
"""
|
|
138
|
+
# Part of SkillPool — independent infrastructure, shared by all agents
|
|
139
|
+
self._validate_tier(from_tier)
|
|
140
|
+
self._validate_tier(to_tier)
|
|
141
|
+
|
|
142
|
+
tier_order = {"L0": 0, "L1": 1, "L2": 2}
|
|
143
|
+
if tier_order[to_tier] <= tier_order[from_tier]:
|
|
144
|
+
raise ValueError(f"to_tier ({to_tier}) must be higher than from_tier ({from_tier})")
|
|
145
|
+
|
|
146
|
+
with self._lock:
|
|
147
|
+
if skill_id not in self._cache:
|
|
148
|
+
raise ValueError(f"Skill {skill_id} not in cache — call load() first")
|
|
149
|
+
|
|
150
|
+
if from_tier not in self._cache[skill_id]:
|
|
151
|
+
raise ValueError(f"Skill {skill_id} not loaded at {from_tier} — load it first")
|
|
152
|
+
|
|
153
|
+
# Re-load at the higher tier (uses cache for lower tiers)
|
|
154
|
+
return self.load(skill_id, tier=to_tier)
|
|
155
|
+
|
|
156
|
+
def clear_cache(self, skill_id: str | None = None) -> None:
|
|
157
|
+
"""Clear cached data for a skill, or all skills if skill_id is None."""
|
|
158
|
+
with self._lock:
|
|
159
|
+
if skill_id is None:
|
|
160
|
+
self._cache.clear()
|
|
161
|
+
self._mtimes.clear()
|
|
162
|
+
else:
|
|
163
|
+
self._cache.pop(skill_id, None)
|
|
164
|
+
self._mtimes.pop(skill_id, None)
|
|
165
|
+
|
|
166
|
+
# ── Internal helpers ──────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def _check_invalidation(self, skill_id: str) -> None:
|
|
169
|
+
"""Check if cached data is stale (file modified since last load).
|
|
170
|
+
|
|
171
|
+
Must be called with self._lock held.
|
|
172
|
+
"""
|
|
173
|
+
# Try to find the source file
|
|
174
|
+
yaml_path = self._skills_dir / f"{skill_id}.yaml"
|
|
175
|
+
if not yaml_path.exists():
|
|
176
|
+
for p in self._skills_dir.glob(f"{skill_id}_*.yaml"):
|
|
177
|
+
yaml_path = p
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
if not yaml_path.exists():
|
|
181
|
+
# Directory-based skill
|
|
182
|
+
md_path = self._skills_dir / skill_id / "SKILL.md"
|
|
183
|
+
if md_path.exists():
|
|
184
|
+
yaml_path = md_path
|
|
185
|
+
|
|
186
|
+
if yaml_path.exists():
|
|
187
|
+
try:
|
|
188
|
+
mtime = yaml_path.stat().st_mtime
|
|
189
|
+
if skill_id in self._mtimes and self._mtimes[skill_id] != mtime:
|
|
190
|
+
logger.info("Cache invalidated for %s (file modified)", skill_id)
|
|
191
|
+
self._cache.pop(skill_id, None)
|
|
192
|
+
self._mtimes[skill_id] = mtime
|
|
193
|
+
except OSError:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
def _ensure_tiers(self, skill_id: str, csdf: dict, target_tier: str) -> None:
|
|
197
|
+
"""Build cache entries up to the target tier, starting from L0.
|
|
198
|
+
|
|
199
|
+
Must be called with self._lock held.
|
|
200
|
+
"""
|
|
201
|
+
cache = self._cache[skill_id]
|
|
202
|
+
|
|
203
|
+
# L0: metadata only (~50 tokens)
|
|
204
|
+
if "L0" not in cache:
|
|
205
|
+
cache["L0"] = {
|
|
206
|
+
"id": csdf.get("id", skill_id),
|
|
207
|
+
"name": csdf.get("name", ""),
|
|
208
|
+
"version": csdf.get("version", ""),
|
|
209
|
+
"dimension": csdf.get("dimension", ""),
|
|
210
|
+
"weight": csdf.get("weight", 0),
|
|
211
|
+
"tags": csdf.get("tags", []),
|
|
212
|
+
"_tier": "L0",
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if target_tier == "L0":
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# L1: L0 + description + checklist summary (~200 tokens)
|
|
219
|
+
if "L1" not in cache:
|
|
220
|
+
l0 = cache["L0"]
|
|
221
|
+
checklist_summary = []
|
|
222
|
+
for item in csdf.get("checklist", []):
|
|
223
|
+
if isinstance(item, dict):
|
|
224
|
+
checklist_summary.append(
|
|
225
|
+
{
|
|
226
|
+
"id": item.get("id", ""),
|
|
227
|
+
"description": item.get("description", ""),
|
|
228
|
+
"severity": item.get("severity", ""),
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
cache["L1"] = {
|
|
232
|
+
**l0,
|
|
233
|
+
"description": csdf.get("description", ""),
|
|
234
|
+
"checklist_summary": checklist_summary,
|
|
235
|
+
"_tier": "L1",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if target_tier == "L1":
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# L2: full materialization via Materializer
|
|
242
|
+
if "L2" not in cache:
|
|
243
|
+
mat = Materializer(profile=self._profile)
|
|
244
|
+
result: MaterializationResult = mat.materialize(csdf_dict=csdf)
|
|
245
|
+
l2_data = {
|
|
246
|
+
**cache["L1"],
|
|
247
|
+
"_tier": "L2",
|
|
248
|
+
}
|
|
249
|
+
# Preserve raw markdown body for directory-based skills
|
|
250
|
+
if "_markdown_body" in csdf:
|
|
251
|
+
l2_data["_markdown_body"] = csdf["_markdown_body"]
|
|
252
|
+
if result.status == "success" and result.skill is not None:
|
|
253
|
+
l2_data["markdown"] = result.skill.markdown
|
|
254
|
+
l2_data["token_count"] = result.skill.token_count
|
|
255
|
+
else:
|
|
256
|
+
logger.warning("L2 materialization failed for %s: %s", skill_id, result.errors)
|
|
257
|
+
l2_data["markdown"] = ""
|
|
258
|
+
l2_data["token_count"] = 0
|
|
259
|
+
l2_data["_materialization_errors"] = result.errors
|
|
260
|
+
cache["L2"] = l2_data
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _validate_tier(tier: str) -> None:
|
|
264
|
+
if tier not in _VALID_TIERS:
|
|
265
|
+
raise ValueError(f"Invalid tier '{tier}'; must be one of {sorted(_VALID_TIERS)}")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""LifecycleFilter — 根据 Skill 生命周期状态过滤 markdown 内容。
|
|
2
|
+
|
|
3
|
+
根据 CSDF 中的 lifecycle_state 字段,对 markdown 内容添加状态标记、
|
|
4
|
+
降级警告或截断处理。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from skillpool.lifecycle import SkillLifecycleState, parse_state
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LifecycleFilter:
|
|
13
|
+
"""根据 Skill 生命周期状态过滤内容。
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
strict: True 时,REJECTED 清空内容,ARCHIVED 截断内容。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, strict: bool = True):
|
|
20
|
+
self.strict = strict
|
|
21
|
+
|
|
22
|
+
def filter(self, markdown: str, csdf: dict) -> str:
|
|
23
|
+
"""过滤 markdown 内容。
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
markdown: 原始 markdown 文本。
|
|
27
|
+
csdf: CSDF 字典,需包含 lifecycle_state 字段。
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
过滤后的 markdown 文本。
|
|
31
|
+
"""
|
|
32
|
+
state = self._resolve_state(csdf)
|
|
33
|
+
return self._apply_filter(markdown, state)
|
|
34
|
+
|
|
35
|
+
def _resolve_state(self, csdf: dict) -> SkillLifecycleState:
|
|
36
|
+
"""从 CSDF 解析生命周期状态,默认 ACTIVE。"""
|
|
37
|
+
raw = csdf.get("lifecycle_state")
|
|
38
|
+
if raw is None:
|
|
39
|
+
return SkillLifecycleState.ACTIVE
|
|
40
|
+
state = parse_state(str(raw))
|
|
41
|
+
if state is None:
|
|
42
|
+
return SkillLifecycleState.ACTIVE
|
|
43
|
+
return state
|
|
44
|
+
|
|
45
|
+
def _apply_filter(self, markdown: str, state: SkillLifecycleState) -> str:
|
|
46
|
+
"""根据状态应用过滤规则。"""
|
|
47
|
+
if state == SkillLifecycleState.DRAFT:
|
|
48
|
+
return self._prepend_warning(markdown, "[DRAFT] 开发中")
|
|
49
|
+
elif state == SkillLifecycleState.PROPOSED:
|
|
50
|
+
return self._prepend_warning(markdown, "[DRAFT] 开发中")
|
|
51
|
+
elif state == SkillLifecycleState.UNDER_REVIEW:
|
|
52
|
+
return self._prepend_warning(markdown, "[REVIEW] 审核中")
|
|
53
|
+
elif state == SkillLifecycleState.APPROVED:
|
|
54
|
+
return markdown
|
|
55
|
+
elif state == SkillLifecycleState.ACTIVE:
|
|
56
|
+
return markdown
|
|
57
|
+
elif state == SkillLifecycleState.REJECTED:
|
|
58
|
+
return self._handle_rejected(markdown)
|
|
59
|
+
elif state == SkillLifecycleState.DEPRECATED:
|
|
60
|
+
return self._handle_deprecated(markdown)
|
|
61
|
+
elif state == SkillLifecycleState.ARCHIVED:
|
|
62
|
+
return self._handle_archived(markdown)
|
|
63
|
+
elif state == SkillLifecycleState.REMOVED:
|
|
64
|
+
return ""
|
|
65
|
+
return markdown
|
|
66
|
+
|
|
67
|
+
def _prepend_warning(self, markdown: str, warning: str) -> str:
|
|
68
|
+
"""在 markdown 顶部添加警告。"""
|
|
69
|
+
return f"> ⚠️ {warning}\n\n{markdown}"
|
|
70
|
+
|
|
71
|
+
def _handle_rejected(self, markdown: str) -> str:
|
|
72
|
+
"""处理 REJECTED 状态。"""
|
|
73
|
+
warning = "[REJECTED] 已否决"
|
|
74
|
+
if self.strict:
|
|
75
|
+
return f"> ⚠️ {warning}\n"
|
|
76
|
+
return self._prepend_warning(markdown, warning)
|
|
77
|
+
|
|
78
|
+
def _handle_deprecated(self, markdown: str) -> str:
|
|
79
|
+
"""处理 DEPRECATED 状态,添加替代建议。"""
|
|
80
|
+
replacement = "请查看替代方案或联系维护者"
|
|
81
|
+
warning = f"[DEPRECATED] 已弃用 — {replacement}"
|
|
82
|
+
return self._prepend_warning(markdown, warning)
|
|
83
|
+
|
|
84
|
+
def _handle_archived(self, markdown: str) -> str:
|
|
85
|
+
"""处理 ARCHIVED 状态。"""
|
|
86
|
+
warning = "[ARCHIVED] 已归档"
|
|
87
|
+
if self.strict:
|
|
88
|
+
# 截断:只保留前 200 字符
|
|
89
|
+
truncated = markdown[:200]
|
|
90
|
+
if len(markdown) > 200:
|
|
91
|
+
truncated += "\n\n... (内容已截断)"
|
|
92
|
+
return f"> ⚠️ {warning}\n\n{truncated}"
|
|
93
|
+
return self._prepend_warning(markdown, warning)
|