Skip to content
Merged
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
4 changes: 3 additions & 1 deletion emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3415,7 +3415,9 @@ async def handle_key(data: bytes) -> bool:
await conn.send_command("skills_update")
status.update(center="checking skill updates…")
else:
skills = load_skills()
# The session's directory, not this process's: the project
# skills a session has are the ones under its own root.
skills = load_skills(Path(cwd) if cwd else None)
if skills:
lines = ["**Loaded Skills:**", ""]
for s in skills:
Expand Down
37 changes: 25 additions & 12 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,10 +786,15 @@ def __init__(self, llm_config: LlmConfig) -> None:
self.tools.register(SubmitRantTool())
logger.info("tools registered: %s", self.tools.names)

# Load skills
self.skills = load_skills()
if self.skills:
logger.info("skills loaded: %s", [s.name for s in self.skills])
# Skills are **not** held here. A snapshot taken at startup cannot see a skill
# installed or edited later, and it was taken from the daemon's own working
# directory rather than the session's — so `load_skills` runs live, with the
# session's cwd, each time a system prompt is built and each time
# `skills_available` is answered (rant 2026-10-21T09:30:39). This line only
# records what the daemon's own directory sees, for the log.
startup_skills = load_skills()
if startup_skills:
logger.info("skills at startup: %s", [s.name for s in startup_skills])

async def serve(self) -> None:
"""Start listening for IPC connections (fixed-port, platform-adaptive)."""
Expand Down Expand Up @@ -1224,8 +1229,9 @@ async def _skills_ttl_loop(self) -> None:
while True:
result = await run_update_check_once()
if result.get("updated"):
# Nothing to refresh: the prompt reads the skills live, so the files
# this just rewrote are already what the next round sees.
logger.info("skills auto-updated: %s", result["updated"])
self.skills = load_skills()
elif result.get("errors"):
logger.warning("skills update errors: %s", result["errors"])
await asyncio.sleep(_UPDATE_TTL_SECONDS)
Expand Down Expand Up @@ -2444,11 +2450,16 @@ def _build_system_prompt(self, session: Session | None = None) -> str:
ctx["project_context"] = self._collect_project_context(session)

# ── Skills ──
if self.skills:
# Read live, from **this session's** working directory: a skill installed,
# edited or deleted on disk has to reach the next prompt without a daemon
# restart, and a session's project skills are the ones under its own root
# rather than under the directory the daemon was started in.
skills = load_skills(session.cwd if session else None)
if skills:
ctx["skills"] = [
{"name": s.name, "source": s.source, "path": s.path,
"description": s.description}
for s in self.skills
for s in skills
]

