Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ Thumbs.db

# uv
uv.lock
.venv/
21 changes: 21 additions & 0 deletions skillclaw/claw_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,25 @@ def inspect_hermes_config(cfg: "SkillClawConfig") -> dict[str, object]:
"Run SkillClaw once before relying on `skillclaw restore hermes`, so a backup can be created."
)

companion_enabled = True
try:
from .config_store import ConfigStore

companion_enabled = bool(ConfigStore().to_skillclaw_config().skills_include_companion_files)
except Exception as e:
notes.append(f"Could not read SkillClaw config for companion-files setting ({e}); assuming enabled.")
companion_coverage = "(skills dir unavailable)"
if expected_skills_dir.is_dir():
try:
from .skill_manager import SkillManager

mgr = SkillManager(skills_dir=str(expected_skills_dir), include_companion_files=True)
all_skills = mgr.get_all_skills()
with_companions = sum(1 for s in all_skills if s.get("companion_files"))
companion_coverage = f"{with_companions} of {len(all_skills)} skills have companion files"
except Exception as e:
companion_coverage = f"(scan failed: {e})"

return {
"status": "ok" if not issues else "warning",
"config_path": str(config_path),
Expand All @@ -565,6 +584,8 @@ def inspect_hermes_config(cfg: "SkillClawConfig") -> dict[str, object]:
"legacy_skillclaw_skills_present": legacy_present,
"latest_backup": str(backup_path) if backup_path else "(none)",
"session_boundary_mode": "explicit headers if provided, proxy heuristics otherwise",
"companion_files_enabled": companion_enabled,
"companion_files_coverage": companion_coverage,
"issues": issues,
"notes": notes,
"next_steps": next_steps,
Expand Down
1 change: 1 addition & 0 deletions skillclaw/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class SkillClawConfig:
embedding_model_path: str = "Qwen/Qwen3-Embedding-0.6B"
skill_top_k: int = 6
max_skills_prompt_chars: int = 30000
skills_include_companion_files: bool = True

# ------------------------------------------------------------------ #
# Context window #
Expand Down
2 changes: 2 additions & 0 deletions skillclaw/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"dir": str(_DEFAULT_SKILLS_DIR),
"retrieval_mode": "template",
"top_k": 6,
"companion_files": True,
},
"openrouter": {
"app_name": "SkillClaw",
Expand Down Expand Up @@ -370,6 +371,7 @@ def to_skillclaw_config(self) -> SkillClawConfig:
skills_public_root=str(skills.get("public_root", "") or ""),
retrieval_mode=skills.get("retrieval_mode", "template"),
skill_top_k=int(skills.get("top_k", 6)),
skills_include_companion_files=bool(skills.get("companion_files", True)),
max_context_tokens=int(data.get("max_context_tokens", 20000) or 20000),
# PRM
use_prm=bool(prm.get("enabled", True)),
Expand Down
1 change: 1 addition & 0 deletions skillclaw/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ async def _run(self, cfg):
public_skill_root=cfg.skills_public_root,
retrieval_mode=cfg.retrieval_mode,
embedding_model_path=cfg.embedding_model_path,
include_companion_files=cfg.skills_include_companion_files,
)
logger.info("[Launcher] SkillManager loaded: %s skills", skill_manager.get_skill_count())

Expand Down
90 changes: 90 additions & 0 deletions skillclaw/skill_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,60 @@

_SAFE_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$")

# ------------------------------------------------------------------ #
# Companion files #
# ------------------------------------------------------------------ #

# Directories inside a skill folder that carry load-bearing material
# beyond SKILL.md (reference docs, runnable scripts, assets, examples).
_COMPANION_DIRS = ("references", "reference", "docs", "assets", "scripts", "examples", "templates")

# Extra markdown docs at the skill root that are part of the skill
# (setup guides, usage notes) — everything except SKILL.md itself.
_COMPANION_ROOT_DOCS_RE = re.compile(
r"^(setup|changelog|readme|how[_-]?to[_-]?use|guide|usage|notes|architecture)"
r"(\.[a-z-]+)?\.md$",
re.IGNORECASE,
)

