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.
Files changed (90) hide show
  1. skillpool/__init__.py +74 -0
  2. skillpool/__main__.py +6 -0
  3. skillpool/adapters/__init__.py +8 -0
  4. skillpool/adapters/base.py +41 -0
  5. skillpool/adapters/claude_adapter.py +36 -0
  6. skillpool/adapters/codex_adapter.py +92 -0
  7. skillpool/adapters/hermes_adapter.py +38 -0
  8. skillpool/audit/__init__.py +651 -0
  9. skillpool/bridge/__init__.py +16 -0
  10. skillpool/bridge/freeze_detector.py +134 -0
  11. skillpool/bridge/maintenance.py +119 -0
  12. skillpool/bridge/wal_manager.py +136 -0
  13. skillpool/clawmem_client.py +176 -0
  14. skillpool/cli.py +700 -0
  15. skillpool/combiner/__init__.py +31 -0
  16. skillpool/combiner/lifecycle.py +453 -0
  17. skillpool/combiner/models.py +99 -0
  18. skillpool/config.py +34 -0
  19. skillpool/cost/__init__.py +111 -0
  20. skillpool/cost/audit_hash.py +51 -0
  21. skillpool/cost/budget_tracker.py +66 -0
  22. skillpool/cost/dashboard.py +189 -0
  23. skillpool/cost/models.py +129 -0
  24. skillpool/cost/token_governor.py +264 -0
  25. skillpool/cost/trace_ceiling.py +38 -0
  26. skillpool/csdf.py +126 -0
  27. skillpool/evolver/__init__.py +978 -0
  28. skillpool/gain/__init__.py +285 -0
  29. skillpool/gate.py +282 -0
  30. skillpool/gate_policy/__init__.py +31 -0
  31. skillpool/gate_policy/incremental.py +157 -0
  32. skillpool/gate_policy/parser.py +258 -0
  33. skillpool/gate_policy/state_machine.py +432 -0
  34. skillpool/graph/__init__.py +14 -0
  35. skillpool/graph/ppr.py +279 -0
  36. skillpool/health/__init__.py +73 -0
  37. skillpool/health/check.py +85 -0
  38. skillpool/health/degradation.py +90 -0
  39. skillpool/health/models.py +43 -0
  40. skillpool/hooks/__init__.py +4 -0
  41. skillpool/hooks/security_scanner.py +288 -0
  42. skillpool/lifecycle.py +150 -0
  43. skillpool/materializer/__init__.py +124 -0
  44. skillpool/materializer/budget_cropper.py +178 -0
  45. skillpool/materializer/csdf_loader.py +114 -0
  46. skillpool/materializer/lazy_loader.py +265 -0
  47. skillpool/materializer/lifecycle_filter.py +93 -0
  48. skillpool/materializer/mapper.py +178 -0
  49. skillpool/materializer/models.py +66 -0
  50. skillpool/mcp_server.py +2005 -0
  51. skillpool/monitor/__init__.py +576 -0
  52. skillpool/monitor/bug_collector.py +392 -0
  53. skillpool/monitor/defect_classifier.py +218 -0
  54. skillpool/monitor/self_healing.py +530 -0
  55. skillpool/monitor/telemetry_bridge.py +197 -0
  56. skillpool/paradigm/__init__.py +312 -0
  57. skillpool/paradigm/override.py +285 -0
  58. skillpool/profile.py +94 -0
  59. skillpool/quality.py +254 -0
  60. skillpool/registry/__init__.py +509 -0
  61. skillpool/registry/models.py +98 -0
  62. skillpool/resolver/__init__.py +320 -0
  63. skillpool/resolver/cache.py +103 -0
  64. skillpool/resolver/circuit_breaker.py +103 -0
  65. skillpool/resolver/conflict_detector.py +111 -0
  66. skillpool/resolver/health_filter.py +38 -0
  67. skillpool/resolver/models.py +154 -0
  68. skillpool/resolver/rate_limiter.py +48 -0
  69. skillpool/resolver/skill_graph.py +183 -0
  70. skillpool/review/__init__.py +242 -0
  71. skillpool/review/async_queue.py +96 -0
  72. skillpool/review/checkpoint_runner.py +345 -0
  73. skillpool/review/models.py +164 -0
  74. skillpool/review/suspect_marker.py +39 -0
  75. skillpool/review/veto_evaluator.py +94 -0
  76. skillpool/router/__init__.py +481 -0
  77. skillpool/schemas.py +119 -0
  78. skillpool/synergy/__init__.py +240 -0
  79. skillpool/synergy/detector.py +5 -0
  80. skillpool/telemetry.py +126 -0
  81. skillpool/utils/__init__.py +21 -0
  82. skillpool/utils/changelog.py +218 -0
  83. skillpool/utils/logger.py +273 -0
  84. skillpool/utils/runtime_audit.py +163 -0
  85. skillpool/utils/time_utils.py +13 -0
  86. skillpool-4.3.0.dist-info/METADATA +21 -0
  87. skillpool-4.3.0.dist-info/RECORD +90 -0
  88. skillpool-4.3.0.dist-info/WHEEL +5 -0
  89. skillpool-4.3.0.dist-info/entry_points.txt +3 -0
  90. 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)