# ── Memory ──
Expand Down Expand Up @@ -3160,7 +3171,11 @@ async def _process_message(
# Installable-skills catalog (rant 2026-08-08T10:14:29): list
# catalog skills with installed/managed status.
entries = load_catalog_skills()
installed = {s.name for s in self.skills}
# Live, not a snapshot: the question is whether a skill is on disk *now*,
# which is the same reason the prompt reads them live. This frame carries
# no cwd, so the project half is the daemon's own directory — the catalog
# installs into the user root, which is read either way.
installed = {s.name for s in load_skills()}
result = [
{
"name": e.get("name", ""),
Expand All @@ -3186,10 +3201,8 @@ async def _process_message(
})
return
result = await install_skill(name, confirmed=confirmed)
if result.get("ok"):
# Reload so the next system-prompt build includes the skill
# (design: "下次构建系统提示即含该技能", no daemon restart needed).
self.skills = load_skills()
# No reload step: the prompt reads the skills live, so the file this just
# wrote is what the next system-prompt build sees.
await self._send(ws, {"type": "skills_install_result", "name": name, **result})

elif msg_type == "skills_update":
Expand Down
142 changes: 116 additions & 26 deletions emrg/skills/loader.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
"""Skill loader — parse Markdown skills with YAML frontmatter.

Skills are filesystem-based Markdown packages. They live in:
- ~/.emrg/skills/*.md (user skills)
- .emrg/skills/*.md (project skills)
Skills are filesystem-based Markdown packages. They live in two roots:

- ~/.emrg/skills/ (user skills)
- <project>/.emrg/skills/ (project skills)

and each root is read in **both** layouts, `<name>.md` and `<name>/SKILL.md`
(`_skill_files_in`). `<project>` is the nearest ancestor of the *session's*
working directory carrying a `.git` (`_project_root`) — not the process's cwd,
which for the daemon is `~/.emrg` and would give every session one project's
skills.

Nothing here is cached: every `load_skills` call re-reads the files, so a skill
written, edited or deleted on disk reaches the next prompt without a restart.

Each skill file has YAML frontmatter:
---
Expand Down Expand Up @@ -31,6 +41,11 @@
# boundary is one fact, and a second spelling of it is a second boundary that can drift.
from emrg.tools.base import non_regular_kind

# Where this instance's user skills live is `config_dir()/skills`, the same directory the
# installer publishes into and the catalog is read from — one derivation, three readers,
# and a test can redirect it in one place (`installer._resolve_dest` already does).
from emrg.config import config_dir

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -153,34 +168,109 @@ def _parse_skill_file(file_path: Path, source: str) -> Optional[Skill]:
)


def load_skills(project_dir: Optional[Path] = None) -> list[Skill]:
"""Load all skills from user and project directories.
def _project_root(start: Path) -> Path:
"""The nearest ancestor of ``start`` carrying a ``.git``, else ``start`` itself.

A session is normally opened *inside* a project rather than at its top, while the
project's skills sit at the top — so the root has to be resolved rather than
assumed to be the directory the session happens to be in. A directory with no
repository above it is its own root, which is what a scratch or temp directory
wants and what keeps a skills directory beside the files being worked on usable.
"""
for candidate in (start, *start.parents):
if (candidate / ".git").exists():
return candidate
return start


def _user_skills_root() -> Path:
"""``~/.emrg/skills`` — the instance-wide root, taken from `config_dir()`."""
return config_dir() / "skills"


def _skill_files_in(root: Path) -> list[Path]:
"""Every skill file one root carries, in both layouts the ecosystem uses.

A skill is either a flat ``<root>/<name>.md`` or a bundle ``<root>/<name>/SKILL.md``.
Both must be read: the flat form is what this instance's installer publishes
(``installer.install_skill`` writes ``<name>.md``) and what the shipped catalog is,
while the bundle is the layout the skill spec documents and the only one that can
carry the files a skill references beside it.

User skills (~/.emrg/skills/) are loaded first, then project skills.
Skills with duplicate names: project overrides user.
When one root carries both for the same name the **bundle wins**: it is the form
that can hold the rest of the skill, so the flat file beside it is a leftover copy
of the same skill rather than a second one.

Only the shape is decided here. Whether an entry can be opened at all — a FIFO, a
socket, a file with no frontmatter — stays `_parse_skill_file`'s question, which is
why a flat entry is listed without being stat'ed first: a FIFO named `wedged.md`
must reach the kind check that skips it, not be filtered out here where the skip
would be silent (issue #2083).
"""
try:
entries = list(root.iterdir())
except OSError:
logger.debug("skill: cannot list %s", root)
return []

flat: dict[str, Path] = {}
bundled: dict[str, Path] = {}
for entry in entries:
try:
is_dir = entry.is_dir()
except OSError:
continue
if is_dir:
body = entry / "SKILL.md"
if body.is_file():
bundled[entry.name] = body
elif entry.name.endswith(".md"):
flat[entry.stem] = entry

candidates = dict(flat)
candidates.update(bundled)
return [candidates[name] for name in sorted(candidates)]


def _skills_under(root: Path, source: str, into: dict[str, Skill]) -> None:
"""Read one root into ``into``, the later root winning on a shared name."""
for skill_file in _skill_files_in(root):
skill = _parse_skill_file(skill_file, source)
if skill:
into[skill.name] = skill
logger.debug("skill loaded: %s (%s) from %s", skill.name, source, skill_file)


def load_skills(project_dir: Optional[Path] = None) -> list[Skill]:
"""Load every skill from the user root and one project root, read live.

User skills (``~/.emrg/skills/``) are read first, then the project's
(``<root>/.emrg/skills/``); a skill of the same name: the project's wins.

``project_dir`` is the **session's** working directory — deliberately not the
process's, which for the daemon is whatever it was started in and made every
session read the same project's skills. It resolves to the nearest ancestor
carrying a ``.git`` (`_project_root`). Called with nothing, it falls back to the
process cwd, for a caller that has no session at all.

Nothing is cached, here or above: every call re-reads the files, so a skill
installed, edited or deleted on disk reaches the next prompt without a restart.
The cost is one directory listing and a few small reads (0.074 ms median measured
for three skills), and the result is a deterministic function of the files — an
unchanged tree renders a byte-identical prompt prefix, so the provider's cache
still matches.
"""
if project_dir is None:
project_dir = Path.cwd()
start = Path(project_dir) if project_dir is not None else Path.cwd()
project_root = _project_root(start)

all_skills: dict[str, Skill] = {}
user_root = _user_skills_root()
if user_root.is_dir():
_skills_under(user_root, "user", all_skills)

# User skills (lower priority)
user_dir = Path.home() / ".emrg" / "skills"
if user_dir.is_dir():
for md_file in sorted(user_dir.glob("*.md")):
skill = _parse_skill_file(md_file, "user")
if skill:
all_skills[skill.name] = skill
logger.debug("skill loaded: %s (user)", skill.name)

# Project skills (higher priority — override user)
proj_dir = project_dir / ".emrg" / "skills"
if proj_dir.is_dir():
for md_file in sorted(proj_dir.glob("*.md")):
skill = _parse_skill_file(md_file, "project")
if skill:
all_skills[skill.name] = skill
logger.debug("skill loaded: %s (project)", skill.name)
project_skills_root = project_root / ".emrg" / "skills"
if project_skills_root.is_dir():
_skills_under(project_skills_root, "project", all_skills)

return sorted(all_skills.values(), key=lambda s: s.name)

Expand Down
Loading
Loading