# Cap per skill so the injected catalog stays bounded for fat skills.
_MAX_COMPANION_FILES = 12


def _find_companion_files(skill_dir: str) -> list[str]:
"""Return the skill's companion files, as sorted paths relative to skill_dir.

General across any skill layout: files under the known companion
directories plus recognized root-level docs, never SKILL.md itself.
Ignored-noise paths (.git, __pycache__, *.pyc, .DS_Store) are skipped
with the same rules as skill bundles.
"""
from .skill_bundle import is_ignored_bundle_rel_path

found: list[str] = []
for sub in _COMPANION_DIRS:
sub_dir = os.path.join(skill_dir, sub)
if not os.path.isdir(sub_dir):
continue
for dirpath, dirnames, filenames in os.walk(sub_dir):
dirnames[:] = sorted(dirnames)
for filename in sorted(filenames):
full = os.path.join(dirpath, filename)
rel = os.path.relpath(full, skill_dir).replace(os.sep, "/")
if is_ignored_bundle_rel_path(rel):
continue
found.append(rel)
try:
for entry in sorted(os.listdir(skill_dir)):
if entry.upper() == "SKILL.MD":
continue
if _COMPANION_ROOT_DOCS_RE.match(entry) and os.path.isfile(os.path.join(skill_dir, entry)):
found.append(entry)
except OSError:
return []
return sorted(found)[:_MAX_COMPANION_FILES]


# ------------------------------------------------------------------ #
# Frontmatter parser #
# ------------------------------------------------------------------ #
Expand Down Expand Up @@ -174,6 +228,7 @@ def __init__(
public_skill_root: str = "",
retrieval_mode: str = "template",
embedding_model_path: Optional[str] = None,
include_companion_files: bool = True,
):
if retrieval_mode not in ("template", "embedding"):
raise ValueError(f"retrieval_mode must be 'template' or 'embedding', got '{retrieval_mode}'")
Expand All @@ -184,6 +239,7 @@ def __init__(
self._public_skill_root = public_skill_root.strip()
self.retrieval_mode = retrieval_mode
self.embedding_model_path = embedding_model_path or "Qwen/Qwen3-Embedding-0.6B"
self.include_companion_files = include_companion_files

self._embedding_model = None
self._skill_embeddings_cache: Optional[Dict] = None
Expand Down Expand Up @@ -304,6 +360,10 @@ def _load_skills(self) -> Dict[str, Any]:
skill = _parse_skill_md(path)
if skill is None:
continue
if self.include_companion_files:
companions = _find_companion_files(os.path.dirname(path))
if companions:
skill["companion_files"] = companions
result["all_skills"].append(skill)

return result
Expand All @@ -329,6 +389,23 @@ def _compute_skills_fingerprint(self) -> tuple[tuple[str, int, int], ...]:
int(stat.st_size),
)
)
# Companion files are part of the catalog: adding/editing/removing
# one must invalidate the cache even when SKILL.md is untouched.
if self.include_companion_files:
skill_dir = os.path.dirname(path)
for rel in _find_companion_files(skill_dir):
full = os.path.join(skill_dir, rel)
try:
cstat = os.stat(full)
except OSError:
continue
fingerprint.append(
(
os.path.realpath(full),
int(cstat.st_mtime_ns),
int(cstat.st_size),
)
)
return tuple(fingerprint)

