Persistent adaptive skill registry under ``~/.nanoresearch/skills``.
| 127 | |
| 128 | |
| 129 | class SkillEvolutionStore: |
| 130 | """Persistent adaptive skill registry under ``~/.nanoresearch/skills``.""" |
| 131 | |
| 132 | def __init__(self, root: Path | None = None, *, enabled: bool = True) -> None: |
| 133 | self.enabled = enabled |
| 134 | self.root = root or (Path.home() / ".nanoresearch" / "skills") |
| 135 | self.nl_file = self.root / "natural_language.json" |
| 136 | self.script_file = self.root / "script_registry.json" |
| 137 | self.candidate_file = self.root / "candidate_log.json" |
| 138 | self.review_file = self.root / "review_log.json" |
| 139 | self.users_root = self.root / "Users" / "default" |
| 140 | self._static_skill_registry: list[dict[str, Any]] = [] |
| 141 | if self.enabled: |
| 142 | self.root.mkdir(parents=True, exist_ok=True) |
| 143 | self.users_root.mkdir(parents=True, exist_ok=True) |
| 144 | |
| 145 | @staticmethod |
| 146 | def _normalize_tags(tags: list[str] | None) -> list[str]: |
| 147 | seen: set[str] = set() |
| 148 | normalized: list[str] = [] |
| 149 | for tag in tags or []: |
| 150 | norm = re.sub(r"\s+", " ", str(tag).strip().lower()) |
| 151 | if not norm or norm in seen: |
| 152 | continue |
| 153 | seen.add(norm) |
| 154 | normalized.append(norm) |
| 155 | return normalized |
| 156 | |
| 157 | @staticmethod |
| 158 | def _tokenize(text: str) -> set[str]: |
| 159 | return set(_WORD_RE.findall((text or "").lower())) |
| 160 | |
| 161 | @staticmethod |
| 162 | def _slug(text: str) -> str: |
| 163 | slug = re.sub(r"[^a-z0-9]+", "-", (text or "").strip().lower()).strip("-") |
| 164 | return slug or "adaptive-skill" |
| 165 | |
| 166 | @staticmethod |
| 167 | def _skill_id(prefix: str, *parts: str) -> str: |
| 168 | digest = hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest() |
| 169 | return f"{prefix}-{digest[:12]}" |
| 170 | |
| 171 | @staticmethod |
| 172 | def _parse_patch(version: str) -> tuple[int, int, int]: |
| 173 | try: |
| 174 | major, minor, patch = [int(part) for part in str(version).split(".")[:3]] |
| 175 | return major, minor, patch |
| 176 | except Exception: |
| 177 | return 0, 1, 0 |
| 178 | |
| 179 | def _bump_patch(self, version: str) -> str: |
| 180 | major, minor, patch = self._parse_patch(version) |
| 181 | return f"{major}.{minor}.{patch + 1}" |
| 182 | |
| 183 | def _load_models(self, path: Path, model_cls: type[BaseModel]) -> list[BaseModel]: |
| 184 | if not self.enabled or not path.is_file(): |
| 185 | return [] |
| 186 | try: |