def _is_hermes_skill_root(self) -> bool:
Expand Down Expand Up @@ -585,6 +662,12 @@ def format_skills_for_prompt(self, skills: list[dict]) -> str:
lines.append(
f" <location>{escape(self._public_skill_path(skill) or skill.get('file_path', ''))}</location>"
)
companions = skill.get("companion_files") or []
if companions:
lines.append(" <companion_files>")
for rel in companions:
lines.append(f" <file>{escape(rel)}</file>")
lines.append(" </companion_files>")
lines.append(" </skill>")
lines.append("</available_skills>")
return "\n".join(lines)
Expand Down Expand Up @@ -630,6 +713,12 @@ def build_skills_section(
trimmed = skills_prompt.strip()
if not trimmed:
return ""
companion_lines = []
if "<companion_files>" in trimmed:
companion_lines = [
"- If the chosen skill lists <companion_files>, read those files too before "
"acting — they are part of the skill (reference docs, scripts, setup guides).",
]
return "\n".join(
[
"## Skills (mandatory)",
Expand All @@ -639,6 +728,7 @@ def build_skills_section(
"- If multiple could apply: choose the most specific one, then read/follow it.",
"- If none clearly apply: do not read any SKILL.md.",
"Constraints: never read more than one skill up front; only read after selecting.",
*companion_lines,
"- When a skill drives external API writes, assume rate limits: prefer fewer "
"larger writes, avoid tight one-item loops, serialize bursts when possible, "
"and respect 429/Retry-After.",
Expand Down
177 changes: 177 additions & 0 deletions tests/test_skill_companion_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""Companion-file awareness in the injected skill catalog.

Agents reliably read a skill's SKILL.md but skip its companion files
(references/, scripts/, assets/, extra root docs) — the parts where setup
commands and load-bearing details live. The injected ``<available_skills>``
catalog should surface each skill's companion files as structured data so
the agent reads the *whole* skill, for any skill layout.
"""
from __future__ import annotations

from pathlib import Path

from skillclaw.skill_manager import SkillManager

SKILL_MD = """---
name: {name}
description: {desc}
---

# Body
"""


def _make_skill(
root: Path,
name: str,
*,
refs: list[str] | None = None,
scripts: list[str] | None = None,
root_docs: list[str] | None = None,
) -> None:
d = root / name
d.mkdir(parents=True)
(d / "SKILL.md").write_text(SKILL_MD.format(name=name, desc=f"Demo {name}"), encoding="utf-8")
for rel in refs or []:
p = d / "references" / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("# ref", encoding="utf-8")
for rel in scripts or []:
p = d / "scripts" / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("#!/bin/sh\n", encoding="utf-8")
for rel in root_docs or []:
(d / rel).write_text("# doc", encoding="utf-8")


def test_catalog_lists_companion_files(tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_make_skill(
skills_dir,
"demo-skill",
refs=["lifecycle.md", "gates.md"],
scripts=["run.sh"],
root_docs=["setup.md"],
)
mgr = SkillManager(skills_dir=str(skills_dir))
prompt = mgr.format_skills_for_prompt(mgr.get_all_skills())
assert "<companion_files>" in prompt
assert "references/lifecycle.md" in prompt
assert "references/gates.md" in prompt
assert "scripts/run.sh" in prompt
assert "setup.md" in prompt


def test_skill_without_companions_has_no_element(tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_make_skill(skills_dir, "bare-skill")
mgr = SkillManager(skills_dir=str(skills_dir))
prompt = mgr.format_skills_for_prompt(mgr.get_all_skills())
assert "<companion_files>" not in prompt


def test_companion_files_capped_and_sorted(tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_make_skill(skills_dir, "fat-skill", refs=[f"r{i:02d}.md" for i in range(20)])
mgr = SkillManager(skills_dir=str(skills_dir))
skill = mgr.get_all_skills()[0]
companions = skill.get("companion_files", [])
assert 0 < len(companions) <= 12
assert companions == sorted(companions)


def test_compact_format_omits_companions(tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_make_skill(skills_dir, "demo-skill", refs=["lifecycle.md"])
mgr = SkillManager(skills_dir=str(skills_dir))
compact = mgr.format_skills_compact(mgr.get_all_skills())
assert "<companion_files>" not in compact


def test_toggle_off_disables_detection(tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_make_skill(skills_dir, "demo-skill", refs=["lifecycle.md"], root_docs=["setup.md"])
mgr = SkillManager(skills_dir=str(skills_dir), include_companion_files=False)
skill = mgr.get_all_skills()[0]
assert "companion_files" not in skill
prompt = mgr.format_skills_for_prompt(mgr.get_all_skills())
assert "<companion_files>" not in prompt


def test_injection_instruction_mentions_companion_files(tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_make_skill(skills_dir, "demo-skill", refs=["lifecycle.md"])
mgr = SkillManager(skills_dir=str(skills_dir))
prompt = mgr.build_injection_prompt()
assert "companion_files" in prompt


def test_toggle_off_prompt_is_byte_identical_to_pre_feature(tmp_path: Path) -> None:
"""With the toggle off, neither the catalog nor the instruction may
mention companion files — output must match pre-feature behavior."""
skills_dir = tmp_path / "skills"
_make_skill(skills_dir, "demo-skill", refs=["lifecycle.md"], root_docs=["setup.md"])
mgr = SkillManager(skills_dir=str(skills_dir), include_companion_files=False)
prompt = mgr.build_injection_prompt()
assert "companion_files" not in prompt


def test_compact_fallback_drops_instruction_bullet(tmp_path: Path) -> None:
"""Oversized catalog -> compact format, which lists no companions; the
instruction bullet must not tell the agent to read files never listed."""
skills_dir = tmp_path / "skills"
_make_skill(skills_dir, "demo-skill", refs=["lifecycle.md"])
mgr = SkillManager(skills_dir=str(skills_dir))
prompt = mgr.build_injection_prompt(max_chars=10)
assert "companion_files" not in prompt


def test_companion_only_change_triggers_refresh(tmp_path: Path) -> None:
"""Adding a references/ file without touching SKILL.md must invalidate
the cache so refresh_if_changed reloads the catalog."""
skills_dir = tmp_path / "skills"
_make_skill(skills_dir, "demo-skill", refs=["lifecycle.md"])
mgr = SkillManager(skills_dir=str(skills_dir))
assert mgr.refresh_if_changed() is False
new_ref = skills_dir / "demo-skill" / "references" / "new.md"
new_ref.write_text("# new", encoding="utf-8")
assert mgr.refresh_if_changed() is True
skill = mgr.get_all_skills()[0]
assert "references/new.md" in skill.get("companion_files", [])


def test_ignored_noise_excluded(tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_make_skill(skills_dir, "demo-skill", scripts=["run.sh"])
cache = skills_dir / "demo-skill" / "scripts" / "__pycache__"
cache.mkdir()
(cache / "run.cpython-311.pyc").write_bytes(b"\x00")
mgr = SkillManager(skills_dir=str(skills_dir))
skill = mgr.get_all_skills()[0]
companions = skill.get("companion_files", [])
assert companions == ["scripts/run.sh"]


def test_config_mapping_round_trip(tmp_path: Path) -> None:
"""skills.companion_files in config.yaml reaches SkillClawConfig."""
import yaml

from skillclaw.config_store import ConfigStore

cfg = tmp_path / "config.yaml"
cfg.write_text(yaml.safe_dump({"skills": {"enabled": True, "companion_files": False}}))
assert ConfigStore(cfg).to_skillclaw_config().skills_include_companion_files is False
cfg.write_text(yaml.safe_dump({"skills": {"enabled": True}}))
assert ConfigStore(cfg).to_skillclaw_config().skills_include_companion_files is True


def test_paths_are_xml_escaped(tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
d = skills_dir / "demo-skill"
(d / "references").mkdir(parents=True)
(d / "SKILL.md").write_text(SKILL_MD.format(name="demo-skill", desc="Demo"), encoding="utf-8")
(d / "references" / "a&b.md").write_text("# ref", encoding="utf-8")
mgr = SkillManager(skills_dir=str(skills_dir))
prompt = mgr.format_skills_for_prompt(mgr.get_all_skills())
assert "a&amp;b.md" in prompt
assert "a&b.md" not in prompt