From cdea41bc03ee491401f1cc145cf9e14e1ab6d6e2 Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 02:28:29 +0000 Subject: [PATCH 01/14] feat(plugin): a shared skills library every host can see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec sections 1, 2 and the multi-directory gap it lists alongside them. This is the foundation the install/dedup/invalidation work sits on; it lands on its own because it is independently useful and independently reviewable. Today each of the five hosts scans its own directory and only its own, so a skill a user has in one agent is invisible to the other four. Raven has it worst: its default is a *relative* path, so two projects belonging to one user do not share even with each other. `shared.py` / `shared.ts` add one root — `~/.evermind-skillsearch`, the same expression on all three platforms — and a registry inside it. On Windows that is not the platform convention, and the module says why consistency wins here: this is the one path all five must compute identically, and a platform branch is somewhere for a service-launched agent to quietly resolve elsewhere. Hosts self-register rather than us hardcoding their five directories, because hardcoding is wrong three ways at once: versions change the default, users move it, and the Windows location is not knowable from here. Each writes the absolute path it actually resolved and reads the table back, so what it sees is exactly "the directories of the other hosts that also have this plugin". Three disciplines the file's editability depends on, all tested: - registering is idempotent — the steady state is one small read and no write, which matters because WorkBuddy's hook is a fresh process every turn, on the turn's hot path, inside an 8s budget; - re-registering never overwrites the user's `enabled`, so switching a host off survives that host restarting; - everything fails open. A missing, truncated or hand-corrupted registry costs this machine the sharing feature and nothing else — never a turn. `enabled` in the registry and `shareSkills`/`share_skills` in a host's own config are deliberately two switches, because users conflate them: the first is "do the others read me", the second is "do I read the others". Also closes the gap the spec notes in passing — Raven and Hermes had only a singular `skills_dir` and no environment override, while the engine has supported several roots all along. Both now take `skills_dirs` and `SKILLSEARCH_SKILLS_DIRS`, split the same way the three TypeScript packages already split it. The two ports write this file and read each other's writes on one machine, so `fixtures-registry.json` pins eleven parse cases and five unparseable documents that both suites assert against. A disagreement there would not be cosmetic; it would split a user's agents apart with nothing logged. Two things found by building it: Registration is a side effect of constructing an engine, which means the test suites started writing to the developer's real home directory — and one OpenClaw ranking assertion changed because retrieval had picked up whatever was installed there. Every suite now redirects `SKILLSEARCH_HOME` to a scratch directory, autouse on the Python side so a new test cannot forget. The project-inventory gate walks the working tree rather than the index, so a gitignored `.ruff_cache` still failed it. Dot-directories are now skipped. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check_project_inventory.py | 10 +- .../engine-python/skillsearch/shared.py | 363 ++++++++++++++++++ .../engine-python/tests/conftest.py | 24 ++ .../engine-python/tests/test_shared.py | 227 +++++++++++ .../engine-typescript/src/index.ts | 16 +- .../engine-typescript/src/shared.ts | 357 +++++++++++++++++ .../tests/fixtures-registry.json | 91 +++++ .../engine-typescript/tests/parity.test.ts | 95 +++++ skillcorpus_plugin/plugin-hermes/__init__.py | 12 + .../plugin-hermes/engine_adapter.py | 10 + .../plugin-hermes/tests/conftest.py | 24 ++ .../plugin-hermes/tests/test_provider.py | 52 +++ .../plugin-openclaw/openclaw.plugin.json | 5 + .../plugin-openclaw/src/config.ts | 15 + .../plugin-openclaw/src/register.ts | 12 +- .../plugin-openclaw/test/marketplace.test.ts | 7 + .../plugin-openclaw/test/model.test.ts | 7 + .../plugin-openclaw/test/register.test.ts | 9 + .../plugin-openclaw/test/tool.test.ts | 9 + .../plugin-openclaw2/openclaw.plugin.json | 5 + .../plugin-openclaw2/src/config.ts | 15 + .../plugin-openclaw2/src/register.ts | 12 +- .../plugin-openclaw2/test/marketplace.test.ts | 7 + .../plugin-openclaw2/test/model.test.ts | 7 + .../plugin-openclaw2/test/register.test.ts | 9 + .../plugin-openclaw2/test/tool.test.ts | 9 + .../skillsearch_raven/__init__.py | 11 + .../skillsearch_raven/raven-plugin.toml | 10 + .../plugin-raven/tests/conftest.py | 24 ++ .../plugin-raven/tests/test_segment.py | 82 +++- .../plugin-workbuddy/dist/hook.mjs | 206 ++++++++-- .../plugin-workbuddy/dist/mcp.mjs | 202 ++++++++-- .../plugin-workbuddy/src/config.ts | 15 + .../plugin-workbuddy/src/retrieve.ts | 15 +- .../test/cached-local-source.test.ts | 7 + .../plugin-workbuddy/test/config.test.ts | 7 + .../plugin-workbuddy/test/hook.test.ts | 7 + .../plugin-workbuddy/test/mcp.test.ts | 7 + 38 files changed, 1935 insertions(+), 67 deletions(-) create mode 100644 skillcorpus_plugin/engine-python/skillsearch/shared.py create mode 100644 skillcorpus_plugin/engine-python/tests/conftest.py create mode 100644 skillcorpus_plugin/engine-python/tests/test_shared.py create mode 100644 skillcorpus_plugin/engine-typescript/src/shared.ts create mode 100644 skillcorpus_plugin/engine-typescript/tests/fixtures-registry.json create mode 100644 skillcorpus_plugin/plugin-hermes/tests/conftest.py create mode 100644 skillcorpus_plugin/plugin-raven/tests/conftest.py diff --git a/scripts/check_project_inventory.py b/scripts/check_project_inventory.py index f7b79e2..49804a3 100644 --- a/scripts/check_project_inventory.py +++ b/scripts/check_project_inventory.py @@ -146,7 +146,15 @@ def _check_projects() -> list[str]: def _check_plugin_dirs() -> list[str]: - actual = {path.name for path in PLUGIN_ROOT.iterdir() if path.is_dir()} + # Dot-directories are never packages — `.ruff_cache`, `.pytest_cache` and + # friends appear wherever a tool was invoked from. They are gitignored, but + # this walks the working tree rather than the index, so an ignored + # directory still failed the gate and read as a missing inventory entry. + actual = { + path.name + for path in PLUGIN_ROOT.iterdir() + if path.is_dir() and not (path.name.startswith(".") and path.name != ".github") + } unexpected = sorted(actual - EXPECTED_PLUGIN_DIRS) missing = sorted(EXPECTED_PLUGIN_DIRS - actual) failures = [ diff --git a/skillcorpus_plugin/engine-python/skillsearch/shared.py b/skillcorpus_plugin/engine-python/skillsearch/shared.py new file mode 100644 index 0000000..c328301 --- /dev/null +++ b/skillcorpus_plugin/engine-python/skillsearch/shared.py @@ -0,0 +1,363 @@ +"""The one directory all five hosts agree on, and the registry inside it. + +Every host this plugin family supports scans its own skills directory and only +its own, so a skill a user has in one agent is invisible to the other four. +This module is the fix: a single shared root, and a registry each host writes +its own directory into at startup so the others can read it. + +## Why the root is a hardcoded expression + +``homedir() / ".evermind-skillsearch"``, the same on all three platforms, with +no per-platform branch. On Windows that expands to +``C:\\Users\\x\\.evermind-skillsearch``, which is not the Windows convention — +``%LOCALAPPDATA%`` is. Consistency is chosen over convention on purpose: + +- this is the *one* path all five hosts must compute identically, and the whole + feature is premised on them landing in the same place; +- a platform branch is somewhere for them to diverge. An agent started as a + service or a scheduled task has a different environment from a desktop + session, so ``%LOCALAPPDATA%`` can resolve elsewhere and the five split apart + with nothing logged; +- it is the plugin's own directory, not a system integration point, and the + user has to open it to drop skills in. + +``SKILLSEARCH_HOME`` overrides it, but only as an advanced escape hatch: a +GUI-launched agent never reads a shell profile, so the default can never depend +on it. + +## Why hosts self-register instead of us hardcoding their directories + +Hardcoding the five defaults is wrong in three ways at once — host versions +change the default, users move it, and the Windows location is not knowable +from here. So each host writes the absolute path it actually resolved at +runtime, and reads the whole table back. What a host then sees is exactly "the +directories of the other hosts that also have this plugin installed", which is +the correct set: a host without the plugin has nothing to share anyway. + +## Everything here fails open + +A registry that cannot be read or written costs this machine the sharing +feature, and nothing else. It must never cost a turn — on WorkBuddy the hook +that calls this runs per turn, inside an 8-second budget, and a raising hook +blocks the user's message. Every public function here swallows and degrades. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path + +#: Overrides the root. Advanced use only — see the module docstring. +HOME_ENV = "SKILLSEARCH_HOME" + +_NOTE = ( + "To exclude a directory, set its `enabled` to false. Deleting the line does " + "not work — that agent re-registers it on its next start." +) + + +def shared_root() -> Path: + """The shared root, honouring ``SKILLSEARCH_HOME``. + + Never raises and never creates anything: callers that only read should not + have to mkdir, and the ones that write say so. + """ + override = os.environ.get(HOME_ENV, "").strip() + if override: + return Path(os.path.expanduser(override)) + return Path(os.path.expanduser("~")) / ".evermind-skillsearch" + + +def shared_skills_dir() -> Path: + """Where skills installed through the plugin land.""" + return shared_root() / "skills" + + +def registry_path() -> Path: + """The registry of host skills directories.""" + return shared_root() / "registry.json" + + +def shared_config_path() -> Path: + """Settings all five hosts read, so one edit applies everywhere.""" + return shared_root() / "config.json" + + +@dataclass(frozen=True) +class HostEntry: + """One host's registration. + + ``enabled`` belongs to the user, not to the host that wrote the line. + """ + + id: str + dir: str + enabled: bool = True + + def as_json(self) -> dict[str, object]: + return {"id": self.id, "dir": self.dir, "enabled": self.enabled} + + +def read_registry(path: Path | None = None) -> list[HostEntry]: + """Every registered host, or an empty list if the file is unusable. + + A missing, truncated, hand-corrupted or half-written registry all answer + the same way. The alternative — raising — turns a broken JSON file into a + broken agent on five hosts at once. + """ + target = path or registry_path() + try: + raw = json.loads(target.read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + if not isinstance(raw, dict): + return [] + entries: list[HostEntry] = [] + seen: set[str] = set() + for item in raw.get("hosts") or []: + if not isinstance(item, dict): + continue + host_id = str(item.get("id") or "").strip() + directory = str(item.get("dir") or "").strip() + if not host_id or not directory or host_id in seen: + continue + seen.add(host_id) + enabled = item.get("enabled") + entries.append(HostEntry(host_id, directory, True if enabled is None else bool(enabled))) + return entries + + +def _write_registry(entries: list[HostEntry], path: Path) -> bool: + """Replace the registry atomically. ``False`` if anything went wrong. + + Temp file in the same directory, then ``os.replace`` — the same shape + `hub_client` uses for bundles, and for the same reason: five processes can + reach this concurrently, and a reader must never see a half-written file. + A loser of the race is fine; its next start writes again. + """ + payload = {"_note": _NOTE, "hosts": [entry.as_json() for entry in entries]} + try: + path.parent.mkdir(parents=True, exist_ok=True) + handle, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".registry-", suffix=".json") + try: + with os.fdopen(handle, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, ensure_ascii=False) + fh.write("\n") + os.replace(tmp, path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp) + raise + except (OSError, ValueError): + return False + return True + + +def register_host(host_id: str, skills_dir: str | os.PathLike[str] | None, path: Path | None = None) -> list[HostEntry]: + """Record this host's skills directory and return the whole table. + + Cheap and idempotent by design: the common case is one small read and no + write at all. WorkBuddy's hook calls this once per turn, on the turn's hot + path, so a write every turn would be a real cost — and five agents + rewriting the same file all day is a race nobody needs. + + ``enabled`` is never written back for an entry that already exists. That is + what makes the file editable: a user who sets ``enabled: false`` must not + have it undone by the next start of the host it belongs to. + + @param host_id - stable per host, e.g. ``"raven"``. One entry per id. + @param skills_dir - resolved to an absolute path; relative paths and ``~`` + would each have to be re-resolved by a reader on a different platform. + ``None`` or empty registers nothing but still returns the table. + @returns every entry, this host's included. Empty on any failure. + """ + target = path or registry_path() + entries = read_registry(target) + host_id = str(host_id or "").strip() + if not host_id or not skills_dir: + return entries + + try: + resolved = Path(os.path.expanduser(str(skills_dir))).resolve() + except (OSError, ValueError, RuntimeError): + return entries + if not resolved.is_absolute(): + return entries + wanted = str(resolved) + + for index, entry in enumerate(entries): + if entry.id != host_id: + continue + if entry.dir == wanted: + # The short circuit the per-turn caller depends on: one read. + return entries + # The path moved. Update it and keep the user's `enabled` as it is. + entries[index] = HostEntry(host_id, wanted, entry.enabled) + break + else: + entries.append(HostEntry(host_id, wanted, True)) + + _write_registry(entries, target) + return entries + + +def registered_dirs( + host_id: str = "", path: Path | None = None, *, include_self: bool = False +) -> list[tuple[str, str]]: + """Directories to scan, as ``(path, name)`` pairs. + + Filtered three ways, all of them deliberate: entries the user disabled are + dropped; entries whose directory no longer exists are dropped, because an + uninstalled agent leaves its line behind; and this host's own directory is + dropped unless asked for, since the caller already scans it and a second + copy would compete with itself in one ranking. + + @param host_id - whose entry to treat as "self". + @param include_self - keep this host's own entry in the result. + """ + out: list[tuple[str, str]] = [] + for entry in read_registry(path): + if not entry.enabled: + continue + if entry.id == host_id and not include_self: + continue + try: + if not Path(entry.dir).is_dir(): + continue + except OSError: + continue + out.append((entry.dir, entry.id)) + return out + + +def shared_dirs( + host_id: str, skills_dir: str | os.PathLike[str] | None = None, path: Path | None = None +) -> list[tuple[str, str]]: + """Register, then answer with everything worth scanning beyond our own. + + The one call a host adapter needs. The shared skills directory comes first + and is always present — it is where this plugin installs things, so it + exists whether or not any other host has registered. + + Never raises. + """ + try: + register_host(host_id, skills_dir, path) + dirs: list[tuple[str, str]] = [] + shared = shared_skills_dir() + if shared.is_dir(): + dirs.append((str(shared), "shared")) + own = None + if skills_dir: + try: + own = str(Path(os.path.expanduser(str(skills_dir))).resolve()) + except (OSError, ValueError, RuntimeError): + own = None + for directory, name in registered_dirs(host_id, path): + # Two hosts pointed at one directory is a real configuration — + # OpenClaw 1 and 2 share `~/.openclaw/skills` — and scanning it + # twice would double every skill in it. + if directory == own or any(directory == d for d, _ in dirs): + continue + dirs.append((directory, name)) + except Exception: # sharing is never worth a failed turn + return [] + return dirs + + +#: The list of extra skills directories, comma-separated. Named and split to +#: match the three TypeScript packages, which have had it since 0.2.0 — a +#: deployment that sets it should not have to care which host reads it. +DIRS_ENV = "SKILLSEARCH_SKILLS_DIRS" + + +def configured_dirs(value: object, env: dict[str, str] | None = None) -> list[str]: + """Extra directories from a config value or the environment. + + Accepts a list or a comma-separated string, the same two shapes and the + same splitting as `asList` in the TypeScript packages. The environment wins + over the config value, again matching them. + """ + source = env if env is not None else os.environ + raw: object = source.get(DIRS_ENV, "").strip() or value + if isinstance(raw, str): + return [part.strip() for part in raw.split(",") if part.strip()] + if isinstance(raw, (list, tuple)): + return [str(part).strip() for part in raw if str(part).strip()] + return [] + + +def extra_dirs_for( + host_id: str, + skills_dir: str | os.PathLike[str] | None, + configured: object = None, + path: Path | None = None, + env: dict[str, str] | None = None, +) -> list[dict[str, object]]: + """Everything to hand `SearchConfig.extra_dirs`, in one call. + + Three sources in order, deduplicated by resolved path: the directories the + deployment configured, the shared skills directory, and the other hosts' + directories from the registry. Registers this host on the way through. + + Returns plain dicts because that is what `SearchConfig.from_mapping` + coerces; the caller does not need to import `LocalDir`. + + Never raises — see the module docstring. + """ + out: list[dict[str, object]] = [] + seen: set[str] = set() + + def add(directory: str, name: str) -> None: + try: + resolved = str(Path(os.path.expanduser(directory)).resolve()) + except (OSError, ValueError, RuntimeError): + return + if resolved in seen: + return + seen.add(resolved) + out.append({"path": resolved, "name": name, "enabled": True}) + + try: + # The host's own main directory is scanned separately by the engine, + # so it only goes in `seen` — enough to keep the registry from adding + # it back a second time. + if skills_dir: + with contextlib.suppress(OSError, ValueError, RuntimeError): + seen.add(str(Path(os.path.expanduser(str(skills_dir))).resolve())) + for directory in configured_dirs(configured, env): + add(directory, "configured") + for directory, name in shared_dirs(host_id, skills_dir, path): + add(directory, name) + except Exception: # sharing is never worth a failed turn + return out + return out + + +def opted_in(value: object, default: bool = True) -> bool: + """Whether a host's config asked to join the shared library. + + Its own switch, separate from the registry's ``enabled``, because the two + answer different questions and users conflate them: this one is "do I read + the others", the registry's is "do the others read me". + + Strings are accepted because a host config file, or an environment + variable, delivers one — ``"false"``, ``"0"``, ``"no"`` and ``"off"`` all + mean off. + """ + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if text in ("false", "0", "no", "off"): + return False + if text in ("true", "1", "yes", "on"): + return True + return default diff --git a/skillcorpus_plugin/engine-python/tests/conftest.py b/skillcorpus_plugin/engine-python/tests/conftest.py new file mode 100644 index 0000000..3b6ffa4 --- /dev/null +++ b/skillcorpus_plugin/engine-python/tests/conftest.py @@ -0,0 +1,24 @@ +"""Keep the tests out of the user's home directory. + +Building an engine registers this host in the shared registry, which lives +under `~/.evermind-skillsearch`. A test that writes there pollutes the machine +it runs on and — worse — makes its own result depend on whatever the developer +happens to have installed, which is how a green suite hides a real ranking +change. Autouse so a new test cannot forget. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True) +def _isolated_shared_root(tmp_path_factory: pytest.TempPathFactory, + monkeypatch: pytest.MonkeyPatch) -> None: + root: Path = tmp_path_factory.mktemp("skillsearch-home") + monkeypatch.setenv("SKILLSEARCH_HOME", str(root)) + # A test that wants extra directories asks for them; inheriting the + # developer's would be the same leak by another route. + monkeypatch.delenv("SKILLSEARCH_SKILLS_DIRS", raising=False) diff --git a/skillcorpus_plugin/engine-python/tests/test_shared.py b/skillcorpus_plugin/engine-python/tests/test_shared.py new file mode 100644 index 0000000..760f517 --- /dev/null +++ b/skillcorpus_plugin/engine-python/tests/test_shared.py @@ -0,0 +1,227 @@ +"""The shared root and the host registry. + +The acceptance list in the spec is mostly about what must *not* happen — a +user's `enabled` must not be undone, a broken file must not raise, a per-turn +caller must not write every turn — so most of these assert an absence. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from skillsearch import shared + + +@pytest.fixture +def registry(tmp_path: Path) -> Path: + return tmp_path / "registry.json" + + +def test_the_root_is_the_same_expression_everywhere(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(shared.HOME_ENV, raising=False) + monkeypatch.setattr(os.path, "expanduser", lambda p: p.replace("~", "/home/x", 1)) + assert shared.shared_root() == Path("/home/x/.evermind-skillsearch") + assert shared.shared_skills_dir() == Path("/home/x/.evermind-skillsearch/skills") + assert shared.registry_path() == Path("/home/x/.evermind-skillsearch/registry.json") + + +def test_the_env_override_wins(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv(shared.HOME_ENV, str(tmp_path / "elsewhere")) + assert shared.shared_root() == tmp_path / "elsewhere" + + +def test_registering_writes_an_absolute_path(registry: Path, tmp_path: Path) -> None: + skills = tmp_path / "skills" + skills.mkdir() + entries = shared.register_host("raven", skills, registry) + assert [(e.id, e.dir, e.enabled) for e in entries] == [("raven", str(skills.resolve()), True)] + assert json.loads(registry.read_text())["hosts"][0]["dir"] == str(skills.resolve()) + + +def test_registering_again_with_the_same_path_does_not_write(registry: Path, tmp_path: Path) -> None: + """The short circuit WorkBuddy's per-turn hook depends on. + + Asserted through the file's mtime rather than by counting calls: what + matters is that the file on disk is untouched, however that is achieved. + """ + skills = tmp_path / "skills" + skills.mkdir() + shared.register_host("workbuddy", skills, registry) + before = registry.stat().st_mtime_ns + os.utime(registry, ns=(before - 10_000_000_000, before - 10_000_000_000)) + stamped = registry.stat().st_mtime_ns + + shared.register_host("workbuddy", skills, registry) + + assert registry.stat().st_mtime_ns == stamped + + +def test_a_moved_directory_is_updated_but_keeps_the_users_enabled(registry: Path, tmp_path: Path) -> None: + """The rule that makes the file editable. + + A user who has switched a host off must not have that undone the next time + the host starts — which is exactly when the host re-registers. + """ + first, second = tmp_path / "one", tmp_path / "two" + first.mkdir() + second.mkdir() + shared.register_host("hermes", first, registry) + + document = json.loads(registry.read_text()) + document["hosts"][0]["enabled"] = False + registry.write_text(json.dumps(document)) + + entries = shared.register_host("hermes", second, registry) + + assert len(entries) == 1 + assert entries[0].dir == str(second.resolve()) + assert entries[0].enabled is False + + +def test_one_entry_per_host_id(registry: Path, tmp_path: Path) -> None: + a, b = tmp_path / "a", tmp_path / "b" + a.mkdir() + b.mkdir() + shared.register_host("openclaw2", a, registry) + shared.register_host("openclaw2", b, registry) + assert len(shared.read_registry(registry)) == 1 + + +def test_a_corrupt_registry_reads_as_empty_rather_than_raising(registry: Path) -> None: + registry.write_text("{not json at all") + assert shared.read_registry(registry) == [] + + +def test_a_corrupt_registry_does_not_stop_a_host_registering(registry: Path, tmp_path: Path) -> None: + """Acceptance 9: a broken registry costs sharing, not retrieval.""" + registry.write_text("]]]") + skills = tmp_path / "skills" + skills.mkdir() + entries = shared.register_host("raven", skills, registry) + assert [e.id for e in entries] == ["raven"] + + +def test_entries_without_an_id_or_a_dir_are_skipped(registry: Path) -> None: + registry.write_text( + json.dumps( + { + "hosts": [ + {"id": "", "dir": "/x"}, + {"id": "y"}, + {"dir": "/z"}, + "not a dict", + {"id": "ok", "dir": "/ok"}, + ] + } + ) + ) + assert [e.id for e in shared.read_registry(registry)] == ["ok"] + + +def test_disabled_and_missing_directories_are_not_scanned(registry: Path, tmp_path: Path) -> None: + live, gone = tmp_path / "live", tmp_path / "gone" + live.mkdir() + registry.write_text( + json.dumps( + { + "hosts": [ + {"id": "a", "dir": str(live), "enabled": True}, + {"id": "b", "dir": str(live), "enabled": False}, + {"id": "c", "dir": str(gone), "enabled": True}, + ] + } + ) + ) + assert shared.registered_dirs("", registry) == [(str(live), "a")] + + +def test_a_host_does_not_scan_its_own_directory_twice(registry: Path, tmp_path: Path) -> None: + mine, theirs = tmp_path / "mine", tmp_path / "theirs" + mine.mkdir() + theirs.mkdir() + registry.write_text( + json.dumps( + { + "hosts": [ + {"id": "raven", "dir": str(mine)}, + {"id": "hermes", "dir": str(theirs)}, + ] + } + ) + ) + assert shared.registered_dirs("raven", registry) == [(str(theirs), "hermes")] + assert len(shared.registered_dirs("raven", registry, include_self=True)) == 2 + + +def test_shared_dirs_registers_and_lists(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + root = tmp_path / "root" + monkeypatch.setenv(shared.HOME_ENV, str(root)) + (root / "skills").mkdir(parents=True) + mine, theirs = tmp_path / "mine", tmp_path / "theirs" + mine.mkdir() + theirs.mkdir() + shared.register_host("hermes", theirs, root / "registry.json") + + dirs = shared.shared_dirs("raven", mine, root / "registry.json") + + # The shared skills directory first, then other hosts; never our own. + assert dirs == [(str(root / "skills"), "shared"), (str(theirs.resolve()), "hermes")] + assert [e.id for e in shared.read_registry(root / "registry.json")] == ["hermes", "raven"] + + +def test_two_hosts_on_one_directory_is_scanned_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """OpenClaw 1 and 2 share `~/.openclaw/skills`; scanning it twice would + double every skill in it.""" + root = tmp_path / "root" + monkeypatch.setenv(shared.HOME_ENV, str(root)) + common = tmp_path / "openclaw-skills" + common.mkdir() + registry = root / "registry.json" + shared.register_host("openclaw", common, registry) + shared.register_host("openclaw2", common, registry) + + assert shared.shared_dirs("hermes", tmp_path / "hermes-skills", registry) == [ + (str(common.resolve()), "openclaw"), + ] + + +def test_shared_dirs_never_raises(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv(shared.HOME_ENV, str(tmp_path / "root")) + + def explode(*_a: object, **_k: object) -> None: + raise RuntimeError("disk on fire") + + monkeypatch.setattr(shared, "register_host", explode) + assert shared.shared_dirs("raven", tmp_path) == [] + + +# --------------------------------------------------------------------------- +# the cross-language contract + + +FIXTURES = Path(__file__).resolve().parents[2] / "engine-typescript" / "tests" / "fixtures-registry.json" + + +def _fixtures() -> dict: + return json.loads(FIXTURES.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("case", _fixtures()["cases"], ids=lambda c: c["why"]) +def test_registry_parses_the_same_as_the_typescript_port(case: dict, registry: Path) -> None: + """Both ports read this file; a disagreement splits a user's agents apart. + + The fixture is shared with `engine-typescript/tests/parity.test.ts`, which + asserts the same expectations against the same documents. + """ + registry.write_text(json.dumps(case["document"]), encoding="utf-8") + assert [e.as_json() for e in shared.read_registry(registry)] == case["expected"] + + +@pytest.mark.parametrize("text", _fixtures()["unparseable"]) +def test_unparseable_registries_read_as_empty_in_both_ports(text: str, registry: Path) -> None: + registry.write_text(text, encoding="utf-8") + assert shared.read_registry(registry) == [] diff --git a/skillcorpus_plugin/engine-typescript/src/index.ts b/skillcorpus_plugin/engine-typescript/src/index.ts index 776ec4f..69a5c7e 100644 --- a/skillcorpus_plugin/engine-typescript/src/index.ts +++ b/skillcorpus_plugin/engine-typescript/src/index.ts @@ -31,6 +31,7 @@ import type { SkillSource } from './types.js' import { LLMGateFilter } from './gate.js' import { HubSkillSource, SkillHubClient } from './hub-source.js' import { LocalSkillSource } from './local-source.js' +import { scanDirs } from './shared.js' import { MarketplaceClient, MarketplaceSkillSource } from './marketplace-source.js' import { QueryRewriter } from './rewriter.js' @@ -57,6 +58,7 @@ export { resolveRefs } from './refs.js' export interface Config { /** Directories scanned for `SKILL.md`. Relative paths resolve against cwd. */ skillsDirs?: string[] + shareSkills?: boolean /** Remote catalog base URL. Empty disables the remote source. */ hubEndpoint?: string /** Bearer token the catalog requires, if any. */ @@ -157,6 +159,10 @@ export interface Config { export const Config: z = z.object({ skillsDirs: z.array(z.string()).default(['.dsh/skills']), + // Join the cross-host shared library. Off stops this host reading the + // others; to stop the others reading this one, set `enabled: false` on + // its line in the shared registry.json. + shareSkills: z.boolean().default(true), hubEndpoint: z.string().default('https://skillhub.evermind.ai'), hubApiKey: z.string().default(''), clawhubEndpoint: z.string().default('https://clawhub.ai'), @@ -340,11 +346,11 @@ function buildEngine(ctx: Context, cfg: Config): SkillSearchEngine { const sources: SkillSource[] = [] const dirs = cfg.skillsDirs ?? [] - if (dirs.length > 0) { - const local = new LocalSkillSource( - dirs.map(path => ({ path, name: 'local' })), - { indexBody: cfg.indexBody ?? false }, - ) + // Registers this harness's skills directory so the other four hosts can + // scan it, and appends the shared directory plus whatever they registered. + const roots = scanDirs('deepseek-harness', dirs, cfg.shareSkills) + if (roots.length > 0) { + const local = new LocalSkillSource(roots, { indexBody: cfg.indexBody ?? false }) local.weight = cfg.weightLocal ?? 1.0 sources.push(local) } diff --git a/skillcorpus_plugin/engine-typescript/src/shared.ts b/skillcorpus_plugin/engine-typescript/src/shared.ts new file mode 100644 index 0000000..663626e --- /dev/null +++ b/skillcorpus_plugin/engine-typescript/src/shared.ts @@ -0,0 +1,357 @@ +/** + * The one directory all five hosts agree on, and the registry inside it. + * + * A byte-for-byte counterpart of `engine-python/skillsearch/shared.py`. The two + * write the same file and read each other's writes — a Python host and a + * TypeScript host on one machine share this registry — so the semantics are + * not merely similar, they have to match. `tests/parity.test.ts` pins the + * cases where a difference would be invisible until it split five agents + * apart on a user's machine. + * + * ## Why the root is a hardcoded expression + * + * `homedir()/.evermind-skillsearch`, identical on all three platforms, with no + * per-platform branch. On Windows that is `C:\Users\x\.evermind-skillsearch`, + * which is not the Windows convention — `%LOCALAPPDATA%` is. Consistency is + * chosen over convention deliberately: + * + * - this is the *one* path all five hosts must compute identically, and the + * whole feature is premised on them landing in the same place; + * - a platform branch is somewhere for them to diverge. An agent started as a + * service or a scheduled task has a different environment from a desktop + * session, so `%LOCALAPPDATA%` can resolve elsewhere and the five split + * apart with nothing logged; + * - it is the plugin's own directory, not a system integration point, and the + * user has to open it to drop skills in. + * + * `SKILLSEARCH_HOME` overrides it, but only as an advanced escape hatch: a + * GUI-launched agent never reads a shell profile, so the default can never + * depend on it. + * + * ## Why hosts self-register + * + * Hardcoding the five defaults is wrong three ways at once — host versions + * change the default, users move it, and the Windows location is not knowable + * from here. Each host instead writes the absolute path it actually resolved + * at runtime and reads the whole table back. What it then sees is exactly "the + * directories of the other hosts that also have this plugin", which is the + * right set: a host without the plugin has nothing to share. + * + * ## Everything here fails open + * + * A registry that cannot be read or written costs this machine the sharing + * feature and nothing else. It must never cost a turn — WorkBuddy's hook calls + * this once per turn inside an 8-second budget, and a hook that throws blocks + * the user's message. + * + * @module + */ + +import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' + +/** Overrides the root. Advanced use only — see the module docs. */ +export const HOME_ENV = 'SKILLSEARCH_HOME' + +const NOTE = + 'To exclude a directory, set its `enabled` to false. Deleting the line does ' + + 'not work — that agent re-registers it on its next start.' + +/** One host's registration. `enabled` belongs to the user, not to the host. */ +export interface HostEntry { + readonly id: string + readonly dir: string + readonly enabled: boolean +} + +/** Expand a leading `~` against the user's home, leaving other paths alone. */ +function expandHome(path: string, home: string = homedir()): string { + if (path === '~') return home + if (path.startsWith('~/')) return join(home, path.slice(2)) + return path +} + +/** + * The shared root, honouring `SKILLSEARCH_HOME`. + * + * Never throws and never creates anything: a caller that only reads should not + * have to mkdir, and the ones that write say so. + */ +export function sharedRoot(env: NodeJS.ProcessEnv = process.env): string { + const override = (env[HOME_ENV] ?? '').trim() + if (override) return expandHome(override) + return join(homedir(), '.evermind-skillsearch') +} + +/** Where skills installed through the plugin land. */ +export function sharedSkillsDir(env: NodeJS.ProcessEnv = process.env): string { + return join(sharedRoot(env), 'skills') +} + +/** The registry of host skills directories. */ +export function registryPath(env: NodeJS.ProcessEnv = process.env): string { + return join(sharedRoot(env), 'registry.json') +} + +/** Settings all five hosts read, so one edit applies everywhere. */ +export function sharedConfigPath(env: NodeJS.ProcessEnv = process.env): string { + return join(sharedRoot(env), 'config.json') +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +/** + * Every registered host, or an empty list if the file is unusable. + * + * Missing, truncated, hand-corrupted and half-written all answer the same way. + * The alternative — throwing — turns one broken JSON file into a broken agent + * on five hosts at once. + */ +export function readRegistry(path?: string, env: NodeJS.ProcessEnv = process.env): HostEntry[] { + const target = path ?? registryPath(env) + let raw: unknown + try { + raw = JSON.parse(readFileSync(target, 'utf8')) + } catch { + return [] + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [] + const hosts = (raw as { hosts?: unknown }).hosts + if (!Array.isArray(hosts)) return [] + + const entries: HostEntry[] = [] + const seen = new Set() + for (const item of hosts) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue + const record = item as { id?: unknown; dir?: unknown; enabled?: unknown } + const id = String(record.id ?? '').trim() + const dir = String(record.dir ?? '').trim() + if (!id || !dir || seen.has(id)) continue + seen.add(id) + entries.push({ id, dir, enabled: record.enabled == null ? true : Boolean(record.enabled) }) + } + return entries +} + +/** + * Replace the registry atomically. `false` if anything went wrong. + * + * Temp file beside the target, then `rename` — the same shape the bundle + * installer uses, and for the same reason: five processes can reach this + * concurrently and a reader must never see a half-written file. Losing the + * race is fine; the loser's next start writes again. + */ +function writeRegistry(entries: HostEntry[], path: string): boolean { + const payload = { + _note: NOTE, + hosts: entries.map(entry => ({ id: entry.id, dir: entry.dir, enabled: entry.enabled })), + } + let staging: string | undefined + try { + const parent = path.slice(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))) + mkdirSync(parent, { recursive: true }) + staging = mkdtempSync(join(parent, '.registry-')) + const scratch = join(staging, 'registry.json') + writeFileSync(scratch, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + renameSync(scratch, path) + return true + } catch { + return false + } finally { + if (staging) { + try { + rmSync(staging, { recursive: true, force: true }) + } catch { + // Leaving a scratch directory behind is not worth a thrown error. + } + } + } +} + +/** + * Record this host's skills directory and return the whole table. + * + * Cheap and idempotent by design: the common case is one small read and no + * write. WorkBuddy's hook calls this once per turn on the turn's hot path, so + * writing every turn would be a real cost — and five agents rewriting one file + * all day is a race nobody needs. + * + * `enabled` is never written back for an entry that already exists. That is + * what makes the file editable: a user who sets `enabled: false` must not have + * it undone by the next start of the host it belongs to. + * + * @param hostId - stable per host, e.g. `"openclaw2"`. One entry per id. + * @param skillsDir - resolved to an absolute path; a relative path or a `~` + * would each have to be re-resolved by a reader on another platform. + * @returns every entry, this host's included. Empty on any failure. + */ +export function registerHost( + hostId: string, + skillsDir: string | undefined, + path?: string, + env: NodeJS.ProcessEnv = process.env, +): HostEntry[] { + const target = path ?? registryPath(env) + const entries = readRegistry(target, env) + const id = String(hostId ?? '').trim() + if (!id || !skillsDir) return entries + + let wanted: string + try { + wanted = resolve(expandHome(skillsDir)) + } catch { + return entries + } + if (!isAbsolute(wanted)) return entries + + const index = entries.findIndex(entry => entry.id === id) + const existing = index >= 0 ? entries[index] : undefined + if (existing) { + // The short circuit the per-turn caller depends on: one read, no write. + if (existing.dir === wanted) return entries + // The path moved. Update it and leave the user's `enabled` alone. + entries[index] = { id, dir: wanted, enabled: existing.enabled } + } else { + entries.push({ id, dir: wanted, enabled: true }) + } + + writeRegistry(entries, target) + return entries +} + +/** + * Directories to scan, as `[path, name]` pairs. + * + * Filtered three ways, all deliberate: entries the user disabled are dropped; + * entries whose directory no longer exists are dropped, because an uninstalled + * agent leaves its line behind; and this host's own directory is dropped + * unless asked for, since the caller already scans it and a second copy would + * compete with itself in one ranking. + */ +export function registeredDirs( + hostId = '', + path?: string, + options: { includeSelf?: boolean } = {}, + env: NodeJS.ProcessEnv = process.env, +): Array<[string, string]> { + const out: Array<[string, string]> = [] + for (const entry of readRegistry(path, env)) { + if (!entry.enabled) continue + if (entry.id === hostId && !options.includeSelf) continue + if (!isDirectory(entry.dir)) continue + out.push([entry.dir, entry.id]) + } + return out +} + +/** + * Register, then answer with everything worth scanning beyond our own. + * + * The one call a host adapter needs. The shared skills directory comes first + * and is listed whenever it exists — it is where this plugin installs things, + * independent of whether any other host has registered. + * + * Never throws. + */ +export function sharedDirs( + hostId: string, + skillsDir?: string, + path?: string, + env: NodeJS.ProcessEnv = process.env, +): Array<[string, string]> { + try { + registerHost(hostId, skillsDir, path, env) + const dirs: Array<[string, string]> = [] + const shared = sharedSkillsDir(env) + if (isDirectory(shared)) dirs.push([shared, 'shared']) + + let own: string | undefined + if (skillsDir) { + try { + own = resolve(expandHome(skillsDir)) + } catch { + own = undefined + } + } + for (const [dir, name] of registeredDirs(hostId, path, {}, env)) { + // Two hosts pointed at one directory is a real configuration — OpenClaw + // 1 and 2 share `~/.openclaw/skills` — and scanning it twice would + // double every skill in it. + if (dir === own || dirs.some(([seen]) => seen === dir)) continue + dirs.push([dir, name]) + } + return dirs + } catch { + return [] + } +} + +/** + * Whether a host's config asked to join the shared library. + * + * Its own switch, separate from the registry's `enabled`, because the two + * answer different questions and users conflate them: this one is "do I read + * the others", the registry's is "do the others read me". + * + * Strings are accepted because a config file or an environment variable + * delivers one. + */ +export function optedIn(value: unknown, fallback = true): boolean { + if (value == null) return fallback + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value !== 0 + const text = String(value).trim().toLowerCase() + if (['false', '0', 'no', 'off'].includes(text)) return false + if (['true', '1', 'yes', 'on'].includes(text)) return true + return fallback +} + +/** + * Every directory this host should scan, its own first. + * + * The one call a host's engine builder needs: registers the host's main + * directory so the others can find it, then appends the shared skills + * directory and the other hosts' directories. Deduplicated by path, because + * the same directory listed twice doubles every skill in it in one ranking — + * and OpenClaw 1 and 2 both defaulting to `~/.openclaw/skills` makes that a + * real configuration rather than a hypothetical. + * + * Never throws; the worst case is the host's own directories, unchanged. + * + * @param hostId - stable per host, e.g. `"workbuddy"`. + * @param ownDirs - already expanded and absolute. The first is registered as + * this host's directory; the rest are the deployment's extra roots. + * @param share - `false` keeps this host out of the shared library entirely. + */ +export function scanDirs( + hostId: string, + ownDirs: readonly string[], + share = true, + path?: string, + env: NodeJS.ProcessEnv = process.env, +): Array<{ path: string; name: string }> { + const out: Array<{ path: string; name: string }> = [] + const seen = new Set() + const add = (dir: string, name: string): void => { + if (!dir || seen.has(dir)) return + seen.add(dir) + out.push({ path: dir, name }) + } + + for (const dir of ownDirs) add(dir, 'local') + if (!share) return out + + try { + for (const [dir, name] of sharedDirs(hostId, ownDirs[0], path, env)) add(dir, name) + } catch { + // Sharing is never worth a failed turn. + } + return out +} diff --git a/skillcorpus_plugin/engine-typescript/tests/fixtures-registry.json b/skillcorpus_plugin/engine-typescript/tests/fixtures-registry.json new file mode 100644 index 0000000..d1a4500 --- /dev/null +++ b/skillcorpus_plugin/engine-typescript/tests/fixtures-registry.json @@ -0,0 +1,91 @@ +{ + "_note": "Shared by engine-python/tests/test_shared.py and engine-typescript/tests/parity.test.ts. Both parse every `document` below and must produce the matching `expected`. A Python host and a TypeScript host write this same file on one machine and read each other's writes, so a disagreement here is not cosmetic — it splits a user's agents apart with nothing logged.", + "cases": [ + { + "why": "the ordinary case", + "document": { + "hosts": [ + { "id": "openclaw2", "dir": "/u/.openclaw/skills", "enabled": true }, + { "id": "raven", "dir": "/u/proj/skills", "enabled": false } + ] + }, + "expected": [ + { "id": "openclaw2", "dir": "/u/.openclaw/skills", "enabled": true }, + { "id": "raven", "dir": "/u/proj/skills", "enabled": false } + ] + }, + { + "why": "a missing `enabled` defaults to true, so a hand-written line works", + "document": { "hosts": [{ "id": "hermes", "dir": "/u/.hermes/skills" }] }, + "expected": [{ "id": "hermes", "dir": "/u/.hermes/skills", "enabled": true }] + }, + { + "why": "an explicit null is the same as absent", + "document": { "hosts": [{ "id": "hermes", "dir": "/u/x", "enabled": null }] }, + "expected": [{ "id": "hermes", "dir": "/u/x", "enabled": true }] + }, + { + "why": "truthiness, not type-strictness — a hand-edited 0 or \"\" means off", + "document": { + "hosts": [ + { "id": "a", "dir": "/a", "enabled": 0 }, + { "id": "b", "dir": "/b", "enabled": "" }, + { "id": "c", "dir": "/c", "enabled": "false" } + ] + }, + "expected": [ + { "id": "a", "dir": "/a", "enabled": false }, + { "id": "b", "dir": "/b", "enabled": false }, + { "id": "c", "dir": "/c", "enabled": true } + ] + }, + { + "why": "whitespace around an id or a dir is trimmed before use", + "document": { "hosts": [{ "id": " raven ", "dir": " /u/x " }] }, + "expected": [{ "id": "raven", "dir": "/u/x", "enabled": true }] + }, + { + "why": "an entry missing either half is dropped, not defaulted", + "document": { + "hosts": [ + { "id": "", "dir": "/x" }, + { "id": "y" }, + { "dir": "/z" }, + { "id": "ok", "dir": "/ok" } + ] + }, + "expected": [{ "id": "ok", "dir": "/ok", "enabled": true }] + }, + { + "why": "first line wins on a duplicate id, so a hand-added line above is honoured", + "document": { + "hosts": [ + { "id": "raven", "dir": "/first" }, + { "id": "raven", "dir": "/second" } + ] + }, + "expected": [{ "id": "raven", "dir": "/first", "enabled": true }] + }, + { + "why": "non-objects in the array are skipped rather than raising", + "document": { "hosts": ["nope", 7, null, { "id": "ok", "dir": "/ok" }] }, + "expected": [{ "id": "ok", "dir": "/ok", "enabled": true }] + }, + { + "why": "no hosts key at all", + "document": { "_note": "empty" }, + "expected": [] + }, + { + "why": "hosts is the wrong type", + "document": { "hosts": { "raven": "/x" } }, + "expected": [] + }, + { + "why": "the whole document is the wrong type", + "document": ["raven"], + "expected": [] + } + ], + "unparseable": ["{not json at all", "]]]", "", "null", "42"] +} diff --git a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts index 56d73bb..ccd0e1d 100644 --- a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts +++ b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts @@ -12,6 +12,7 @@ */ import assert from 'node:assert/strict' +import { readFileSync, statSync } from 'node:fs' import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -23,6 +24,7 @@ import { LLMGateFilter } from '../src/gate.ts' import { LocalSkillSource, formatSkillText } from '../src/local-source.ts' import { resolvePlaceholders, resolveRefs } from '../src/refs.ts' import { QueryRewriter } from '../src/rewriter.ts' +import { readRegistry, registerHost, registeredDirs, sharedDirs, sharedRoot } from '../src/shared.ts' import { checkKeywordRelevance, queryTerms } from '../src/relevance.ts' import { SkillSearchEngine } from '../src/engine.ts' import { HubSkillSource, SkillHubClient } from '../src/hub-source.ts' @@ -540,3 +542,96 @@ test('Kubernetes is not mangled by plural normalization', () => { assert.deepEqual(queryTerms('kubernetes deployment'), ['kubernetes', 'deployment']) assert.equal(checkKeywordRelevance('kubernetes deployment', candidate).passed, true) }) + +// --------------------------------------------------------------------------- +// the host registry, against the fixture the Python suite also reads + +const registryFixtures = JSON.parse( + readFileSync(new URL('./fixtures-registry.json', import.meta.url), 'utf8'), +) as { + cases: Array<{ why: string; document: unknown; expected: unknown[] }> + unparseable: string[] +} + +test('the registry parses the same as the Python port', async () => { + // A Python host and a TypeScript host write this same file on one machine + // and read each other's writes, so a disagreement here is not cosmetic — + // it splits a user's agents apart with nothing logged. + const root = await mkdtemp(join(tmpdir(), 'skillsearch-registry-')) + for (const [index, item] of registryFixtures.cases.entries()) { + const path = join(root, `case-${index}.json`) + await writeFile(path, JSON.stringify(item.document)) + assert.deepEqual(readRegistry(path), item.expected, item.why) + } +}) + +test('an unparseable registry reads as empty in both ports', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillsearch-registry-bad-')) + for (const [index, text] of registryFixtures.unparseable.entries()) { + const path = join(root, `bad-${index}.json`) + await writeFile(path, text) + assert.deepEqual(readRegistry(path), [], JSON.stringify(text)) + } +}) + +test('the shared root is one expression, and SKILLSEARCH_HOME overrides it', () => { + assert.ok(sharedRoot({}).endsWith('.evermind-skillsearch')) + assert.equal(sharedRoot({ SKILLSEARCH_HOME: '/tmp/elsewhere' }), '/tmp/elsewhere') + // Blank is not an override: an unset-looking variable must not point the + // five hosts at the process's current directory. + assert.ok(sharedRoot({ SKILLSEARCH_HOME: ' ' }).endsWith('.evermind-skillsearch')) +}) + +test('registering is idempotent and never overwrites the user’s enabled', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillsearch-register-')) + const registry = join(root, 'registry.json') + const skills = join(root, 'skills') + await mkdir(skills) + + registerHost('openclaw2', skills, registry) + const stamped = statSync(registry).mtimeNs + // The short circuit WorkBuddy's per-turn hook depends on: one read, no write. + registerHost('openclaw2', skills, registry) + assert.equal(statSync(registry).mtimeNs, stamped) + + const document = JSON.parse(readFileSync(registry, 'utf8')) as { hosts: Array<{ enabled: boolean }> } + document.hosts[0].enabled = false + await writeFile(registry, JSON.stringify(document)) + + const moved = join(root, 'moved') + await mkdir(moved) + const entries = registerHost('openclaw2', moved, registry) + assert.equal(entries.length, 1) + assert.equal(entries[0].dir, moved) + assert.equal(entries[0].enabled, false, 'a host restart must not undo the user’s choice') +}) + +test('scanning skips disabled entries, dead directories, and our own', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillsearch-dirs-')) + const registry = join(root, 'registry.json') + const mine = join(root, 'mine') + const theirs = join(root, 'theirs') + await mkdir(mine) + await mkdir(theirs) + await writeFile(registry, JSON.stringify({ + hosts: [ + { id: 'openclaw2', dir: mine, enabled: true }, + { id: 'hermes', dir: theirs, enabled: true }, + { id: 'raven', dir: theirs, enabled: false }, + { id: 'gone', dir: join(root, 'never-existed'), enabled: true }, + ], + })) + + assert.deepEqual(registeredDirs('openclaw2', registry), [[theirs, 'hermes']]) + assert.equal(registeredDirs('openclaw2', registry, { includeSelf: true }).length, 2) + + // `sharedDirs` also drops a directory two hosts both registered, which is a + // real configuration: OpenClaw 1 and 2 share `~/.openclaw/skills`. + const env = { SKILLSEARCH_HOME: join(root, 'no-shared-root-here') } + assert.deepEqual(sharedDirs('openclaw2', mine, registry, env), [[theirs, 'hermes']]) +}) + +test('sharedDirs never throws', () => { + // A registry path that cannot exist. Sharing degrades; the turn does not. + assert.deepEqual(sharedDirs('raven', '\0bad', '\0also-bad', {}), []) +}) diff --git a/skillcorpus_plugin/plugin-hermes/__init__.py b/skillcorpus_plugin/plugin-hermes/__init__.py index 8833b7f..483df89 100644 --- a/skillcorpus_plugin/plugin-hermes/__init__.py +++ b/skillcorpus_plugin/plugin-hermes/__init__.py @@ -41,6 +41,8 @@ class MemoryProvider: # type: ignore[no-redef] DEFAULTS: dict[str, Any] = { "skills_dir": "~/.hermes/skills", + "skills_dirs": [], + "share_skills": True, "hub_endpoint": "https://skillhub.evermind.ai", "clawhub_endpoint": "https://clawhub.ai", "skillhub_cn_endpoint": "https://api.skillhub.cn", @@ -258,6 +260,16 @@ def get_config_schema(self) -> list[dict[str, Any]]: "description": "Directory scanned for SKILL.md files", "default": DEFAULTS["skills_dir"], }, + { + "key": "share_skills", + "description": ( + "Also scan the shared library — the cross-host skills directory " + "and the directories other agents registered. Turning this off " + "stops this agent reading the others; to stop the others reading " + "this one, set enabled:false on its line in the shared registry." + ), + "default": DEFAULTS["share_skills"], + }, { "key": "hub_endpoint", "description": "Remote catalog base URL (leave empty for local only)", diff --git a/skillcorpus_plugin/plugin-hermes/engine_adapter.py b/skillcorpus_plugin/plugin-hermes/engine_adapter.py index f89a88b..4dae447 100644 --- a/skillcorpus_plugin/plugin-hermes/engine_adapter.py +++ b/skillcorpus_plugin/plugin-hermes/engine_adapter.py @@ -26,6 +26,7 @@ from pathlib import Path from typing import Any +from skillsearch import shared from skillsearch.config import SearchConfig from skillsearch.engine import SkillSearch @@ -161,6 +162,15 @@ def load_config(hermes_home: str) -> SearchConfig: raw.setdefault("state_dir", hermes_home) # Off by default: only a trusted PathGuard corpus may expand placeholders. raw.setdefault("resolve_placeholders", False) + # Join the shared library: register this agent's skills directory so the + # other hosts can scan it, and fold theirs plus the shared directory into + # `extra_dirs`. `skills_dirs` and `SKILLSEARCH_SKILLS_DIRS` come along — + # Hermes had neither, while the engine has supported several roots all + # along. + if shared.opted_in(raw.get("share_skills")): + extras = shared.extra_dirs_for("hermes", raw.get("skills_dir"), raw.get("skills_dirs")) + if extras: + raw["extra_dirs"] = list(raw.get("extra_dirs") or []) + extras return SearchConfig.from_mapping(raw) diff --git a/skillcorpus_plugin/plugin-hermes/tests/conftest.py b/skillcorpus_plugin/plugin-hermes/tests/conftest.py new file mode 100644 index 0000000..3b6ffa4 --- /dev/null +++ b/skillcorpus_plugin/plugin-hermes/tests/conftest.py @@ -0,0 +1,24 @@ +"""Keep the tests out of the user's home directory. + +Building an engine registers this host in the shared registry, which lives +under `~/.evermind-skillsearch`. A test that writes there pollutes the machine +it runs on and — worse — makes its own result depend on whatever the developer +happens to have installed, which is how a green suite hides a real ranking +change. Autouse so a new test cannot forget. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True) +def _isolated_shared_root(tmp_path_factory: pytest.TempPathFactory, + monkeypatch: pytest.MonkeyPatch) -> None: + root: Path = tmp_path_factory.mktemp("skillsearch-home") + monkeypatch.setenv("SKILLSEARCH_HOME", str(root)) + # A test that wants extra directories asks for them; inheriting the + # developer's would be the same leak by another route. + monkeypatch.delenv("SKILLSEARCH_SKILLS_DIRS", raising=False) diff --git a/skillcorpus_plugin/plugin-hermes/tests/test_provider.py b/skillcorpus_plugin/plugin-hermes/tests/test_provider.py index bf61c3a..bd54cb6 100644 --- a/skillcorpus_plugin/plugin-hermes/tests/test_provider.py +++ b/skillcorpus_plugin/plugin-hermes/tests/test_provider.py @@ -301,3 +301,55 @@ def test_unknown_mode_narrows_to_the_default_and_says_so(tmp_path, caplog): # An unknown mode must leave the on-demand surface intact, not strip it. assert [s["name"] for s in provider.get_tool_schemas()] == ["skill_search"] + + +def test_hermes_registers_itself_and_scans_the_shared_library(monkeypatch, tmp_path): + """Hermes's side of the cross-host library.""" + from skillsearch import shared + + root = tmp_path / "shared" + monkeypatch.setenv(shared.HOME_ENV, str(root)) + (root / "skills").mkdir(parents=True) + theirs = tmp_path / "raven-skills" + theirs.mkdir() + shared.register_host("raven", theirs) + + home = tmp_path / "hermes-home" + (home / "skills").mkdir(parents=True) + (home / "skillsearch.json").write_text(json.dumps({ + "hub_endpoint": "", "clawhub_endpoint": "", "skillhub_cn_endpoint": "", + }), encoding="utf-8") + + from engine_adapter import load_config + + cfg = load_config(str(home)) + scanned = {d.path for d in cfg.extra_dirs} + assert str(root / "skills") in scanned + assert str(theirs.resolve()) in scanned + assert [e.id for e in shared.read_registry()] == ["raven", "hermes"] + + +def test_hermes_reads_extra_dirs_from_the_environment(monkeypatch, tmp_path): + """`SKILLSEARCH_SKILLS_DIRS`, which the three TypeScript hosts already had. + + Comma-separated, same splitting as theirs: a deployment that sets it + should not have to care which host reads it. + """ + from skillsearch import shared + + root = tmp_path / "shared" + monkeypatch.setenv(shared.HOME_ENV, str(root)) + one, two = tmp_path / "one", tmp_path / "two" + one.mkdir() + two.mkdir() + monkeypatch.setenv(shared.DIRS_ENV, f"{one}, {two}") + + home = tmp_path / "hermes-home" + (home / "skills").mkdir(parents=True) + + from engine_adapter import load_config + + cfg = load_config(str(home)) + scanned = {d.path for d in cfg.extra_dirs} + assert str(one.resolve()) in scanned + assert str(two.resolve()) in scanned diff --git a/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json b/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json index 165ed34..9c01203 100644 --- a/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json +++ b/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json @@ -44,6 +44,11 @@ ], "description": "Directories scanned for SKILL.md files" }, + "shareSkills": { + "type": "boolean", + "default": true, + "description": "Also scan the cross-host shared library: the shared skills directory and the directories other agents registered. Off stops this host reading the others; to stop the others reading this one, set enabled:false on its line in the shared registry.json." + }, "hubEndpoint": { "type": "string", "default": "https://skillhub.evermind.ai", diff --git a/skillcorpus_plugin/plugin-openclaw/src/config.ts b/skillcorpus_plugin/plugin-openclaw/src/config.ts index b7b64bf..60a9b22 100644 --- a/skillcorpus_plugin/plugin-openclaw/src/config.ts +++ b/skillcorpus_plugin/plugin-openclaw/src/config.ts @@ -72,6 +72,18 @@ export interface SkillSearchConfig { * the agent never thinks to search — which its tool description exists to * prevent. */ + /** + * Join the cross-host shared library: scan the shared skills directory and + * the directories the other hosts registered, and register this host's own + * so they can scan it. + * + * Off stops this host reading the others. It does **not** stop the others + * reading this one — that switch is `enabled` on this host's line in the + * shared `registry.json`, and the two are deliberately separate because + * users conflate them. + */ + readonly shareSkills: boolean + readonly mode: 'on_demand' | 'auto' } @@ -94,6 +106,7 @@ export const DEFAULTS: SkillSearchConfig = { timeoutMs: 8000, availableTools: [], resolvePlaceholders: false, + shareSkills: true, mode: 'on_demand', } @@ -117,6 +130,7 @@ const ENV_KEYS: Partial> = { timeoutMs: 'SKILLSEARCH_TIMEOUT_MS', availableTools: 'SKILLSEARCH_AVAILABLE_TOOLS', resolvePlaceholders: 'SKILLSEARCH_RESOLVE_PLACEHOLDERS', + shareSkills: 'SKILLSEARCH_SHARE_SKILLS', mode: 'SKILLSEARCH_MODE', } @@ -239,6 +253,7 @@ export function loadConfig( // An unrecognised value falls back to the default rather than failing the // load: a typo should cost the deployment the mode it wanted, not its // whole plugin config. + shareSkills: asBoolean(pick('shareSkills')) ?? DEFAULTS.shareSkills, mode: pick('mode') === 'auto' ? 'auto' : 'on_demand', } } diff --git a/skillcorpus_plugin/plugin-openclaw/src/register.ts b/skillcorpus_plugin/plugin-openclaw/src/register.ts index c4d8a74..794b874 100644 --- a/skillcorpus_plugin/plugin-openclaw/src/register.ts +++ b/skillcorpus_plugin/plugin-openclaw/src/register.ts @@ -16,6 +16,7 @@ import { HubSkillSource, SkillHubClient } from '../../engine-typescript/src/hub- import { MarketplaceClient, MarketplaceSkillSource } from '../../engine-typescript/src/marketplace-source.js' import { LocalSkillSource } from '../../engine-typescript/src/local-source.js' import { QueryRewriter } from '../../engine-typescript/src/rewriter.js' +import { scanDirs } from '../../engine-typescript/src/shared.js' import type { SkillSource } from '../../engine-typescript/src/types.js' import { loadConfig, unknownMode, type SkillSearchConfig } from './config.js' import { createChatModel } from './model.js' @@ -49,11 +50,12 @@ export function buildEngine( const sources: SkillSource[] = [] const dirs = config.skillsDirs.map(dir => expandHome(dir)).filter(dir => isAbsolute(dir) || dir) - if (dirs.length > 0) { - sources.push(new LocalSkillSource( - dirs.map(path => ({ path, name: 'local' })), - { indexBody: config.indexBody }, - )) + // Registers this host's own directory so the other four can scan it, and + // appends the shared directory plus whatever they registered. Returns + // `dirs` unchanged when the deployment opted out or anything went wrong. + const roots = scanDirs('openclaw', dirs, config.shareSkills) + if (roots.length > 0) { + sources.push(new LocalSkillSource(roots, { indexBody: config.indexBody })) } let client: SkillHubClient | undefined diff --git a/skillcorpus_plugin/plugin-openclaw/test/marketplace.test.ts b/skillcorpus_plugin/plugin-openclaw/test/marketplace.test.ts index c2655ea..edf461a 100644 --- a/skillcorpus_plugin/plugin-openclaw/test/marketplace.test.ts +++ b/skillcorpus_plugin/plugin-openclaw/test/marketplace.test.ts @@ -1,8 +1,15 @@ import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' import { access, mkdir, mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, test } from 'node:test' + +// Building an engine registers this host in the shared registry, under the +// user's home directory. A test must never write there: it pollutes the +// machine and makes the result depend on whatever the developer has +// installed. Redirected before anything under test is imported. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { MarketplaceClient, MarketplaceSkillSource } from '../../engine-typescript/src/marketplace-source.ts' const originalFetch = globalThis.fetch diff --git a/skillcorpus_plugin/plugin-openclaw/test/model.test.ts b/skillcorpus_plugin/plugin-openclaw/test/model.test.ts index e68c8e3..d3d6cc6 100644 --- a/skillcorpus_plugin/plugin-openclaw/test/model.test.ts +++ b/skillcorpus_plugin/plugin-openclaw/test/model.test.ts @@ -8,11 +8,18 @@ */ import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { after, test } from 'node:test' + +// Building an engine registers this host in the shared registry, under the +// user's home directory. A test must never write there: it pollutes the +// machine and makes the result depend on whatever the developer has +// installed. Redirected before anything under test is imported. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { createChatModel } from '../src/model.ts' import { register } from '../src/register.ts' import type { diff --git a/skillcorpus_plugin/plugin-openclaw/test/register.test.ts b/skillcorpus_plugin/plugin-openclaw/test/register.test.ts index d81a881..fa60aea 100644 --- a/skillcorpus_plugin/plugin-openclaw/test/register.test.ts +++ b/skillcorpus_plugin/plugin-openclaw/test/register.test.ts @@ -14,7 +14,16 @@ import assert from 'node:assert/strict' import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { mkdtempSync } from 'node:fs' import test from 'node:test' + +// Every suite in this package builds a real engine, and building one now +// registers this host in the shared registry — under the user's home +// directory. A test must never write there: it pollutes the machine it runs +// on and, worse, makes the result depend on whatever the developer happens to +// have installed. Pointed at a scratch directory before anything imports the +// modules under test. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { DEFAULTS, loadConfig } from '../src/config.ts' import { buildEngine, expandHome, recentUserText, register } from '../src/register.ts' import type { diff --git a/skillcorpus_plugin/plugin-openclaw/test/tool.test.ts b/skillcorpus_plugin/plugin-openclaw/test/tool.test.ts index aad85a3..d05c0ef 100644 --- a/skillcorpus_plugin/plugin-openclaw/test/tool.test.ts +++ b/skillcorpus_plugin/plugin-openclaw/test/tool.test.ts @@ -12,7 +12,16 @@ import assert from 'node:assert/strict' import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { mkdtempSync } from 'node:fs' import test from 'node:test' + +// Every suite in this package builds a real engine, and building one now +// registers this host in the shared registry — under the user's home +// directory. A test must never write there: it pollutes the machine it runs +// on and, worse, makes the result depend on whatever the developer happens to +// have installed. Pointed at a scratch directory before anything imports the +// modules under test. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { DEFAULTS } from '../src/config.ts' import { buildEngine, register } from '../src/register.ts' import { skillSearchTool } from '../src/tool.ts' diff --git a/skillcorpus_plugin/plugin-openclaw2/openclaw.plugin.json b/skillcorpus_plugin/plugin-openclaw2/openclaw.plugin.json index de0c87e..ad92dfb 100644 --- a/skillcorpus_plugin/plugin-openclaw2/openclaw.plugin.json +++ b/skillcorpus_plugin/plugin-openclaw2/openclaw.plugin.json @@ -45,6 +45,11 @@ ], "description": "Directories scanned for SKILL.md files" }, + "shareSkills": { + "type": "boolean", + "default": true, + "description": "Also scan the cross-host shared library: the shared skills directory and the directories other agents registered. Off stops this host reading the others; to stop the others reading this one, set enabled:false on its line in the shared registry.json." + }, "hubEndpoint": { "type": "string", "default": "https://skillhub.evermind.ai", diff --git a/skillcorpus_plugin/plugin-openclaw2/src/config.ts b/skillcorpus_plugin/plugin-openclaw2/src/config.ts index b7b64bf..60a9b22 100644 --- a/skillcorpus_plugin/plugin-openclaw2/src/config.ts +++ b/skillcorpus_plugin/plugin-openclaw2/src/config.ts @@ -72,6 +72,18 @@ export interface SkillSearchConfig { * the agent never thinks to search — which its tool description exists to * prevent. */ + /** + * Join the cross-host shared library: scan the shared skills directory and + * the directories the other hosts registered, and register this host's own + * so they can scan it. + * + * Off stops this host reading the others. It does **not** stop the others + * reading this one — that switch is `enabled` on this host's line in the + * shared `registry.json`, and the two are deliberately separate because + * users conflate them. + */ + readonly shareSkills: boolean + readonly mode: 'on_demand' | 'auto' } @@ -94,6 +106,7 @@ export const DEFAULTS: SkillSearchConfig = { timeoutMs: 8000, availableTools: [], resolvePlaceholders: false, + shareSkills: true, mode: 'on_demand', } @@ -117,6 +130,7 @@ const ENV_KEYS: Partial> = { timeoutMs: 'SKILLSEARCH_TIMEOUT_MS', availableTools: 'SKILLSEARCH_AVAILABLE_TOOLS', resolvePlaceholders: 'SKILLSEARCH_RESOLVE_PLACEHOLDERS', + shareSkills: 'SKILLSEARCH_SHARE_SKILLS', mode: 'SKILLSEARCH_MODE', } @@ -239,6 +253,7 @@ export function loadConfig( // An unrecognised value falls back to the default rather than failing the // load: a typo should cost the deployment the mode it wanted, not its // whole plugin config. + shareSkills: asBoolean(pick('shareSkills')) ?? DEFAULTS.shareSkills, mode: pick('mode') === 'auto' ? 'auto' : 'on_demand', } } diff --git a/skillcorpus_plugin/plugin-openclaw2/src/register.ts b/skillcorpus_plugin/plugin-openclaw2/src/register.ts index fc7ed2c..ed04fec 100644 --- a/skillcorpus_plugin/plugin-openclaw2/src/register.ts +++ b/skillcorpus_plugin/plugin-openclaw2/src/register.ts @@ -48,6 +48,7 @@ import { HubSkillSource, SkillHubClient } from '../../engine-typescript/src/hub- import { MarketplaceClient, MarketplaceSkillSource } from '../../engine-typescript/src/marketplace-source.js' import { LocalSkillSource } from '../../engine-typescript/src/local-source.js' import { QueryRewriter } from '../../engine-typescript/src/rewriter.js' +import { scanDirs } from '../../engine-typescript/src/shared.js' import type { SkillSource } from '../../engine-typescript/src/types.js' import { loadConfig, unknownMode, type SkillSearchConfig } from './config.js' import { createChatModel } from './model.js' @@ -87,11 +88,12 @@ export function buildEngine( const sources: SkillSource[] = [] const dirs = config.skillsDirs.map(dir => expandHome(dir)).filter(dir => isAbsolute(dir) || dir) - if (dirs.length > 0) { - sources.push(new LocalSkillSource( - dirs.map(path => ({ path, name: 'local' })), - { indexBody: config.indexBody }, - )) + // Registers this host's own directory so the other four can scan it, and + // appends the shared directory plus whatever they registered. Returns + // `dirs` unchanged when the deployment opted out or anything went wrong. + const roots = scanDirs('openclaw2', dirs, config.shareSkills) + if (roots.length > 0) { + sources.push(new LocalSkillSource(roots, { indexBody: config.indexBody })) } let client: SkillHubClient | undefined diff --git a/skillcorpus_plugin/plugin-openclaw2/test/marketplace.test.ts b/skillcorpus_plugin/plugin-openclaw2/test/marketplace.test.ts index c2655ea..edf461a 100644 --- a/skillcorpus_plugin/plugin-openclaw2/test/marketplace.test.ts +++ b/skillcorpus_plugin/plugin-openclaw2/test/marketplace.test.ts @@ -1,8 +1,15 @@ import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' import { access, mkdir, mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, test } from 'node:test' + +// Building an engine registers this host in the shared registry, under the +// user's home directory. A test must never write there: it pollutes the +// machine and makes the result depend on whatever the developer has +// installed. Redirected before anything under test is imported. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { MarketplaceClient, MarketplaceSkillSource } from '../../engine-typescript/src/marketplace-source.ts' const originalFetch = globalThis.fetch diff --git a/skillcorpus_plugin/plugin-openclaw2/test/model.test.ts b/skillcorpus_plugin/plugin-openclaw2/test/model.test.ts index 5dd22b0..e49f377 100644 --- a/skillcorpus_plugin/plugin-openclaw2/test/model.test.ts +++ b/skillcorpus_plugin/plugin-openclaw2/test/model.test.ts @@ -8,11 +8,18 @@ */ import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { after, test } from 'node:test' + +// Building an engine registers this host in the shared registry, under the +// user's home directory. A test must never write there: it pollutes the +// machine and makes the result depend on whatever the developer has +// installed. Redirected before anything under test is imported. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { createChatModel } from '../src/model.ts' import { register } from '../src/register.ts' import type { diff --git a/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts b/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts index df042c2..9f4ae7c 100644 --- a/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts +++ b/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts @@ -15,7 +15,16 @@ import assert from 'node:assert/strict' import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { mkdtempSync } from 'node:fs' import test from 'node:test' + +// Every suite in this package builds a real engine, and building one now +// registers this host in the shared registry — under the user's home +// directory. A test must never write there: it pollutes the machine it runs +// on and, worse, makes the result depend on whatever the developer happens to +// have installed. Pointed at a scratch directory before anything imports the +// modules under test. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { DEFAULTS } from '../src/config.ts' import { buildEngine, expandHome, recentUserText, register } from '../src/register.ts' import { VERSION } from '../src/version.ts' diff --git a/skillcorpus_plugin/plugin-openclaw2/test/tool.test.ts b/skillcorpus_plugin/plugin-openclaw2/test/tool.test.ts index 7234455..135a2fa 100644 --- a/skillcorpus_plugin/plugin-openclaw2/test/tool.test.ts +++ b/skillcorpus_plugin/plugin-openclaw2/test/tool.test.ts @@ -12,7 +12,16 @@ import assert from 'node:assert/strict' import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { mkdtempSync } from 'node:fs' import test from 'node:test' + +// Every suite in this package builds a real engine, and building one now +// registers this host in the shared registry — under the user's home +// directory. A test must never write there: it pollutes the machine it runs +// on and, worse, makes the result depend on whatever the developer happens to +// have installed. Pointed at a scratch directory before anything imports the +// modules under test. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { DEFAULTS } from '../src/config.ts' import { buildEngine, register } from '../src/register.ts' import { skillSearchTool } from '../src/tool.ts' diff --git a/skillcorpus_plugin/plugin-raven/skillsearch_raven/__init__.py b/skillcorpus_plugin/plugin-raven/skillsearch_raven/__init__.py index 18ab358..a3b6581 100644 --- a/skillcorpus_plugin/plugin-raven/skillsearch_raven/__init__.py +++ b/skillcorpus_plugin/plugin-raven/skillsearch_raven/__init__.py @@ -27,6 +27,7 @@ from collections import Counter from typing import Any +from skillsearch import shared from skillsearch.config import SearchConfig from skillsearch.engine import SkillSearch @@ -258,6 +259,16 @@ def _build_search(ctx: Any) -> tuple[Any, dict[str, Any]] | None: cfg_map.setdefault("hub_endpoint", "https://skillhub.evermind.ai") cfg_map.setdefault("clawhub_endpoint", "https://clawhub.ai") cfg_map.setdefault("skillhub_cn_endpoint", "https://api.skillhub.cn") + # Join the shared library: register this agent's own skills directory so + # the other hosts can scan it, and fold theirs plus the shared directory + # into `extra_dirs`. Raven is the host that needs this most — its + # `skills_dir` default is relative, so it resolves per workspace and two + # projects belonging to one user do not even share with each other. + if shared.opted_in(cfg_map.get("share_skills")): + own = SearchConfig.from_mapping({**cfg_map, "extra_dirs": ()}).resolved_skills_dir() + extras = shared.extra_dirs_for("raven", own, cfg_map.get("skills_dirs")) + if extras: + cfg_map["extra_dirs"] = tuple(cfg_map.get("extra_dirs") or ()) + tuple(extras) # PathGuard placeholders' per-agent facts. Raven has no persistent # config/state root of its own, and the agent's writable home is the # workspace, so both {{HOME}} and {{AGENT_STATE_DIR}} collapse there. diff --git a/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml b/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml index 136475d..2431e30 100644 --- a/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml +++ b/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml @@ -30,7 +30,17 @@ mode = { type = "string", default = "on_demand" } # Sources. With neither a skills directory nor a catalog endpoint there is # nothing to retrieve, and the segment declines its slot. skills_dir = { type = "string", default = "skills" } +# Extra roots, beyond `skills_dir`. A list, or one comma-separated string; +# `SKILLSEARCH_SKILLS_DIRS` overrides either, matching the TypeScript hosts. +# The engine has always supported several roots — this is the key that lets a +# Raven deployment ask for them. +skills_dirs = { type = "array", default = [] } builtin_dir = { type = "string", default = "" } +# Whether this agent joins the cross-host shared library: scanning the shared +# skills directory and the directories other hosts registered. Turning it off +# stops this agent reading the others; to stop the others reading *this* +# agent, set `enabled: false` on its line in the shared registry.json. +share_skills = { type = "boolean", default = true } scan_depth = { type = "integer", default = 5 } hub_endpoint = { type = "string", default = "https://skillhub.evermind.ai" } hub_api_key = { type = "string", default = "" } diff --git a/skillcorpus_plugin/plugin-raven/tests/conftest.py b/skillcorpus_plugin/plugin-raven/tests/conftest.py new file mode 100644 index 0000000..3b6ffa4 --- /dev/null +++ b/skillcorpus_plugin/plugin-raven/tests/conftest.py @@ -0,0 +1,24 @@ +"""Keep the tests out of the user's home directory. + +Building an engine registers this host in the shared registry, which lives +under `~/.evermind-skillsearch`. A test that writes there pollutes the machine +it runs on and — worse — makes its own result depend on whatever the developer +happens to have installed, which is how a green suite hides a real ranking +change. Autouse so a new test cannot forget. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True) +def _isolated_shared_root(tmp_path_factory: pytest.TempPathFactory, + monkeypatch: pytest.MonkeyPatch) -> None: + root: Path = tmp_path_factory.mktemp("skillsearch-home") + monkeypatch.setenv("SKILLSEARCH_HOME", str(root)) + # A test that wants extra directories asks for them; inheriting the + # developer's would be the same leak by another route. + monkeypatch.delenv("SKILLSEARCH_SKILLS_DIRS", raising=False) diff --git a/skillcorpus_plugin/plugin-raven/tests/test_segment.py b/skillcorpus_plugin/plugin-raven/tests/test_segment.py index 509112d..2d102e3 100644 --- a/skillcorpus_plugin/plugin-raven/tests/test_segment.py +++ b/skillcorpus_plugin/plugin-raven/tests/test_segment.py @@ -14,7 +14,7 @@ import importlib.util import tomllib from pathlib import Path -from typing import Any +from typing import Any, ClassVar import pytest @@ -374,3 +374,83 @@ def test_a_recognised_mode_is_quiet(caplog): assert _mode({"mode": "on_demand"}) == "on_demand" assert _mode({}) == "on_demand" assert [r for r in caplog.records if "unknown mode" in r.message] == [] + + +def test_raven_registers_itself_and_scans_the_shared_library(monkeypatch, tmp_path): + """The whole point of the feature, from this host's side. + + Raven needs it most: its `skills_dir` default is relative, so it resolves + per workspace and two projects belonging to one user do not share even + with each other. + """ + from skillsearch import shared + + from skillsearch_raven import _build_search + + root = tmp_path / "shared" + monkeypatch.setenv(shared.HOME_ENV, str(root)) + (root / "skills").mkdir(parents=True) + theirs = tmp_path / "hermes-skills" + theirs.mkdir() + shared.register_host("hermes", theirs) + + workspace = tmp_path / "project" + (workspace / "skills").mkdir(parents=True) + + class Services: + def __init__(self) -> None: + self.workspace = str(workspace) + self.agent_id = "" + + class Ctx: + config: ClassVar[dict] = {"skills_dir": "skills", "hub_endpoint": "", + "clawhub_endpoint": "", "skillhub_cn_endpoint": ""} + services = Services() + + built = _build_search(Ctx()) + assert built is not None + _, cfg_map = built + scanned = {d["path"] for d in cfg_map["extra_dirs"]} + + # The shared directory and the other host's, and never our own — the + # engine already scans `skills_dir` separately. + assert str(root / "skills") in scanned + assert str(theirs.resolve()) in scanned + assert str((workspace / "skills").resolve()) not in scanned + + # And we are now visible to the others. + assert [e.id for e in shared.read_registry()] == ["hermes", "raven"] + + +def test_raven_can_opt_out_of_sharing(monkeypatch, tmp_path): + """`share_skills: false` stops this agent reading the others. + + It does not stop the others reading this one — that switch is `enabled` + on this host's line in the registry, and the two are deliberately + separate. + """ + from skillsearch import shared + + from skillsearch_raven import _build_search + + root = tmp_path / "shared" + monkeypatch.setenv(shared.HOME_ENV, str(root)) + (root / "skills").mkdir(parents=True) + + workspace = tmp_path / "project" + (workspace / "skills").mkdir(parents=True) + + class Services: + def __init__(self) -> None: + self.workspace = str(workspace) + self.agent_id = "" + + class Ctx: + config: ClassVar[dict] = {"skills_dir": "skills", "share_skills": False, + "hub_endpoint": "", "clawhub_endpoint": "", + "skillhub_cn_endpoint": ""} + services = Services() + + built = _build_search(Ctx()) + assert built is not None + assert not built[1].get("extra_dirs") diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs index 22c8fc0..043078d 100755 --- a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // src/hook.ts -import { appendFileSync, mkdirSync as mkdirSync2 } from "node:fs"; +import { appendFileSync, mkdirSync as mkdirSync3 } from "node:fs"; import { dirname as dirname3 } from "node:path"; // src/config.ts @@ -70,6 +70,7 @@ var DEFAULTS = { indexCachePath: join(DATA_DIR, "index-cache.json"), logPath: join(DATA_DIR, "skillsearch.log"), resolvePlaceholders: false, + shareSkills: true, mode: "on_demand" }; var ENV_KEYS = { @@ -96,6 +97,7 @@ var ENV_KEYS = { indexCachePath: "SKILLSEARCH_INDEX_CACHE_PATH", logPath: "SKILLSEARCH_LOG_PATH", resolvePlaceholders: "SKILLSEARCH_RESOLVE_PLACEHOLDERS", + shareSkills: "SKILLSEARCH_SHARE_SKILLS", mode: "SKILLSEARCH_MODE" }; function asList(value) { @@ -182,13 +184,14 @@ function loadConfig(document, env = process.env) { // An unrecognised value falls back to the default rather than failing the // load: a typo should cost the deployment the mode it wanted, not its // whole plugin config. + shareSkills: asBoolean(pick("shareSkills")) ?? DEFAULTS.shareSkills, mode: pick("mode") === "auto" ? "auto" : "on_demand" }; } // src/retrieve.ts -import { homedir as homedir2 } from "node:os"; -import { join as join8 } from "node:path"; +import { homedir as homedir3 } from "node:os"; +import { join as join9 } from "node:path"; // ../engine-typescript/src/engine.ts import { createHash } from "node:crypto"; @@ -1484,13 +1487,165 @@ function errorMessage2(error) { return error instanceof Error ? error.message : String(error); } +// ../engine-typescript/src/shared.ts +import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, renameSync, rmSync, statSync as statSync2, writeFileSync } from "node:fs"; +import { homedir as homedir2 } from "node:os"; +import { isAbsolute as isAbsolute2, join as join6, resolve as resolve2 } from "node:path"; +var HOME_ENV = "SKILLSEARCH_HOME"; +var NOTE = "To exclude a directory, set its `enabled` to false. Deleting the line does not work \u2014 that agent re-registers it on its next start."; +function expandHome(path, home = homedir2()) { + if (path === "~") return home; + if (path.startsWith("~/")) return join6(home, path.slice(2)); + return path; +} +function sharedRoot(env = process.env) { + const override = (env[HOME_ENV] ?? "").trim(); + if (override) return expandHome(override); + return join6(homedir2(), ".evermind-skillsearch"); +} +function sharedSkillsDir(env = process.env) { + return join6(sharedRoot(env), "skills"); +} +function registryPath(env = process.env) { + return join6(sharedRoot(env), "registry.json"); +} +function isDirectory2(path) { + try { + return statSync2(path).isDirectory(); + } catch { + return false; + } +} +function readRegistry(path, env = process.env) { + const target = path ?? registryPath(env); + let raw; + try { + raw = JSON.parse(readFileSync2(target, "utf8")); + } catch { + return []; + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const hosts = raw.hosts; + if (!Array.isArray(hosts)) return []; + const entries = []; + const seen = /* @__PURE__ */ new Set(); + for (const item of hosts) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item; + const id = String(record.id ?? "").trim(); + const dir = String(record.dir ?? "").trim(); + if (!id || !dir || seen.has(id)) continue; + seen.add(id); + entries.push({ id, dir, enabled: record.enabled == null ? true : Boolean(record.enabled) }); + } + return entries; +} +function writeRegistry(entries, path) { + const payload = { + _note: NOTE, + hosts: entries.map((entry) => ({ id: entry.id, dir: entry.dir, enabled: entry.enabled })) + }; + let staging; + try { + const parent = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))); + mkdirSync(parent, { recursive: true }); + staging = mkdtempSync(join6(parent, ".registry-")); + const scratch = join6(staging, "registry.json"); + writeFileSync(scratch, `${JSON.stringify(payload, null, 2)} +`, "utf8"); + renameSync(scratch, path); + return true; + } catch { + return false; + } finally { + if (staging) { + try { + rmSync(staging, { recursive: true, force: true }); + } catch { + } + } + } +} +function registerHost(hostId, skillsDir, path, env = process.env) { + const target = path ?? registryPath(env); + const entries = readRegistry(target, env); + const id = String(hostId ?? "").trim(); + if (!id || !skillsDir) return entries; + let wanted; + try { + wanted = resolve2(expandHome(skillsDir)); + } catch { + return entries; + } + if (!isAbsolute2(wanted)) return entries; + const index = entries.findIndex((entry) => entry.id === id); + const existing = index >= 0 ? entries[index] : void 0; + if (existing) { + if (existing.dir === wanted) return entries; + entries[index] = { id, dir: wanted, enabled: existing.enabled }; + } else { + entries.push({ id, dir: wanted, enabled: true }); + } + writeRegistry(entries, target); + return entries; +} +function registeredDirs(hostId = "", path, options = {}, env = process.env) { + const out = []; + for (const entry of readRegistry(path, env)) { + if (!entry.enabled) continue; + if (entry.id === hostId && !options.includeSelf) continue; + if (!isDirectory2(entry.dir)) continue; + out.push([entry.dir, entry.id]); + } + return out; +} +function sharedDirs(hostId, skillsDir, path, env = process.env) { + try { + registerHost(hostId, skillsDir, path, env); + const dirs = []; + const shared = sharedSkillsDir(env); + if (isDirectory2(shared)) dirs.push([shared, "shared"]); + let own; + if (skillsDir) { + try { + own = resolve2(expandHome(skillsDir)); + } catch { + own = void 0; + } + } + for (const [dir, name] of registeredDirs(hostId, path, {}, env)) { + if (dir === own || dirs.some(([seen]) => seen === dir)) continue; + dirs.push([dir, name]); + } + return dirs; + } catch { + return []; + } +} +function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { + const out = []; + const seen = /* @__PURE__ */ new Set(); + const add = (dir, name) => { + if (!dir || seen.has(dir)) return; + seen.add(dir); + out.push({ path: dir, name }); + }; + for (const dir of ownDirs) add(dir, "local"); + if (!share) return out; + try { + for (const [dir, name] of sharedDirs(hostId, ownDirs[0], path, env)) add(dir, name); + } catch { + } + return out; +} + // src/cached-local-source.ts -import { mkdirSync, readFileSync as readFileSync2, readdirSync, renameSync, statSync as statSync2, writeFileSync } from "node:fs"; -import { dirname as dirname2, join as join7 } from "node:path"; +import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync, renameSync as renameSync2, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs"; +import { dirname as dirname2, join as join8 } from "node:path"; // ../engine-typescript/src/local-source.ts import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises"; -import { basename, join as join6 } from "node:path"; +import { basename, join as join7 } from "node:path"; // ../engine-typescript/src/bm25.ts var TOKEN_RE = /[a-z0-9]{2,}|[一-鿿]+/g; @@ -1679,9 +1834,9 @@ async function* walk(root, maxDepth) { } for (const entry of entries) { if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) stack.push({ dir: join6(dir, entry.name), depth: depth + 1 }); + if (!SKIP_DIRS.has(entry.name)) stack.push({ dir: join7(dir, entry.name), depth: depth + 1 }); } else if (entry.name === SKILL_FILE) { - yield join6(dir, entry.name); + yield join7(dir, entry.name); } } } @@ -1737,7 +1892,7 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } read() { try { - const parsed = JSON.parse(readFileSync2(this.cachePath, "utf8")); + const parsed = JSON.parse(readFileSync3(this.cachePath, "utf8")); if (!parsed || typeof parsed !== "object") return void 0; const file = parsed; if (file.version !== 1 || typeof file.fingerprint !== "string") return void 0; @@ -1748,10 +1903,10 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } write(file) { try { - mkdirSync(dirname2(this.cachePath), { recursive: true }); + mkdirSync2(dirname2(this.cachePath), { recursive: true }); const temp = `${this.cachePath}.${process.pid}.tmp`; - writeFileSync(temp, JSON.stringify(file)); - renameSync(temp, this.cachePath); + writeFileSync2(temp, JSON.stringify(file)); + renameSync2(temp, this.cachePath); } catch { } } @@ -1766,11 +1921,11 @@ function collect(dir, depth, out) { } for (const entry of entries) { if (SKIP_DIRS2.has(entry.name)) continue; - const path = join7(dir, entry.name); + const path = join8(dir, entry.name); if (entry.isDirectory()) collect(path, depth - 1, out); else if (entry.name === SKILL_FILE2) { try { - out.push(`${path}:${statSync2(path).mtimeMs}`); + out.push(`${path}:${statSync3(path).mtimeMs}`); } catch { } } @@ -1813,18 +1968,19 @@ function createChatModel(options) { } // src/retrieve.ts -function expandHome(path, home = homedir2()) { +function expandHome2(path, home = homedir3()) { if (path === "~") return home; - if (path.startsWith("~/")) return join8(home, path.slice(2)); + if (path.startsWith("~/")) return join9(home, path.slice(2)); return path; } function buildEngine(config, onDiagnostic, workspaceDir) { const sources = []; - const dirs = config.skillsDirs.map((dir) => expandHome(dir)).filter(Boolean); - if (dirs.length > 0) { + const dirs = config.skillsDirs.map((dir) => expandHome2(dir)).filter(Boolean); + const roots = scanDirs("workbuddy", dirs, config.shareSkills); + if (roots.length > 0) { const local = new CachedLocalSkillSource( - dirs.map((path) => ({ path, name: "local" })), - { indexBody: config.indexBody, cachePath: expandHome(config.indexCachePath) } + roots, + { indexBody: config.indexBody, cachePath: expandHome2(config.indexCachePath) } ); local.weight = config.localWeight; sources.push(local); @@ -1836,7 +1992,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // Outside every scanned directory. `~/.workbuddy-ai/plugins/cache` is // one of the defaults, so a bundle extracted under it would come back // as a local skill on the next scan. - cacheDir: expandHome(config.bundleCacheDir) || join8(homedir2(), ".workbuddy-ai", "skillsearch-bundles") + cacheDir: expandHome2(config.bundleCacheDir) || join9(homedir3(), ".workbuddy-ai", "skillsearch-bundles") }); const hub = new HubSkillSource(client); hub.weight = config.hubWeight; @@ -1849,7 +2005,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { ]) { if (!endpoint) continue; const marketplace = new MarketplaceClient(kind, endpoint, { - cacheDir: expandHome(config.bundleCacheDir) || join8(homedir2(), ".workbuddy-ai", "skillsearch-bundles"), + cacheDir: expandHome2(config.bundleCacheDir) || join9(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), // ClawHub measured 4–5s on the supported route. Give search headroom, // but leave time under the hook's global deadline for body hydration. timeoutMs: Math.max(1, Math.min(config.timeoutMs, 6500)), @@ -1905,8 +2061,8 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // is ~/.workbuddy-ai; the agent's writable output is its workspace, // falling back to the hook process's cwd when the payload reports none. outputDir: workspaceDir || process.cwd(), - homeDir: homedir2(), - stateDir: join8(homedir2(), ".workbuddy-ai"), + homeDir: homedir3(), + stateDir: join9(homedir3(), ".workbuddy-ai"), resolvePlaceholders: config.resolvePlaceholders } ); @@ -1959,7 +2115,7 @@ function resultFor(block) { function log(config, entry) { if (!config.logPath) return; try { - mkdirSync2(dirname3(config.logPath), { recursive: true }); + mkdirSync3(dirname3(config.logPath), { recursive: true }); appendFileSync(config.logPath, `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry })} `); } catch { diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs index 3285b5e..67e6d78 100644 --- a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs @@ -69,6 +69,7 @@ var DEFAULTS = { indexCachePath: join(DATA_DIR, "index-cache.json"), logPath: join(DATA_DIR, "skillsearch.log"), resolvePlaceholders: false, + shareSkills: true, mode: "on_demand" }; var ENV_KEYS = { @@ -95,6 +96,7 @@ var ENV_KEYS = { indexCachePath: "SKILLSEARCH_INDEX_CACHE_PATH", logPath: "SKILLSEARCH_LOG_PATH", resolvePlaceholders: "SKILLSEARCH_RESOLVE_PLACEHOLDERS", + shareSkills: "SKILLSEARCH_SHARE_SKILLS", mode: "SKILLSEARCH_MODE" }; function asList(value) { @@ -173,13 +175,14 @@ function loadConfig(document, env = process.env) { // An unrecognised value falls back to the default rather than failing the // load: a typo should cost the deployment the mode it wanted, not its // whole plugin config. + shareSkills: asBoolean(pick("shareSkills")) ?? DEFAULTS.shareSkills, mode: pick("mode") === "auto" ? "auto" : "on_demand" }; } // src/retrieve.ts -import { homedir as homedir2 } from "node:os"; -import { join as join8 } from "node:path"; +import { homedir as homedir3 } from "node:os"; +import { join as join9 } from "node:path"; // ../engine-typescript/src/engine.ts import { createHash } from "node:crypto"; @@ -1475,13 +1478,165 @@ function errorMessage2(error) { return error instanceof Error ? error.message : String(error); } +// ../engine-typescript/src/shared.ts +import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, renameSync, rmSync, statSync as statSync2, writeFileSync } from "node:fs"; +import { homedir as homedir2 } from "node:os"; +import { isAbsolute as isAbsolute2, join as join6, resolve as resolve2 } from "node:path"; +var HOME_ENV = "SKILLSEARCH_HOME"; +var NOTE = "To exclude a directory, set its `enabled` to false. Deleting the line does not work \u2014 that agent re-registers it on its next start."; +function expandHome(path, home = homedir2()) { + if (path === "~") return home; + if (path.startsWith("~/")) return join6(home, path.slice(2)); + return path; +} +function sharedRoot(env = process.env) { + const override = (env[HOME_ENV] ?? "").trim(); + if (override) return expandHome(override); + return join6(homedir2(), ".evermind-skillsearch"); +} +function sharedSkillsDir(env = process.env) { + return join6(sharedRoot(env), "skills"); +} +function registryPath(env = process.env) { + return join6(sharedRoot(env), "registry.json"); +} +function isDirectory2(path) { + try { + return statSync2(path).isDirectory(); + } catch { + return false; + } +} +function readRegistry(path, env = process.env) { + const target = path ?? registryPath(env); + let raw; + try { + raw = JSON.parse(readFileSync2(target, "utf8")); + } catch { + return []; + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const hosts = raw.hosts; + if (!Array.isArray(hosts)) return []; + const entries = []; + const seen = /* @__PURE__ */ new Set(); + for (const item of hosts) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item; + const id = String(record.id ?? "").trim(); + const dir = String(record.dir ?? "").trim(); + if (!id || !dir || seen.has(id)) continue; + seen.add(id); + entries.push({ id, dir, enabled: record.enabled == null ? true : Boolean(record.enabled) }); + } + return entries; +} +function writeRegistry(entries, path) { + const payload = { + _note: NOTE, + hosts: entries.map((entry) => ({ id: entry.id, dir: entry.dir, enabled: entry.enabled })) + }; + let staging; + try { + const parent = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))); + mkdirSync(parent, { recursive: true }); + staging = mkdtempSync(join6(parent, ".registry-")); + const scratch = join6(staging, "registry.json"); + writeFileSync(scratch, `${JSON.stringify(payload, null, 2)} +`, "utf8"); + renameSync(scratch, path); + return true; + } catch { + return false; + } finally { + if (staging) { + try { + rmSync(staging, { recursive: true, force: true }); + } catch { + } + } + } +} +function registerHost(hostId, skillsDir, path, env = process.env) { + const target = path ?? registryPath(env); + const entries = readRegistry(target, env); + const id = String(hostId ?? "").trim(); + if (!id || !skillsDir) return entries; + let wanted; + try { + wanted = resolve2(expandHome(skillsDir)); + } catch { + return entries; + } + if (!isAbsolute2(wanted)) return entries; + const index = entries.findIndex((entry) => entry.id === id); + const existing = index >= 0 ? entries[index] : void 0; + if (existing) { + if (existing.dir === wanted) return entries; + entries[index] = { id, dir: wanted, enabled: existing.enabled }; + } else { + entries.push({ id, dir: wanted, enabled: true }); + } + writeRegistry(entries, target); + return entries; +} +function registeredDirs(hostId = "", path, options = {}, env = process.env) { + const out = []; + for (const entry of readRegistry(path, env)) { + if (!entry.enabled) continue; + if (entry.id === hostId && !options.includeSelf) continue; + if (!isDirectory2(entry.dir)) continue; + out.push([entry.dir, entry.id]); + } + return out; +} +function sharedDirs(hostId, skillsDir, path, env = process.env) { + try { + registerHost(hostId, skillsDir, path, env); + const dirs = []; + const shared = sharedSkillsDir(env); + if (isDirectory2(shared)) dirs.push([shared, "shared"]); + let own; + if (skillsDir) { + try { + own = resolve2(expandHome(skillsDir)); + } catch { + own = void 0; + } + } + for (const [dir, name] of registeredDirs(hostId, path, {}, env)) { + if (dir === own || dirs.some(([seen]) => seen === dir)) continue; + dirs.push([dir, name]); + } + return dirs; + } catch { + return []; + } +} +function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { + const out = []; + const seen = /* @__PURE__ */ new Set(); + const add = (dir, name) => { + if (!dir || seen.has(dir)) return; + seen.add(dir); + out.push({ path: dir, name }); + }; + for (const dir of ownDirs) add(dir, "local"); + if (!share) return out; + try { + for (const [dir, name] of sharedDirs(hostId, ownDirs[0], path, env)) add(dir, name); + } catch { + } + return out; +} + // src/cached-local-source.ts -import { mkdirSync, readFileSync as readFileSync2, readdirSync, renameSync, statSync as statSync2, writeFileSync } from "node:fs"; -import { dirname as dirname2, join as join7 } from "node:path"; +import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync, renameSync as renameSync2, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs"; +import { dirname as dirname2, join as join8 } from "node:path"; // ../engine-typescript/src/local-source.ts import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises"; -import { basename, join as join6 } from "node:path"; +import { basename, join as join7 } from "node:path"; // ../engine-typescript/src/bm25.ts var TOKEN_RE = /[a-z0-9]{2,}|[一-鿿]+/g; @@ -1670,9 +1825,9 @@ async function* walk(root, maxDepth) { } for (const entry of entries) { if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) stack.push({ dir: join6(dir, entry.name), depth: depth + 1 }); + if (!SKIP_DIRS.has(entry.name)) stack.push({ dir: join7(dir, entry.name), depth: depth + 1 }); } else if (entry.name === SKILL_FILE) { - yield join6(dir, entry.name); + yield join7(dir, entry.name); } } } @@ -1728,7 +1883,7 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } read() { try { - const parsed = JSON.parse(readFileSync2(this.cachePath, "utf8")); + const parsed = JSON.parse(readFileSync3(this.cachePath, "utf8")); if (!parsed || typeof parsed !== "object") return void 0; const file = parsed; if (file.version !== 1 || typeof file.fingerprint !== "string") return void 0; @@ -1739,10 +1894,10 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } write(file) { try { - mkdirSync(dirname2(this.cachePath), { recursive: true }); + mkdirSync2(dirname2(this.cachePath), { recursive: true }); const temp = `${this.cachePath}.${process.pid}.tmp`; - writeFileSync(temp, JSON.stringify(file)); - renameSync(temp, this.cachePath); + writeFileSync2(temp, JSON.stringify(file)); + renameSync2(temp, this.cachePath); } catch { } } @@ -1757,11 +1912,11 @@ function collect(dir, depth, out) { } for (const entry of entries) { if (SKIP_DIRS2.has(entry.name)) continue; - const path = join7(dir, entry.name); + const path = join8(dir, entry.name); if (entry.isDirectory()) collect(path, depth - 1, out); else if (entry.name === SKILL_FILE2) { try { - out.push(`${path}:${statSync2(path).mtimeMs}`); + out.push(`${path}:${statSync3(path).mtimeMs}`); } catch { } } @@ -1804,18 +1959,19 @@ function createChatModel(options) { } // src/retrieve.ts -function expandHome(path, home = homedir2()) { +function expandHome2(path, home = homedir3()) { if (path === "~") return home; - if (path.startsWith("~/")) return join8(home, path.slice(2)); + if (path.startsWith("~/")) return join9(home, path.slice(2)); return path; } function buildEngine(config, onDiagnostic, workspaceDir) { const sources = []; - const dirs = config.skillsDirs.map((dir) => expandHome(dir)).filter(Boolean); - if (dirs.length > 0) { + const dirs = config.skillsDirs.map((dir) => expandHome2(dir)).filter(Boolean); + const roots = scanDirs("workbuddy", dirs, config.shareSkills); + if (roots.length > 0) { const local = new CachedLocalSkillSource( - dirs.map((path) => ({ path, name: "local" })), - { indexBody: config.indexBody, cachePath: expandHome(config.indexCachePath) } + roots, + { indexBody: config.indexBody, cachePath: expandHome2(config.indexCachePath) } ); local.weight = config.localWeight; sources.push(local); @@ -1827,7 +1983,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // Outside every scanned directory. `~/.workbuddy-ai/plugins/cache` is // one of the defaults, so a bundle extracted under it would come back // as a local skill on the next scan. - cacheDir: expandHome(config.bundleCacheDir) || join8(homedir2(), ".workbuddy-ai", "skillsearch-bundles") + cacheDir: expandHome2(config.bundleCacheDir) || join9(homedir3(), ".workbuddy-ai", "skillsearch-bundles") }); const hub = new HubSkillSource(client); hub.weight = config.hubWeight; @@ -1840,7 +1996,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { ]) { if (!endpoint) continue; const marketplace = new MarketplaceClient(kind, endpoint, { - cacheDir: expandHome(config.bundleCacheDir) || join8(homedir2(), ".workbuddy-ai", "skillsearch-bundles"), + cacheDir: expandHome2(config.bundleCacheDir) || join9(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), // ClawHub measured 4–5s on the supported route. Give search headroom, // but leave time under the hook's global deadline for body hydration. timeoutMs: Math.max(1, Math.min(config.timeoutMs, 6500)), @@ -1896,8 +2052,8 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // is ~/.workbuddy-ai; the agent's writable output is its workspace, // falling back to the hook process's cwd when the payload reports none. outputDir: workspaceDir || process.cwd(), - homeDir: homedir2(), - stateDir: join8(homedir2(), ".workbuddy-ai"), + homeDir: homedir3(), + stateDir: join9(homedir3(), ".workbuddy-ai"), resolvePlaceholders: config.resolvePlaceholders } ); diff --git a/skillcorpus_plugin/plugin-workbuddy/src/config.ts b/skillcorpus_plugin/plugin-workbuddy/src/config.ts index 1a0e08c..d6d03b8 100644 --- a/skillcorpus_plugin/plugin-workbuddy/src/config.ts +++ b/skillcorpus_plugin/plugin-workbuddy/src/config.ts @@ -77,6 +77,18 @@ export interface SkillSearchConfig { * the agent never thinks to search — which its tool description exists to * prevent. */ + /** + * Join the cross-host shared library: scan the shared skills directory and + * the directories the other hosts registered, and register this host's own + * so they can scan it. + * + * Off stops this host reading the others. It does **not** stop the others + * reading this one — that switch is `enabled` on this host's line in the + * shared `registry.json`, and the two are deliberately separate because + * users conflate them. + */ + readonly shareSkills: boolean + readonly mode: 'on_demand' | 'auto' } @@ -173,6 +185,7 @@ export const DEFAULTS: SkillSearchConfig = { indexCachePath: join(DATA_DIR, 'index-cache.json'), logPath: join(DATA_DIR, 'skillsearch.log'), resolvePlaceholders: false, + shareSkills: true, mode: 'on_demand', } @@ -200,6 +213,7 @@ const ENV_KEYS: Partial> = { indexCachePath: 'SKILLSEARCH_INDEX_CACHE_PATH', logPath: 'SKILLSEARCH_LOG_PATH', resolvePlaceholders: 'SKILLSEARCH_RESOLVE_PLACEHOLDERS', + shareSkills: 'SKILLSEARCH_SHARE_SKILLS', mode: 'SKILLSEARCH_MODE', } @@ -337,6 +351,7 @@ export function loadConfig( // An unrecognised value falls back to the default rather than failing the // load: a typo should cost the deployment the mode it wanted, not its // whole plugin config. + shareSkills: asBoolean(pick('shareSkills')) ?? DEFAULTS.shareSkills, mode: pick('mode') === 'auto' ? 'auto' : 'on_demand', } } diff --git a/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts b/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts index 49cf58e..894a9dc 100644 --- a/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts +++ b/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts @@ -14,6 +14,7 @@ import { SkillSearchEngine, type SourceDiagnostic } from '../../engine-typescrip import { LLMGateFilter } from '../../engine-typescript/src/gate.js' import { HubSkillSource, SkillHubClient } from '../../engine-typescript/src/hub-source.js' import { MarketplaceClient, MarketplaceSkillSource } from '../../engine-typescript/src/marketplace-source.js' +import { scanDirs } from '../../engine-typescript/src/shared.js' import type { SkillSource } from '../../engine-typescript/src/types.js' import { QueryRewriter } from '../../engine-typescript/src/rewriter.js' import { CachedLocalSkillSource } from './cached-local-source.js' @@ -42,9 +43,19 @@ export function buildEngine( const sources: SkillSource[] = [] const dirs = config.skillsDirs.map(dir => expandHome(dir)).filter(Boolean) - if (dirs.length > 0) { + // Registers this host's own directory so the other four can scan it, and + // appends the shared directory plus whatever they registered. + // + // This host reaches here twice with very different lifecycles: the MCP + // server builds once and lives, but the `UserPromptSubmit` hook is a fresh + // process **every turn**, so "at startup" here means "every turn", on the + // turn's hot path, inside an 8s budget, where a throw blocks the user's + // message. `scanDirs` is built for that: the steady state is one small file + // read and no write, and it swallows everything. + const roots = scanDirs('workbuddy', dirs, config.shareSkills) + if (roots.length > 0) { const local = new CachedLocalSkillSource( - dirs.map(path => ({ path, name: 'local' })), + roots, { indexBody: config.indexBody, cachePath: expandHome(config.indexCachePath) }, ) // Set here rather than upstream: preferring the catalog is this host's diff --git a/skillcorpus_plugin/plugin-workbuddy/test/cached-local-source.test.ts b/skillcorpus_plugin/plugin-workbuddy/test/cached-local-source.test.ts index fefb21d..57d631f 100644 --- a/skillcorpus_plugin/plugin-workbuddy/test/cached-local-source.test.ts +++ b/skillcorpus_plugin/plugin-workbuddy/test/cached-local-source.test.ts @@ -4,10 +4,17 @@ */ import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' + +// Building an engine registers this host in the shared registry, under the +// user's home directory. A test must never write there: it pollutes the +// machine and makes the result depend on whatever the developer has +// installed. Redirected before anything under test is imported. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { CachedLocalSkillSource } from '../src/cached-local-source.ts' async function skillDir(): Promise { diff --git a/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts b/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts index f16f61b..dd8183c 100644 --- a/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts +++ b/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts @@ -4,10 +4,17 @@ */ import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' import { mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' + +// Building an engine registers this host in the shared registry, under the +// user's home directory. A test must never write there: it pollutes the +// machine and makes the result depend on whatever the developer has +// installed. Redirected before anything under test is imported. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { dataDirectory, DEFAULTS, MAX_TIMEOUT_MS, loadConfig, marketplaceName, readConfigDocument, unknownMode } from '../src/config.ts' test('the defaults search the two directories WorkBuddy keeps skills in', () => { diff --git a/skillcorpus_plugin/plugin-workbuddy/test/hook.test.ts b/skillcorpus_plugin/plugin-workbuddy/test/hook.test.ts index 2a59ff0..a31178e 100644 --- a/skillcorpus_plugin/plugin-workbuddy/test/hook.test.ts +++ b/skillcorpus_plugin/plugin-workbuddy/test/hook.test.ts @@ -8,11 +8,18 @@ */ import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' import { readFileSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' + +// Building an engine registers this host in the shared registry, under the +// user's home directory. A test must never write there: it pollutes the +// machine and makes the result depend on whatever the developer has +// installed. Redirected before anything under test is imported. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { DEFAULTS, type SkillSearchConfig } from '../src/config.ts' import { queryOf, resultFor, runTurn, selectedSkills } from '../src/hook.ts' diff --git a/skillcorpus_plugin/plugin-workbuddy/test/mcp.test.ts b/skillcorpus_plugin/plugin-workbuddy/test/mcp.test.ts index 6088050..61cd2ad 100644 --- a/skillcorpus_plugin/plugin-workbuddy/test/mcp.test.ts +++ b/skillcorpus_plugin/plugin-workbuddy/test/mcp.test.ts @@ -9,12 +9,19 @@ */ import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' import { spawn } from 'node:child_process' import { once } from 'node:events' import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import test from 'node:test' + +// Building an engine registers this host in the shared registry, under the +// user's home directory. A test must never write there: it pollutes the +// machine and makes the result depend on whatever the developer has +// installed. Redirected before anything under test is imported. +process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) import { fileURLToPath } from 'node:url' import { DEFAULTS } from '../src/config.ts' import { handle, SKILL_SEARCH_TOOL, type Message } from '../src/mcp.ts' From 9689e2d07aca7c55432bf0f4aca5cd67b188ef5b Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 02:34:05 +0000 Subject: [PATCH 02/14] feat(plugin): the shared directory takes effect next turn, not next restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec section 6. Without it the feature's headline does not work: a user installs a skill in agent A and agent B cannot see it until B restarts. The cause was already documented and already unused. `LocalPool.__init__` does one eager `rebuild_index()` and never rebuilds; `SkillSearch.invalidate` exists for exactly this and `local_pool.py` says so in a comment — and no adapter has ever called it. WorkBuddy was the accidental exception: its hook is a fresh process per turn, so it had to restore from a disk cache, and it fingerprints by path and mtime to decide whether that cache is still good. `watch.py` / `watch.ts` generalise that fingerprint, and the engines check it before each retrieval. Three things about the scope, all deliberate: **Only the shared directory.** A host now scans five or more roots rather than one, and walking all of them every turn would charge deployments that are not using the shared library. The shared directory is ours and its size is something we control; it is also the only root that changes behind the host's back. Everyone else's directories keep whatever their host already did, which for four of them still means a restart. That is a real limitation, and it is the spec's call rather than an oversight. **A fingerprint, not a revision file.** A counter the plugin bumps when *it* installs something is one `stat`, but it cannot see a user dragging a directory in by hand — which is one of the acceptance cases. A revision file could only ever be a fast path in front of this, never a replacement. **The walk is not pure overhead.** Measured on WorkBuddy: 34ms to fingerprint 46 skills against 53ms to read and parse them. The walk is flat in corpus size and the parse it avoids is not, so it pays off harder as the library grows. Two details worth the reader's time. The walk sorts each level, because a filesystem may return `readdir` entries in any order and an unsorted walk would invalidate the cache at random. And the TypeScript side reads nanoseconds via `statSync(path, {bigint: true})` rather than `mtimeMs`: millisecond resolution misses an edit made inside the same millisecond as the previous walk, and that failure is silent and reads as "my change did nothing". Both suites assert the headline end to end — drop a skill into a watched directory mid-run and it is retrieved on the next call, exactly once, with nobody having called `invalidate()`. Co-Authored-By: Claude Opus 5 (1M context) --- .../engine-python/skillsearch/engine.py | 35 +++- .../engine-python/skillsearch/watch.py | 119 ++++++++++++ .../engine-python/tests/conftest.py | 3 +- .../engine-python/tests/test_shared.py | 113 +++++++++++ .../engine-typescript/src/engine.ts | 34 ++++ .../engine-typescript/src/index.ts | 4 + .../engine-typescript/src/watch.ts | 120 ++++++++++++ .../engine-typescript/tests/parity.test.ts | 68 ++++++- .../plugin-openclaw/src/register.ts | 4 + .../plugin-openclaw2/src/register.ts | 4 + .../plugin-workbuddy/dist/hook.mjs | 176 +++++++++++++----- .../plugin-workbuddy/dist/mcp.mjs | 176 +++++++++++++----- .../plugin-workbuddy/src/retrieve.ts | 4 + 13 files changed, 768 insertions(+), 92 deletions(-) create mode 100644 skillcorpus_plugin/engine-python/skillsearch/watch.py create mode 100644 skillcorpus_plugin/engine-typescript/src/watch.ts diff --git a/skillcorpus_plugin/engine-python/skillsearch/engine.py b/skillcorpus_plugin/engine-python/skillsearch/engine.py index 6f0eb89..5e550ce 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/engine.py +++ b/skillcorpus_plugin/engine-python/skillsearch/engine.py @@ -214,9 +214,11 @@ def _build_local_source(self, store: SkillStore | None): from skillsearch.sources.local_source import LocalSkillSource cfg = self._cfg + # Declared out here so the watch below can ask what was scanned even + # when the host handed us its own store and this stayed empty. + roots: list[tuple[Any, str]] = [] if store is None: root = cfg.resolved_skills_dir() - roots: list[tuple[Any, str]] = [] if root and root.is_dir(): roots.append((root, "local")) from pathlib import Path @@ -238,6 +240,20 @@ def _build_local_source(self, store: SkillStore | None): store = DirectorySkillStore(roots, max_depth=cfg.scan_depth) self._store = store + # Watch the shared directory, and only it — see `watch.py`. Set up + # here because this is the one place that knows the scan exists, and + # `_revalidate` is what drops it. + from skillsearch.shared import shared_skills_dir + from skillsearch.watch import DirectoryWatch + + # Only when the shared directory is actually one of the roots: a host + # that opted out, or one that brought its own store, gets no walk. + try: + shared_dir = str(shared_skills_dir()) + watched = [shared_dir] if any(str(path) == shared_dir for path, _ in roots) else [] + except Exception: # a watch is an optimisation, never a failure mode + watched = [] + self._watch = DirectoryWatch(watched, max_depth=cfg.scan_depth) self._local_pool = LocalPool(store, index_body=cfg.index_body) return LocalSkillSource( pool=self._local_pool, @@ -267,6 +283,22 @@ def _build_narrowing(self): # ── The entry point ────────────────────────────────────────────── + def _revalidate(self) -> None: + """Drop the cached scan when the shared directory changed. + + The scan is built once and kept, and no adapter calls + :meth:`invalidate` — so without this a skill installed in one agent + stays invisible in the others until they restart, which is the whole + premise of a shared library. + + Scoped to the shared directory alone. Watching every root would put a + per-turn walk on deployments that are not using this, and the other + hosts' directories keep whatever behaviour they already had. + """ + watch = getattr(self, "_watch", None) + if watch is not None and watch.changed(): + self.invalidate() + async def retrieve( self, query: str, @@ -274,6 +306,7 @@ async def retrieve( history: list[dict[str, Any]] | None = None, ) -> str: """Return the block to inject, or ``""`` when there is nothing.""" + self._revalidate() if self._router is None or not (query or "").strip(): return "" try: diff --git a/skillcorpus_plugin/engine-python/skillsearch/watch.py b/skillcorpus_plugin/engine-python/skillsearch/watch.py new file mode 100644 index 0000000..d6fe88f --- /dev/null +++ b/skillcorpus_plugin/engine-python/skillsearch/watch.py @@ -0,0 +1,119 @@ +"""Notice when the shared skills directory changed, without a restart. + +The local scan is built once and cached for the life of the engine, and +:meth:`SkillSearch.invalidate` — the supported way to drop it — is called by no +adapter at all. So on four of the five hosts a skill added today is invisible +until the agent restarts. That is fatal for a *shared* library, whose whole +premise is that a skill installed in one agent shows up in the others. + +This is the fix, and it is deliberately narrow. + +## Only the shared directory + +A host now scans five or more directories rather than one, and walking all of +them every turn would put the cost on every deployment, including the ones not +using this feature. The shared directory is ours, its size is something we +control, and it is the only one whose contents change behind the host's back. + +Everyone else's directories keep their host's existing behaviour: a user +editing a skill inside their own agent's directory still gets whatever that +agent already did, which for four of them means a restart. That is a real +limitation and it is the spec's call, not an oversight. + +## Why a fingerprint rather than a revision file + +A counter the plugin bumps when *it* installs something is cheaper — one +``stat`` — but it cannot see a user dragging a directory into the shared +folder by hand, which is one of the acceptance cases. So the fingerprint is +the mechanism and a revision file could only ever be a fast path in front of +it. + +The walk is not pure overhead. Measured on WorkBuddy, whose per-turn hook has +done this since 0.2.0: 34ms to fingerprint 46 skills against 53ms to read and +parse them. The walk's cost is flat in corpus size and the parse it avoids is +not, so it pays off harder the bigger the library gets. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterable +from pathlib import Path + +#: Directory names never worth descending into. Mirrors the scanner's own skip +#: list — a fingerprint that tracks different files from the scan would either +#: miss changes or invent them. +SKIP_DIRS = frozenset({".git", "node_modules", "__pycache__", ".venv", "venv", ".tox"}) + +SKILL_FILE = "SKILL.md" + + +def fingerprint(dirs: Iterable[str | os.PathLike[str]], max_depth: int = 5) -> str: + """Path and mtime of every ``SKILL.md`` under ``dirs``, in scan order. + + Sorted per directory level so two walks of an unchanged tree agree — a + filesystem is free to hand back ``readdir`` entries in whatever order it + likes, and an unsorted walk would invalidate the cache at random. + + Never raises: a directory that vanishes mid-walk contributes nothing, which + is also the correct answer. + """ + parts: list[str] = [] + for root in dirs: + _collect(Path(root), max_depth, parts) + return "\n".join(parts) + + +def _collect(directory: Path, depth: int, out: list[str]) -> None: + if depth < 0: + return + try: + entries = sorted(os.scandir(directory), key=lambda e: e.name) + except OSError: + return + for entry in entries: + if entry.name in SKIP_DIRS: + continue + try: + if entry.is_dir(follow_symlinks=False): + _collect(Path(entry.path), depth - 1, out) + elif entry.name == SKILL_FILE: + out.append(f"{entry.path}:{entry.stat().st_mtime_ns}") + except OSError: + # Deleted between scandir and stat. The next turn's walk settles it. + continue + + +class DirectoryWatch: + """Answers "did these directories change since I last asked?". + + Stateful on purpose: the first call establishes the baseline and reports + no change, so constructing a watch does not throw away a scan that was + just built. + """ + + def __init__(self, dirs: Iterable[str | os.PathLike[str]], max_depth: int = 5) -> None: + self._dirs = [str(d) for d in dirs] + self._max_depth = max_depth + self._seen: str | None = None + + @property + def active(self) -> bool: + """Whether there is anything to watch. Cheap enough to call per turn.""" + return bool(self._dirs) + + def changed(self) -> bool: + """Whether the tree differs from the last call. Never raises.""" + if not self._dirs: + return False + try: + current = fingerprint(self._dirs, self._max_depth) + except Exception: # a watch is an optimisation, never a failure mode + return False + if self._seen is None: + self._seen = current + return False + if current == self._seen: + return False + self._seen = current + return True diff --git a/skillcorpus_plugin/engine-python/tests/conftest.py b/skillcorpus_plugin/engine-python/tests/conftest.py index 3b6ffa4..3385db6 100644 --- a/skillcorpus_plugin/engine-python/tests/conftest.py +++ b/skillcorpus_plugin/engine-python/tests/conftest.py @@ -15,8 +15,7 @@ @pytest.fixture(autouse=True) -def _isolated_shared_root(tmp_path_factory: pytest.TempPathFactory, - monkeypatch: pytest.MonkeyPatch) -> None: +def _isolated_shared_root(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None: root: Path = tmp_path_factory.mktemp("skillsearch-home") monkeypatch.setenv("SKILLSEARCH_HOME", str(root)) # A test that wants extra directories asks for them; inheriting the diff --git a/skillcorpus_plugin/engine-python/tests/test_shared.py b/skillcorpus_plugin/engine-python/tests/test_shared.py index 760f517..af05f6c 100644 --- a/skillcorpus_plugin/engine-python/tests/test_shared.py +++ b/skillcorpus_plugin/engine-python/tests/test_shared.py @@ -225,3 +225,116 @@ def test_registry_parses_the_same_as_the_typescript_port(case: dict, registry: P def test_unparseable_registries_read_as_empty_in_both_ports(text: str, registry: Path) -> None: registry.write_text(text, encoding="utf-8") assert shared.read_registry(registry) == [] + + +# --------------------------------------------------------------------------- +# noticing a changed shared directory without a restart + + +def test_the_watch_reports_no_change_on_its_first_look(tmp_path: Path) -> None: + """Constructing a watch must not throw away a scan that was just built.""" + from skillsearch.watch import DirectoryWatch + + (tmp_path / "a").mkdir() + (tmp_path / "a" / "SKILL.md").write_text("---\nname: a\n---\n") + watch = DirectoryWatch([tmp_path]) + assert watch.changed() is False + assert watch.changed() is False + + +def test_the_watch_notices_an_added_edited_or_removed_skill(tmp_path: Path) -> None: + from skillsearch.watch import DirectoryWatch + + first = tmp_path / "a" + first.mkdir() + skill = first / "SKILL.md" + skill.write_text("---\nname: a\n---\n") + watch = DirectoryWatch([tmp_path]) + watch.changed() + + second = tmp_path / "b" + second.mkdir() + (second / "SKILL.md").write_text("---\nname: b\n---\n") + assert watch.changed() is True, "an added skill" + assert watch.changed() is False, "and then it settles" + + os.utime(skill, ns=(1_000_000_000_000, 1_000_000_000_000)) + assert watch.changed() is True, "an edited skill" + + (second / "SKILL.md").unlink() + assert watch.changed() is True, "a removed skill" + + +def test_the_watch_ignores_files_that_are_not_skills(tmp_path: Path) -> None: + from skillsearch.watch import DirectoryWatch + + watch = DirectoryWatch([tmp_path]) + watch.changed() + (tmp_path / "notes.md").write_text("not a skill") + (tmp_path / ".git").mkdir() + (tmp_path / ".git" / "SKILL.md").write_text("---\nname: x\n---\n") + assert watch.changed() is False + + +def test_an_empty_watch_is_free_and_silent(tmp_path: Path) -> None: + """A host that opted out, or brought its own store, gets no walk.""" + from skillsearch.watch import DirectoryWatch + + watch = DirectoryWatch([]) + assert watch.active is False + assert watch.changed() is False + + +def test_the_watch_never_raises_on_a_missing_directory(tmp_path: Path) -> None: + from skillsearch.watch import DirectoryWatch + + watch = DirectoryWatch([tmp_path / "never-existed"]) + assert watch.changed() is False + + +@pytest.mark.asyncio +async def test_a_skill_dropped_into_the_shared_directory_is_found_next_turn( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Acceptance 5, end to end through the engine. + + The user drags a skill into the shared directory while the agent is + running. Before this, the scan was built once and kept for the life of the + engine, so it took a restart — which for a *shared* library is fatal: a + skill installed in one agent stayed invisible in the others. + """ + from skillsearch.config import SearchConfig + from skillsearch.engine import SkillSearch + from skillsearch import shared + + root = tmp_path / "home" + monkeypatch.setenv(shared.HOME_ENV, str(root)) + shared_skills = root / "skills" + shared_skills.mkdir(parents=True) + + own = tmp_path / "own" + own.mkdir() + + engine = SkillSearch(SearchConfig.from_mapping({ + "skills_dir": str(own), + "extra_dirs": [{"path": str(shared_skills), "name": "shared"}], + "hub_endpoint": "", "clawhub_endpoint": "", "skillhub_cn_endpoint": "", + "top_k": 3, + })) + + assert await engine.retrieve("extract tables from a scanned PDF invoice") == "" + + # The user drops one in. No restart, no invalidate() call by anyone. + dropped = shared_skills / "pdf-tables" + dropped.mkdir() + (dropped / "SKILL.md").write_text( + "---\nname: pdf-tables\n" + "description: Extract tables from PDF documents, scanned or native, into CSV.\n" + "---\n\nOCR scanned pages before extracting tables.\n", + encoding="utf-8", + ) + + block = await engine.retrieve("extract tables from a scanned PDF invoice") + assert "pdf-tables" in block + # And exactly once — the shared directory is scanned by one source, not two. + assert block.count("### Skill: pdf-tables") == 1 diff --git a/skillcorpus_plugin/engine-typescript/src/engine.ts b/skillcorpus_plugin/engine-typescript/src/engine.ts index 8dfdd92..8d5757a 100644 --- a/skillcorpus_plugin/engine-typescript/src/engine.ts +++ b/skillcorpus_plugin/engine-typescript/src/engine.ts @@ -20,6 +20,7 @@ import { LLMGateFilter } from './gate.js' import { resolvePlaceholders, resolveRefs, type PlaceholderRuntime } from './refs.js' import { QueryRewriter } from './rewriter.js' import type { RouterHit, SkillSource } from './types.js' +import { DirectoryWatch } from './watch.js' /** Deployment-fixed retrieval settings, chosen once when the engine is built. */ export interface EngineOptions { @@ -97,6 +98,15 @@ export interface EngineParts { readonly sources: readonly SkillSource[] /** Receives best-effort source timings and failures; callback errors are ignored. */ readonly onDiagnostic?: (diagnostic: SourceDiagnostic) => void + /** + * Directories to re-fingerprint before each retrieval, dropping the cached + * scan when they changed. + * + * The shared skills directory and nothing else. It is the only root that + * changes behind the host's back, and walking every root every turn would + * charge deployments that are not using the shared library. See `watch.ts`. + */ + readonly watchDirs?: readonly string[] readonly rewriter?: QueryRewriter readonly gate?: LLMGateFilter /** Loads a remote body once the gate has kept its hit. */ @@ -147,6 +157,7 @@ export class SkillSearchEngine { private readonly refs: boolean private readonly placeholders: boolean private readonly runtime: PlaceholderRuntime + private readonly watch: DirectoryWatch constructor(parts: EngineParts, options: EngineOptions = {}) { this.sources = parts.sources @@ -155,6 +166,7 @@ export class SkillSearchEngine { this.fetchBody = parts.fetchBody this.materialise = parts.materialise this.onDiagnostic = parts.onDiagnostic + this.watch = new DirectoryWatch(parts.watchDirs ?? []) this.topK = options.topK ?? 2 this.rrfK = options.rrfK this.gatePool = options.gatePool ?? 10 @@ -198,7 +210,29 @@ export class SkillSearchEngine { * @param options - this turn's cancellation and tool list. * @returns the selected skills, empty on any failure; never rejects. */ + /** + * Drop the cached scan when a watched directory changed. + * + * Without this the scan is built once and kept, and no adapter calls + * `invalidate()` — so a skill installed in one agent stays invisible in the + * others until they restart, which is the premise of a shared library. + */ + private revalidate(): void { + if (!this.watch.active || !this.watch.changed()) return + for (const source of this.sources) { + const invalidate = (source as { invalidate?: () => void }).invalidate + if (typeof invalidate === 'function') { + try { + invalidate.call(source) + } catch { + // A source that will not drop its cache costs freshness, not a turn. + } + } + } + } + async hits(query: string, options: RetrieveOptions = {}): Promise { + this.revalidate() if (!this.enabled || !query.trim()) return [] try { return await this.run(query, options) diff --git a/skillcorpus_plugin/engine-typescript/src/index.ts b/skillcorpus_plugin/engine-typescript/src/index.ts index 69a5c7e..8bcaec7 100644 --- a/skillcorpus_plugin/engine-typescript/src/index.ts +++ b/skillcorpus_plugin/engine-typescript/src/index.ts @@ -396,6 +396,10 @@ function buildEngine(ctx: Context, cfg: Config): SkillSearchEngine { return new SkillSearchEngine( { sources, + // Re-fingerprinted before every retrieval so a skill installed by + // another agent, or dragged in by hand, shows up next turn instead of + // after a restart. Only the shared directory — see `watch.ts`. + watchDirs: roots.filter(root => root.name === 'shared').map(root => root.path), ...(model && wantsRewrite ? { rewriter: new QueryRewriter(model, { diff --git a/skillcorpus_plugin/engine-typescript/src/watch.ts b/skillcorpus_plugin/engine-typescript/src/watch.ts new file mode 100644 index 0000000..2acc4f1 --- /dev/null +++ b/skillcorpus_plugin/engine-typescript/src/watch.ts @@ -0,0 +1,120 @@ +/** + * Notice when the shared skills directory changed, without a restart. + * + * The counterpart of `engine-python/skillsearch/watch.py`. The local scan is + * built once and kept for the life of the engine, and `invalidate()` — the + * supported way to drop it — is called by no adapter. So a skill added today + * is invisible until the agent restarts, which is fatal for a *shared* + * library whose whole premise is that installing in one agent shows up in the + * others. + * + * ## Only the shared directory + * + * A host now scans five or more directories rather than one, and walking all + * of them every turn would put the cost on deployments not using this. The + * shared directory is ours, its size is something we control, and it is the + * only one that changes behind the host's back. Everyone else's directories + * keep their host's existing behaviour. + * + * ## Why a fingerprint rather than a revision file + * + * A counter the plugin bumps when *it* installs something is one `stat`, but + * it cannot see a user dragging a directory in by hand — an acceptance case. + * So the fingerprint is the mechanism; a revision file could only be a fast + * path in front of it. + * + * The walk is not pure overhead. Measured on WorkBuddy, whose per-turn hook + * has fingerprinted since 0.2.0: 34ms for 46 skills against 53ms to read and + * parse them. The walk is flat in corpus size and the parse it avoids is not. + * + * @module + */ + +import { readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Directory names never worth descending into. Mirrors the scanner's own skip + * list — a fingerprint tracking different files from the scan would either + * miss changes or invent them. + */ +const SKIP_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox']) + +const SKILL_FILE = 'SKILL.md' + +/** + * Path and mtime of every `SKILL.md` under `dirs`, in scan order. + * + * Sorted per level so two walks of an unchanged tree agree: a filesystem may + * return `readdir` entries in any order, and an unsorted walk would + * invalidate the cache at random. + * + * Never throws — a directory that vanishes mid-walk contributes nothing, + * which is also the right answer. + */ +export function fingerprint(dirs: readonly string[], maxDepth = 5): string { + const parts: string[] = [] + for (const root of dirs) collect(root, maxDepth, parts) + return parts.join('\n') +} + +function collect(dir: string, depth: number, out: string[]): void { + if (depth < 0) return + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { + if (SKIP_DIRS.has(entry.name)) continue + const path = join(dir, entry.name) + if (entry.isDirectory()) { + collect(path, depth - 1, out) + } else if (entry.name === SKILL_FILE) { + try { + // Nanoseconds, not `mtimeMs`. Millisecond resolution misses an edit + // made inside the same millisecond as the previous walk — rare, but + // the failure is silent and looks like "my change did nothing". + out.push(`${path}:${statSync(path, { bigint: true }).mtimeNs}`) + } catch { + // Deleted between readdir and stat: the next turn's walk settles it. + } + } + } +} + +/** + * Answers "did these directories change since I last asked?". + * + * Stateful on purpose: the first call establishes the baseline and reports no + * change, so constructing a watch does not throw away a scan just built. + */ +export class DirectoryWatch { + private seen: string | undefined + + constructor(private readonly dirs: readonly string[], private readonly maxDepth = 5) {} + + /** Whether there is anything to watch. Cheap enough to call per turn. */ + get active(): boolean { + return this.dirs.length > 0 + } + + /** Whether the tree differs from the last call. Never throws. */ + changed(): boolean { + if (this.dirs.length === 0) return false + let current: string + try { + current = fingerprint(this.dirs, this.maxDepth) + } catch { + return false + } + if (this.seen === undefined) { + this.seen = current + return false + } + if (current === this.seen) return false + this.seen = current + return true + } +} diff --git a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts index ccd0e1d..8ba152a 100644 --- a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts +++ b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts @@ -13,7 +13,7 @@ import assert from 'node:assert/strict' import { readFileSync, statSync } from 'node:fs' -import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' @@ -25,6 +25,7 @@ import { LocalSkillSource, formatSkillText } from '../src/local-source.ts' import { resolvePlaceholders, resolveRefs } from '../src/refs.ts' import { QueryRewriter } from '../src/rewriter.ts' import { readRegistry, registerHost, registeredDirs, sharedDirs, sharedRoot } from '../src/shared.ts' +import { DirectoryWatch } from '../src/watch.ts' import { checkKeywordRelevance, queryTerms } from '../src/relevance.ts' import { SkillSearchEngine } from '../src/engine.ts' import { HubSkillSource, SkillHubClient } from '../src/hub-source.ts' @@ -635,3 +636,68 @@ test('sharedDirs never throws', () => { // A registry path that cannot exist. Sharing degrades; the turn does not. assert.deepEqual(sharedDirs('raven', '\0bad', '\0also-bad', {}), []) }) + +// --------------------------------------------------------------------------- +// noticing a changed shared directory without a restart + +test('the watch is quiet on its first look, then tracks the tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillsearch-watch-')) + await mkdir(join(root, 'a')) + await writeFile(join(root, 'a', 'SKILL.md'), '---\nname: a\n---\n') + + const watch = new DirectoryWatch([root]) + // Constructing a watch must not throw away a scan that was just built. + assert.equal(watch.changed(), false) + assert.equal(watch.changed(), false) + + await mkdir(join(root, 'b')) + await writeFile(join(root, 'b', 'SKILL.md'), '---\nname: b\n---\n') + assert.equal(watch.changed(), true, 'an added skill') + assert.equal(watch.changed(), false, 'and then it settles') + + await writeFile(join(root, 'a', 'SKILL.md'), '---\nname: a\n---\nedited\n') + assert.equal(watch.changed(), true, 'an edited skill') + + await rm(join(root, 'b'), { recursive: true }) + assert.equal(watch.changed(), true, 'a removed skill') +}) + +test('the watch ignores what the scanner ignores, and costs nothing when empty', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillsearch-watch-skip-')) + const watch = new DirectoryWatch([root]) + watch.changed() + + await writeFile(join(root, 'notes.md'), 'not a skill') + await mkdir(join(root, '.git')) + await writeFile(join(root, '.git', 'SKILL.md'), '---\nname: x\n---\n') + assert.equal(watch.changed(), false, 'a fingerprint tracking different files from the scan would invent changes') + + // A host that opted out, or brought its own store, gets no walk at all. + const idle = new DirectoryWatch([]) + assert.equal(idle.active, false) + assert.equal(idle.changed(), false) + assert.equal(new DirectoryWatch([join(root, 'never-existed')]).changed(), false) +}) + +test('a skill dropped into a watched directory is found on the next retrieval', async () => { + // The feature's headline, end to end: no restart, and nobody calling + // `invalidate()` by hand. + const root = await mkdtemp(join(tmpdir(), 'skillsearch-watch-e2e-')) + const local = new LocalSkillSource([{ path: root, name: 'shared' }], {}) + const engine = new SkillSearchEngine({ sources: [local], watchDirs: [root] }, { topK: 3 }) + + assert.equal(await engine.retrieve('extract tables from a scanned PDF invoice'), '') + + await mkdir(join(root, 'pdf-tables')) + await writeFile( + join(root, 'pdf-tables', 'SKILL.md'), + '---\nname: pdf-tables\n' + + 'description: Extract tables from PDF documents, scanned or native, into CSV.\n' + + '---\n\nOCR scanned pages before extracting tables.\n', + ) + + const block = await engine.retrieve('extract tables from a scanned PDF invoice') + assert.match(block, /pdf-tables/) + // Exactly once: the shared directory is scanned by one source, not two. + assert.equal(block.split('### Skill: pdf-tables').length - 1, 1) +}) diff --git a/skillcorpus_plugin/plugin-openclaw/src/register.ts b/skillcorpus_plugin/plugin-openclaw/src/register.ts index 794b874..ad48801 100644 --- a/skillcorpus_plugin/plugin-openclaw/src/register.ts +++ b/skillcorpus_plugin/plugin-openclaw/src/register.ts @@ -91,6 +91,10 @@ export function buildEngine( return new SkillSearchEngine( { sources, + // Re-fingerprinted before every retrieval so a skill installed by + // another agent, or dragged in by hand, shows up next turn instead of + // after a restart. Only the shared directory — see `watch.ts`. + watchDirs: roots.filter(root => root.name === 'shared').map(root => root.path), // Without this a source that is down is invisible here. The engine // already reports it — one failing source leaves the others usable, by // design — but nothing was consuming the report, so "the catalogue was diff --git a/skillcorpus_plugin/plugin-openclaw2/src/register.ts b/skillcorpus_plugin/plugin-openclaw2/src/register.ts index ed04fec..a78d521 100644 --- a/skillcorpus_plugin/plugin-openclaw2/src/register.ts +++ b/skillcorpus_plugin/plugin-openclaw2/src/register.ts @@ -129,6 +129,10 @@ export function buildEngine( return new SkillSearchEngine( { sources, + // Re-fingerprinted before every retrieval so a skill installed by + // another agent, or dragged in by hand, shows up next turn instead of + // after a restart. Only the shared directory — see `watch.ts`. + watchDirs: roots.filter(root => root.name === 'shared').map(root => root.path), // Without this a source that is down is invisible here. The engine // already reports it — one failing source leaves the others usable, by // design — but nothing was consuming the report, so "the catalogue was diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs index 043078d..09ca692 100755 --- a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs @@ -191,7 +191,7 @@ function loadConfig(document, env = process.env) { // src/retrieve.ts import { homedir as homedir3 } from "node:os"; -import { join as join9 } from "node:path"; +import { join as join10 } from "node:path"; // ../engine-typescript/src/engine.ts import { createHash } from "node:crypto"; @@ -543,6 +543,68 @@ function parse(content) { return { rewrittenQuery: rewritten }; } +// ../engine-typescript/src/watch.ts +import { readdirSync, statSync as statSync2 } from "node:fs"; +import { join as join3 } from "node:path"; +var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "__pycache__", ".venv", "venv", ".tox"]); +var SKILL_FILE = "SKILL.md"; +function fingerprint(dirs, maxDepth = 5) { + const parts = []; + for (const root of dirs) collect(root, maxDepth, parts); + return parts.join("\n"); +} +function collect(dir, depth, out) { + if (depth < 0) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of [...entries].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { + if (SKIP_DIRS.has(entry.name)) continue; + const path = join3(dir, entry.name); + if (entry.isDirectory()) { + collect(path, depth - 1, out); + } else if (entry.name === SKILL_FILE) { + try { + out.push(`${path}:${statSync2(path, { bigint: true }).mtimeNs}`); + } catch { + } + } + } +} +var DirectoryWatch = class { + constructor(dirs, maxDepth = 5) { + this.dirs = dirs; + this.maxDepth = maxDepth; + } + dirs; + maxDepth; + seen; + /** Whether there is anything to watch. Cheap enough to call per turn. */ + get active() { + return this.dirs.length > 0; + } + /** Whether the tree differs from the last call. Never throws. */ + changed() { + if (this.dirs.length === 0) return false; + let current; + try { + current = fingerprint(this.dirs, this.maxDepth); + } catch { + return false; + } + if (this.seen === void 0) { + this.seen = current; + return false; + } + if (current === this.seen) return false; + this.seen = current; + return true; + } +}; + // ../engine-typescript/src/engine.ts var SkillSearchEngine = class { sources; @@ -561,6 +623,7 @@ var SkillSearchEngine = class { refs; placeholders; runtime; + watch; constructor(parts, options = {}) { this.sources = parts.sources; this.rewriter = parts.rewriter; @@ -568,6 +631,7 @@ var SkillSearchEngine = class { this.fetchBody = parts.fetchBody; this.materialise = parts.materialise; this.onDiagnostic = parts.onDiagnostic; + this.watch = new DirectoryWatch(parts.watchDirs ?? []); this.topK = options.topK ?? 2; this.rrfK = options.rrfK; this.gatePool = options.gatePool ?? 10; @@ -603,7 +667,27 @@ var SkillSearchEngine = class { * @param options - this turn's cancellation and tool list. * @returns the selected skills, empty on any failure; never rejects. */ + /** + * Drop the cached scan when a watched directory changed. + * + * Without this the scan is built once and kept, and no adapter calls + * `invalidate()` — so a skill installed in one agent stays invisible in the + * others until they restart, which is the premise of a shared library. + */ + revalidate() { + if (!this.watch.active || !this.watch.changed()) return; + for (const source of this.sources) { + const invalidate = source.invalidate; + if (typeof invalidate === "function") { + try { + invalidate.call(source); + } catch { + } + } + } + } async hits(query, options = {}) { + this.revalidate(); if (!this.enabled || !query.trim()) return []; try { return await this.run(query, options); @@ -839,12 +923,12 @@ function errorMessage(error) { // ../engine-typescript/src/hub-source.ts import { existsSync as existsSync2 } from "node:fs"; -import { join as join4 } from "node:path"; +import { join as join5 } from "node:path"; // ../engine-typescript/src/bundle.ts import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import { readdir } from "node:fs/promises"; -import { isAbsolute, join as join3, relative, resolve } from "node:path"; +import { isAbsolute, join as join4, relative, resolve } from "node:path"; // ../engine-typescript/src/zip.ts import { inflateRawSync } from "node:zlib"; @@ -982,7 +1066,7 @@ async function extractBundle(archive, destination) { } const data = entry.read(); total += data.length; - await mkdir(join3(target, ".."), { recursive: true }); + await mkdir(join4(target, ".."), { recursive: true }); await writeFile(target, data); } try { @@ -1008,7 +1092,7 @@ async function bundleRoot(destination) { } const visible = entries.filter((entry) => !entry.name.startsWith(".")); const only = visible[0]; - if (visible.length === 1 && only?.isDirectory()) return join3(destination, only.name); + if (visible.length === 1 && only?.isDirectory()) return join4(destination, only.name); return destination; } @@ -1168,7 +1252,7 @@ var SkillHubClient = class { const record = meta ?? await this.get(id, signal); const slug = String(record.slug ?? record.skill_id ?? id).replace(/\//g, "_"); const version = String(record.version ?? "v0"); - const destination = join4(this.cacheDir, `${slug}@${version}`); + const destination = join5(this.cacheDir, `${slug}@${version}`); if (!existsSync2(destination)) { const archive = await this.download(id, signal); await extractBundle(archive, destination); @@ -1321,7 +1405,7 @@ function randomId() { // ../engine-typescript/src/marketplace-source.ts import { existsSync as existsSync3 } from "node:fs"; import { readFile, rm as rm2 } from "node:fs/promises"; -import { join as join5 } from "node:path"; +import { join as join6 } from "node:path"; var MarketplaceClient = class { kind; base; @@ -1343,7 +1427,7 @@ var MarketplaceClient = class { const owner = String(hit.meta.owner ?? ""); const version = String(hit.meta.version ?? "v0"); const key = `${this.kind}-${owner ? `${owner}_` : ""}${slug}@${version}`.replace(/[^A-Za-z0-9_.@-]+/g, "_"); - const destination = join5(this.cacheDir, key); + const destination = join6(this.cacheDir, key); if (!existsSync3(destination)) { let archive; try { @@ -1359,7 +1443,7 @@ var MarketplaceClient = class { } try { const dir = await bundleRoot(destination); - const skillMd = await readFile(join5(dir, "SKILL.md"), "utf8"); + const skillMd = await readFile(join6(dir, "SKILL.md"), "utf8"); return { dir, body: stripFrontmatter(skillMd) }; } catch (error) { await rm2(destination, { recursive: true, force: true }).catch(() => { @@ -1488,30 +1572,30 @@ function errorMessage2(error) { } // ../engine-typescript/src/shared.ts -import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, renameSync, rmSync, statSync as statSync2, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; import { homedir as homedir2 } from "node:os"; -import { isAbsolute as isAbsolute2, join as join6, resolve as resolve2 } from "node:path"; +import { isAbsolute as isAbsolute2, join as join7, resolve as resolve2 } from "node:path"; var HOME_ENV = "SKILLSEARCH_HOME"; var NOTE = "To exclude a directory, set its `enabled` to false. Deleting the line does not work \u2014 that agent re-registers it on its next start."; function expandHome(path, home = homedir2()) { if (path === "~") return home; - if (path.startsWith("~/")) return join6(home, path.slice(2)); + if (path.startsWith("~/")) return join7(home, path.slice(2)); return path; } function sharedRoot(env = process.env) { const override = (env[HOME_ENV] ?? "").trim(); if (override) return expandHome(override); - return join6(homedir2(), ".evermind-skillsearch"); + return join7(homedir2(), ".evermind-skillsearch"); } function sharedSkillsDir(env = process.env) { - return join6(sharedRoot(env), "skills"); + return join7(sharedRoot(env), "skills"); } function registryPath(env = process.env) { - return join6(sharedRoot(env), "registry.json"); + return join7(sharedRoot(env), "registry.json"); } function isDirectory2(path) { try { - return statSync2(path).isDirectory(); + return statSync3(path).isDirectory(); } catch { return false; } @@ -1549,8 +1633,8 @@ function writeRegistry(entries, path) { try { const parent = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))); mkdirSync(parent, { recursive: true }); - staging = mkdtempSync(join6(parent, ".registry-")); - const scratch = join6(staging, "registry.json"); + staging = mkdtempSync(join7(parent, ".registry-")); + const scratch = join7(staging, "registry.json"); writeFileSync(scratch, `${JSON.stringify(payload, null, 2)} `, "utf8"); renameSync(scratch, path); @@ -1640,12 +1724,12 @@ function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { } // src/cached-local-source.ts -import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync, renameSync as renameSync2, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs"; -import { dirname as dirname2, join as join8 } from "node:path"; +import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync as renameSync2, statSync as statSync4, writeFileSync as writeFileSync2 } from "node:fs"; +import { dirname as dirname2, join as join9 } from "node:path"; // ../engine-typescript/src/local-source.ts import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises"; -import { basename, join as join7 } from "node:path"; +import { basename, join as join8 } from "node:path"; // ../engine-typescript/src/bm25.ts var TOKEN_RE = /[a-z0-9]{2,}|[一-鿿]+/g; @@ -1733,9 +1817,9 @@ var BM25Okapi = class { }; // ../engine-typescript/src/local-source.ts -var SKILL_FILE = "SKILL.md"; +var SKILL_FILE2 = "SKILL.md"; var INDEXED_BODY_CHARS = 4e3; -var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); +var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); var LocalSkillSource = class { name = "local"; weight = 1; @@ -1798,7 +1882,7 @@ var LocalSkillSource = class { continue; } const { meta, body } = parseFrontmatter(text); - const dir = file.slice(0, file.length - SKILL_FILE.length - 1); + const dir = file.slice(0, file.length - SKILL_FILE2.length - 1); const name = meta.name ?? basename(dir); const key = `${root.name}/${name}`; if (seen.has(key)) continue; @@ -1834,9 +1918,9 @@ async function* walk(root, maxDepth) { } for (const entry of entries) { if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) stack.push({ dir: join7(dir, entry.name), depth: depth + 1 }); - } else if (entry.name === SKILL_FILE) { - yield join7(dir, entry.name); + if (!SKIP_DIRS2.has(entry.name)) stack.push({ dir: join8(dir, entry.name), depth: depth + 1 }); + } else if (entry.name === SKILL_FILE2) { + yield join8(dir, entry.name); } } } @@ -1859,8 +1943,8 @@ function parseFrontmatter(text) { } // src/cached-local-source.ts -var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); -var SKILL_FILE2 = "SKILL.md"; +var SKIP_DIRS3 = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); +var SKILL_FILE3 = "SKILL.md"; var CachedLocalSkillSource = class extends LocalSkillSource { cachePath; rootPaths; @@ -1877,17 +1961,17 @@ var CachedLocalSkillSource = class extends LocalSkillSource { */ async listAll() { if (!this.cachePath) return super.listAll(); - const fingerprint = this.fingerprint(); + const fingerprint2 = this.fingerprint(); const cached = this.read(); - if (cached && cached.fingerprint === fingerprint) return cached.skills; + if (cached && cached.fingerprint === fingerprint2) return cached.skills; const skills = await super.listAll(); - this.write({ version: 1, fingerprint, skills }); + this.write({ version: 1, fingerprint: fingerprint2, skills }); return skills; } /** Path and mtime of every `SKILL.md` under the roots, in scan order. */ fingerprint() { const parts = []; - for (const root of this.rootPaths) collect(root, this.depth, parts); + for (const root of this.rootPaths) collect2(root, this.depth, parts); return `${parts.length}|${hash(parts.join("\n"))}`; } read() { @@ -1911,21 +1995,21 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } } }; -function collect(dir, depth, out) { +function collect2(dir, depth, out) { if (depth < 0) return; let entries; try { - entries = readdirSync(dir, { withFileTypes: true }); + entries = readdirSync2(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - if (SKIP_DIRS2.has(entry.name)) continue; - const path = join8(dir, entry.name); - if (entry.isDirectory()) collect(path, depth - 1, out); - else if (entry.name === SKILL_FILE2) { + if (SKIP_DIRS3.has(entry.name)) continue; + const path = join9(dir, entry.name); + if (entry.isDirectory()) collect2(path, depth - 1, out); + else if (entry.name === SKILL_FILE3) { try { - out.push(`${path}:${statSync3(path).mtimeMs}`); + out.push(`${path}:${statSync4(path).mtimeMs}`); } catch { } } @@ -1970,7 +2054,7 @@ function createChatModel(options) { // src/retrieve.ts function expandHome2(path, home = homedir3()) { if (path === "~") return home; - if (path.startsWith("~/")) return join9(home, path.slice(2)); + if (path.startsWith("~/")) return join10(home, path.slice(2)); return path; } function buildEngine(config, onDiagnostic, workspaceDir) { @@ -1992,7 +2076,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // Outside every scanned directory. `~/.workbuddy-ai/plugins/cache` is // one of the defaults, so a bundle extracted under it would come back // as a local skill on the next scan. - cacheDir: expandHome2(config.bundleCacheDir) || join9(homedir3(), ".workbuddy-ai", "skillsearch-bundles") + cacheDir: expandHome2(config.bundleCacheDir) || join10(homedir3(), ".workbuddy-ai", "skillsearch-bundles") }); const hub = new HubSkillSource(client); hub.weight = config.hubWeight; @@ -2005,7 +2089,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { ]) { if (!endpoint) continue; const marketplace = new MarketplaceClient(kind, endpoint, { - cacheDir: expandHome2(config.bundleCacheDir) || join9(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), + cacheDir: expandHome2(config.bundleCacheDir) || join10(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), // ClawHub measured 4–5s on the supported route. Give search headroom, // but leave time under the hook's global deadline for body hydration. timeoutMs: Math.max(1, Math.min(config.timeoutMs, 6500)), @@ -2022,6 +2106,10 @@ function buildEngine(config, onDiagnostic, workspaceDir) { return new SkillSearchEngine( { sources, + // Re-fingerprinted before every retrieval so a skill installed by + // another agent, or dragged in by hand, shows up next turn instead of + // after a restart. Only the shared directory — see `watch.ts`. + watchDirs: roots.filter((root) => root.name === "shared").map((root) => root.path), ...onDiagnostic ? { onDiagnostic } : {}, ...model && config.rewrite ? { rewriter: new QueryRewriter(model) } : {}, ...model && (config.gate ?? (Boolean(config.hubEndpoint) || marketplaceClients.size > 0)) ? { gate: new LLMGateFilter(model, { maxSelect: config.maxSelect }) } : {}, @@ -2062,7 +2150,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // falling back to the hook process's cwd when the payload reports none. outputDir: workspaceDir || process.cwd(), homeDir: homedir3(), - stateDir: join9(homedir3(), ".workbuddy-ai"), + stateDir: join10(homedir3(), ".workbuddy-ai"), resolvePlaceholders: config.resolvePlaceholders } ); diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs index 67e6d78..e4fe0ab 100644 --- a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs @@ -182,7 +182,7 @@ function loadConfig(document, env = process.env) { // src/retrieve.ts import { homedir as homedir3 } from "node:os"; -import { join as join9 } from "node:path"; +import { join as join10 } from "node:path"; // ../engine-typescript/src/engine.ts import { createHash } from "node:crypto"; @@ -534,6 +534,68 @@ function parse(content) { return { rewrittenQuery: rewritten }; } +// ../engine-typescript/src/watch.ts +import { readdirSync, statSync as statSync2 } from "node:fs"; +import { join as join3 } from "node:path"; +var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "__pycache__", ".venv", "venv", ".tox"]); +var SKILL_FILE = "SKILL.md"; +function fingerprint(dirs, maxDepth = 5) { + const parts = []; + for (const root of dirs) collect(root, maxDepth, parts); + return parts.join("\n"); +} +function collect(dir, depth, out) { + if (depth < 0) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of [...entries].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { + if (SKIP_DIRS.has(entry.name)) continue; + const path = join3(dir, entry.name); + if (entry.isDirectory()) { + collect(path, depth - 1, out); + } else if (entry.name === SKILL_FILE) { + try { + out.push(`${path}:${statSync2(path, { bigint: true }).mtimeNs}`); + } catch { + } + } + } +} +var DirectoryWatch = class { + constructor(dirs, maxDepth = 5) { + this.dirs = dirs; + this.maxDepth = maxDepth; + } + dirs; + maxDepth; + seen; + /** Whether there is anything to watch. Cheap enough to call per turn. */ + get active() { + return this.dirs.length > 0; + } + /** Whether the tree differs from the last call. Never throws. */ + changed() { + if (this.dirs.length === 0) return false; + let current; + try { + current = fingerprint(this.dirs, this.maxDepth); + } catch { + return false; + } + if (this.seen === void 0) { + this.seen = current; + return false; + } + if (current === this.seen) return false; + this.seen = current; + return true; + } +}; + // ../engine-typescript/src/engine.ts var SkillSearchEngine = class { sources; @@ -552,6 +614,7 @@ var SkillSearchEngine = class { refs; placeholders; runtime; + watch; constructor(parts, options = {}) { this.sources = parts.sources; this.rewriter = parts.rewriter; @@ -559,6 +622,7 @@ var SkillSearchEngine = class { this.fetchBody = parts.fetchBody; this.materialise = parts.materialise; this.onDiagnostic = parts.onDiagnostic; + this.watch = new DirectoryWatch(parts.watchDirs ?? []); this.topK = options.topK ?? 2; this.rrfK = options.rrfK; this.gatePool = options.gatePool ?? 10; @@ -594,7 +658,27 @@ var SkillSearchEngine = class { * @param options - this turn's cancellation and tool list. * @returns the selected skills, empty on any failure; never rejects. */ + /** + * Drop the cached scan when a watched directory changed. + * + * Without this the scan is built once and kept, and no adapter calls + * `invalidate()` — so a skill installed in one agent stays invisible in the + * others until they restart, which is the premise of a shared library. + */ + revalidate() { + if (!this.watch.active || !this.watch.changed()) return; + for (const source of this.sources) { + const invalidate = source.invalidate; + if (typeof invalidate === "function") { + try { + invalidate.call(source); + } catch { + } + } + } + } async hits(query, options = {}) { + this.revalidate(); if (!this.enabled || !query.trim()) return []; try { return await this.run(query, options); @@ -830,12 +914,12 @@ function errorMessage(error) { // ../engine-typescript/src/hub-source.ts import { existsSync as existsSync2 } from "node:fs"; -import { join as join4 } from "node:path"; +import { join as join5 } from "node:path"; // ../engine-typescript/src/bundle.ts import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import { readdir } from "node:fs/promises"; -import { isAbsolute, join as join3, relative, resolve } from "node:path"; +import { isAbsolute, join as join4, relative, resolve } from "node:path"; // ../engine-typescript/src/zip.ts import { inflateRawSync } from "node:zlib"; @@ -973,7 +1057,7 @@ async function extractBundle(archive, destination) { } const data = entry.read(); total += data.length; - await mkdir(join3(target, ".."), { recursive: true }); + await mkdir(join4(target, ".."), { recursive: true }); await writeFile(target, data); } try { @@ -999,7 +1083,7 @@ async function bundleRoot(destination) { } const visible = entries.filter((entry) => !entry.name.startsWith(".")); const only = visible[0]; - if (visible.length === 1 && only?.isDirectory()) return join3(destination, only.name); + if (visible.length === 1 && only?.isDirectory()) return join4(destination, only.name); return destination; } @@ -1159,7 +1243,7 @@ var SkillHubClient = class { const record = meta ?? await this.get(id, signal); const slug = String(record.slug ?? record.skill_id ?? id).replace(/\//g, "_"); const version = String(record.version ?? "v0"); - const destination = join4(this.cacheDir, `${slug}@${version}`); + const destination = join5(this.cacheDir, `${slug}@${version}`); if (!existsSync2(destination)) { const archive = await this.download(id, signal); await extractBundle(archive, destination); @@ -1312,7 +1396,7 @@ function randomId() { // ../engine-typescript/src/marketplace-source.ts import { existsSync as existsSync3 } from "node:fs"; import { readFile, rm as rm2 } from "node:fs/promises"; -import { join as join5 } from "node:path"; +import { join as join6 } from "node:path"; var MarketplaceClient = class { kind; base; @@ -1334,7 +1418,7 @@ var MarketplaceClient = class { const owner = String(hit.meta.owner ?? ""); const version = String(hit.meta.version ?? "v0"); const key = `${this.kind}-${owner ? `${owner}_` : ""}${slug}@${version}`.replace(/[^A-Za-z0-9_.@-]+/g, "_"); - const destination = join5(this.cacheDir, key); + const destination = join6(this.cacheDir, key); if (!existsSync3(destination)) { let archive; try { @@ -1350,7 +1434,7 @@ var MarketplaceClient = class { } try { const dir = await bundleRoot(destination); - const skillMd = await readFile(join5(dir, "SKILL.md"), "utf8"); + const skillMd = await readFile(join6(dir, "SKILL.md"), "utf8"); return { dir, body: stripFrontmatter(skillMd) }; } catch (error) { await rm2(destination, { recursive: true, force: true }).catch(() => { @@ -1479,30 +1563,30 @@ function errorMessage2(error) { } // ../engine-typescript/src/shared.ts -import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, renameSync, rmSync, statSync as statSync2, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; import { homedir as homedir2 } from "node:os"; -import { isAbsolute as isAbsolute2, join as join6, resolve as resolve2 } from "node:path"; +import { isAbsolute as isAbsolute2, join as join7, resolve as resolve2 } from "node:path"; var HOME_ENV = "SKILLSEARCH_HOME"; var NOTE = "To exclude a directory, set its `enabled` to false. Deleting the line does not work \u2014 that agent re-registers it on its next start."; function expandHome(path, home = homedir2()) { if (path === "~") return home; - if (path.startsWith("~/")) return join6(home, path.slice(2)); + if (path.startsWith("~/")) return join7(home, path.slice(2)); return path; } function sharedRoot(env = process.env) { const override = (env[HOME_ENV] ?? "").trim(); if (override) return expandHome(override); - return join6(homedir2(), ".evermind-skillsearch"); + return join7(homedir2(), ".evermind-skillsearch"); } function sharedSkillsDir(env = process.env) { - return join6(sharedRoot(env), "skills"); + return join7(sharedRoot(env), "skills"); } function registryPath(env = process.env) { - return join6(sharedRoot(env), "registry.json"); + return join7(sharedRoot(env), "registry.json"); } function isDirectory2(path) { try { - return statSync2(path).isDirectory(); + return statSync3(path).isDirectory(); } catch { return false; } @@ -1540,8 +1624,8 @@ function writeRegistry(entries, path) { try { const parent = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))); mkdirSync(parent, { recursive: true }); - staging = mkdtempSync(join6(parent, ".registry-")); - const scratch = join6(staging, "registry.json"); + staging = mkdtempSync(join7(parent, ".registry-")); + const scratch = join7(staging, "registry.json"); writeFileSync(scratch, `${JSON.stringify(payload, null, 2)} `, "utf8"); renameSync(scratch, path); @@ -1631,12 +1715,12 @@ function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { } // src/cached-local-source.ts -import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync, renameSync as renameSync2, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs"; -import { dirname as dirname2, join as join8 } from "node:path"; +import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync as renameSync2, statSync as statSync4, writeFileSync as writeFileSync2 } from "node:fs"; +import { dirname as dirname2, join as join9 } from "node:path"; // ../engine-typescript/src/local-source.ts import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises"; -import { basename, join as join7 } from "node:path"; +import { basename, join as join8 } from "node:path"; // ../engine-typescript/src/bm25.ts var TOKEN_RE = /[a-z0-9]{2,}|[一-鿿]+/g; @@ -1724,9 +1808,9 @@ var BM25Okapi = class { }; // ../engine-typescript/src/local-source.ts -var SKILL_FILE = "SKILL.md"; +var SKILL_FILE2 = "SKILL.md"; var INDEXED_BODY_CHARS = 4e3; -var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); +var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); var LocalSkillSource = class { name = "local"; weight = 1; @@ -1789,7 +1873,7 @@ var LocalSkillSource = class { continue; } const { meta, body } = parseFrontmatter(text2); - const dir = file.slice(0, file.length - SKILL_FILE.length - 1); + const dir = file.slice(0, file.length - SKILL_FILE2.length - 1); const name = meta.name ?? basename(dir); const key = `${root.name}/${name}`; if (seen.has(key)) continue; @@ -1825,9 +1909,9 @@ async function* walk(root, maxDepth) { } for (const entry of entries) { if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) stack.push({ dir: join7(dir, entry.name), depth: depth + 1 }); - } else if (entry.name === SKILL_FILE) { - yield join7(dir, entry.name); + if (!SKIP_DIRS2.has(entry.name)) stack.push({ dir: join8(dir, entry.name), depth: depth + 1 }); + } else if (entry.name === SKILL_FILE2) { + yield join8(dir, entry.name); } } } @@ -1850,8 +1934,8 @@ function parseFrontmatter(text2) { } // src/cached-local-source.ts -var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); -var SKILL_FILE2 = "SKILL.md"; +var SKIP_DIRS3 = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); +var SKILL_FILE3 = "SKILL.md"; var CachedLocalSkillSource = class extends LocalSkillSource { cachePath; rootPaths; @@ -1868,17 +1952,17 @@ var CachedLocalSkillSource = class extends LocalSkillSource { */ async listAll() { if (!this.cachePath) return super.listAll(); - const fingerprint = this.fingerprint(); + const fingerprint2 = this.fingerprint(); const cached = this.read(); - if (cached && cached.fingerprint === fingerprint) return cached.skills; + if (cached && cached.fingerprint === fingerprint2) return cached.skills; const skills = await super.listAll(); - this.write({ version: 1, fingerprint, skills }); + this.write({ version: 1, fingerprint: fingerprint2, skills }); return skills; } /** Path and mtime of every `SKILL.md` under the roots, in scan order. */ fingerprint() { const parts = []; - for (const root of this.rootPaths) collect(root, this.depth, parts); + for (const root of this.rootPaths) collect2(root, this.depth, parts); return `${parts.length}|${hash(parts.join("\n"))}`; } read() { @@ -1902,21 +1986,21 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } } }; -function collect(dir, depth, out) { +function collect2(dir, depth, out) { if (depth < 0) return; let entries; try { - entries = readdirSync(dir, { withFileTypes: true }); + entries = readdirSync2(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - if (SKIP_DIRS2.has(entry.name)) continue; - const path = join8(dir, entry.name); - if (entry.isDirectory()) collect(path, depth - 1, out); - else if (entry.name === SKILL_FILE2) { + if (SKIP_DIRS3.has(entry.name)) continue; + const path = join9(dir, entry.name); + if (entry.isDirectory()) collect2(path, depth - 1, out); + else if (entry.name === SKILL_FILE3) { try { - out.push(`${path}:${statSync3(path).mtimeMs}`); + out.push(`${path}:${statSync4(path).mtimeMs}`); } catch { } } @@ -1961,7 +2045,7 @@ function createChatModel(options) { // src/retrieve.ts function expandHome2(path, home = homedir3()) { if (path === "~") return home; - if (path.startsWith("~/")) return join9(home, path.slice(2)); + if (path.startsWith("~/")) return join10(home, path.slice(2)); return path; } function buildEngine(config, onDiagnostic, workspaceDir) { @@ -1983,7 +2067,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // Outside every scanned directory. `~/.workbuddy-ai/plugins/cache` is // one of the defaults, so a bundle extracted under it would come back // as a local skill on the next scan. - cacheDir: expandHome2(config.bundleCacheDir) || join9(homedir3(), ".workbuddy-ai", "skillsearch-bundles") + cacheDir: expandHome2(config.bundleCacheDir) || join10(homedir3(), ".workbuddy-ai", "skillsearch-bundles") }); const hub = new HubSkillSource(client); hub.weight = config.hubWeight; @@ -1996,7 +2080,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { ]) { if (!endpoint) continue; const marketplace = new MarketplaceClient(kind, endpoint, { - cacheDir: expandHome2(config.bundleCacheDir) || join9(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), + cacheDir: expandHome2(config.bundleCacheDir) || join10(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), // ClawHub measured 4–5s on the supported route. Give search headroom, // but leave time under the hook's global deadline for body hydration. timeoutMs: Math.max(1, Math.min(config.timeoutMs, 6500)), @@ -2013,6 +2097,10 @@ function buildEngine(config, onDiagnostic, workspaceDir) { return new SkillSearchEngine( { sources, + // Re-fingerprinted before every retrieval so a skill installed by + // another agent, or dragged in by hand, shows up next turn instead of + // after a restart. Only the shared directory — see `watch.ts`. + watchDirs: roots.filter((root) => root.name === "shared").map((root) => root.path), ...onDiagnostic ? { onDiagnostic } : {}, ...model && config.rewrite ? { rewriter: new QueryRewriter(model) } : {}, ...model && (config.gate ?? (Boolean(config.hubEndpoint) || marketplaceClients.size > 0)) ? { gate: new LLMGateFilter(model, { maxSelect: config.maxSelect }) } : {}, @@ -2053,7 +2141,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // falling back to the hook process's cwd when the payload reports none. outputDir: workspaceDir || process.cwd(), homeDir: homedir3(), - stateDir: join9(homedir3(), ".workbuddy-ai"), + stateDir: join10(homedir3(), ".workbuddy-ai"), resolvePlaceholders: config.resolvePlaceholders } ); diff --git a/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts b/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts index 894a9dc..bb92112 100644 --- a/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts +++ b/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts @@ -106,6 +106,10 @@ export function buildEngine( return new SkillSearchEngine( { sources, + // Re-fingerprinted before every retrieval so a skill installed by + // another agent, or dragged in by hand, shows up next turn instead of + // after a restart. Only the shared directory — see `watch.ts`. + watchDirs: roots.filter(root => root.name === 'shared').map(root => root.path), ...(onDiagnostic ? { onDiagnostic } : {}), ...(model && config.rewrite ? { rewriter: new QueryRewriter(model) } : {}), ...(model && (config.gate ?? (Boolean(config.hubEndpoint) || marketplaceClients.size > 0)) From 676a9c7ccdf74e6d57c30e0c374a26c0bb45a29f Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 02:51:41 +0000 Subject: [PATCH 03/14] feat(plugin): keep retrieved skills, count them once, and let users manage them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec sections 3, 4 and 5. They land together because they only work together: installing into a scanned directory is what creates the double counting that identity dedup exists to prevent, and the marker carrying that identity is also the ledger management reads. **Where they land.** A skill retrieved from a catalogue was extracted into `.skillsearch-cache/`, used for one turn and thrown away — re-downloaded next turn, and never visible to another agent. That cache is deliberately outside every scan, and for a good reason: underneath a scanned directory each downloaded bundle would be picked up as a *local* skill and compete with itself in the same ranking. Installs now go to the shared directory instead, one directory per identity rather than per version, so an update replaces rather than accumulating a second ranked copy. Not into a host's own directory, which was the other option and is worse: `~/.openclaw/skills` is loaded by OpenClaw's native skill system too, so putting a third-party download there decides on the user's behalf that it applies to the host as well; ownership becomes unguessable; and the same skill ends up copied per host. **Why it still counts once.** `provenance.py` / `provenance.ts` write a marker inside each installed skill — source, slug, version, body digest, timestamp — and the scanner carries the identity on the hit. Fusion collapses on it. Neither existing defence could: fusion's key is `qualifiedId`, and `local/pdf-tables` and `hub/pdf-tables` are different ids, while the exact-body dedup compares a digest that a bumped version or a changed line ending defeats. Identity is what the skill *is*, so the collapse holds whatever the bytes are. **Managing them.** Installing puts files on someone's disk, so `listInstalled` / `uninstall` and a human-readable marker per skill are not optional. The ledger is the directory rather than an index: a skill the user deleted by hand is simply gone, not a stale row nobody can explain. Updates are "build the new one, switch, then delete the old". `rename` alone cannot do it — on POSIX it fails onto a non-empty directory — so the old copy is moved aside and moved *back* if the switch does not complete. A failure at any point leaves the previous version in place and working, which is acceptance 8. Two things found by building it: A same-version reinstall is skipped. Without that, any turn whose retrieval happens to hit an already-installed skill rewrites it on disk, which is both wasteful and enough to trip the directory watch every single turn. Creating the shared directory eagerly made every deployment "enabled", because an empty shared directory is still a source — so a host configured with no skills directory at all started scanning and registering. It does not now: a deployment that configured no skills directory is stating it has no local skills, and joining the shared library would contradict that. Three existing tests caught this, which is what they were for. Co-Authored-By: Claude Opus 5 (1M context) --- .../engine-python/skillsearch/config.py | 12 + .../engine-python/skillsearch/engine.py | 21 ++ .../engine-python/skillsearch/fusion.py | 20 +- .../engine-python/skillsearch/hub_client.py | 64 +++- .../engine-python/skillsearch/local_store.py | 8 + .../engine-python/skillsearch/provenance.py | 251 ++++++++++++++ .../engine-python/skillsearch/shared.py | 6 + .../skillsearch/sources/hub_source.py | 4 + .../skillsearch/sources/local_source.py | 4 + .../skillsearch/sources/marketplace_source.py | 59 +++- .../engine-python/skillsearch/types.py | 8 + .../engine-python/tests/test_provenance.py | 270 +++++++++++++++ .../engine-python/tests/test_shared.py | 20 +- .../engine-typescript/src/fusion.ts | 20 +- .../engine-typescript/src/hub-source.ts | 72 +++- .../engine-typescript/src/index.ts | 26 +- .../engine-typescript/src/local-source.ts | 5 + .../src/marketplace-source.ts | 56 +++- .../engine-typescript/src/provenance.ts | 266 +++++++++++++++ .../engine-typescript/src/shared.ts | 5 + .../engine-typescript/tests/parity.test.ts | 123 +++++++ .../plugin-openclaw/src/register.ts | 26 +- .../plugin-openclaw2/src/register.ts | 26 +- .../plugin-workbuddy/dist/hook.mjs | 309 +++++++++++++++--- .../plugin-workbuddy/dist/mcp.mjs | 305 ++++++++++++++--- .../plugin-workbuddy/src/retrieve.ts | 26 +- 26 files changed, 1887 insertions(+), 125 deletions(-) create mode 100644 skillcorpus_plugin/engine-python/skillsearch/provenance.py create mode 100644 skillcorpus_plugin/engine-python/tests/test_provenance.py create mode 100644 skillcorpus_plugin/engine-typescript/src/provenance.ts diff --git a/skillcorpus_plugin/engine-python/skillsearch/config.py b/skillcorpus_plugin/engine-python/skillsearch/config.py index 054b548..1452c8a 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/config.py +++ b/skillcorpus_plugin/engine-python/skillsearch/config.py @@ -74,6 +74,18 @@ class SearchConfig: builtin_dir: str = "" """Read-only skills shipped with the host.""" + install_to_shared: bool = True + """Install retrieved skills into the shared library rather than the cache. + + The cache is deliberately outside every scan, so a skill downloaded for + one turn is thrown away and re-downloaded for the next, and no other agent + ever sees it. Installing into the shared directory keeps it: one copy, on + disk, scanned by every host that opted in, with a provenance marker so + fusion knows it is the catalogue's own entry rather than a second skill. + + Off restores the pre-0.4 behaviour exactly. + """ + scan_depth: int = 5 index_body: bool = False diff --git a/skillcorpus_plugin/engine-python/skillsearch/engine.py b/skillcorpus_plugin/engine-python/skillsearch/engine.py index 5e550ce..42f2ee4 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/engine.py +++ b/skillcorpus_plugin/engine-python/skillsearch/engine.py @@ -161,6 +161,7 @@ def _build_router(self, store: SkillStore | None, extra_sources: Sequence[Any]): timeout_s=cfg.hub_timeout_s, download_timeout_s=cfg.hub_download_timeout_s, cache_dir=cfg.resolved_cache_dir(), + install_root=self._install_root(), ) sources.append( HubSkillSource( @@ -185,6 +186,7 @@ def _build_router(self, store: SkillStore | None, extra_sources: Sequence[Any]): kind, endpoint, cache_dir=cfg.resolved_cache_dir(), + install_root=self._install_root(), timeout_s=cfg.marketplace_timeout_s, download_timeout_s=cfg.marketplace_download_timeout_s, ) @@ -283,6 +285,25 @@ def _build_narrowing(self): # ── The entry point ────────────────────────────────────────────── + def _install_root(self): + """Where a retrieved skill is kept, or ``None`` to use the cache. + + The shared skills directory when the deployment opted in — created + here rather than lazily, because a directory that does not exist is + not scanned, and a skill installed into an unscanned directory is the + exact bug this feature exists to fix. + """ + if not self._cfg.install_to_shared: + return None + try: + from skillsearch.shared import shared_skills_dir + + root = shared_skills_dir() + root.mkdir(parents=True, exist_ok=True) + except Exception: # sharing is never worth a failed turn + return None + return root + def _revalidate(self) -> None: """Drop the cached scan when the shared directory changed. diff --git a/skillcorpus_plugin/engine-python/skillsearch/fusion.py b/skillcorpus_plugin/engine-python/skillsearch/fusion.py index 25a1eb9..5912ff3 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/fusion.py +++ b/skillcorpus_plugin/engine-python/skillsearch/fusion.py @@ -43,6 +43,24 @@ RRF_K: int = 60 +def _collapse_key(hit: RouterHit, dedup_by: str) -> str: + """What counts as "the same skill" across sources. + + ``meta["origin"]`` when the skill has one, because that is what it *is*: + a skill installed from a catalogue and the catalogue entry it came from + are one thing, and nothing else here would notice — their + ``qualified_id``s differ (``local/x`` versus ``hub/x``), and a body digest + misses a bumped version or a changed line ending. + + Falls back to ``dedup_by`` for everything without an origin, which is + every hand-written skill and every uninstalled catalogue hit. + """ + origin = (hit.meta or {}).get("origin") + if isinstance(origin, str) and origin.strip(): + return origin.strip() + return str(getattr(hit, dedup_by)) + + def rrf_merge_weighted( source_results: list[tuple[str, float, list[RouterHit]]], k: int, @@ -77,7 +95,7 @@ def rrf_merge_weighted( for source_name, weight, hits in source_results: for rank, hit in enumerate(hits, start=1): - key = getattr(hit, dedup_by) + key = _collapse_key(hit, dedup_by) claim = weight / (rrf_k + rank) rrf_scores[key] += claim contributing[key].append(source_name) diff --git a/skillcorpus_plugin/engine-python/skillsearch/hub_client.py b/skillcorpus_plugin/engine-python/skillsearch/hub_client.py index dc1ffc6..0ddd897 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/hub_client.py +++ b/skillcorpus_plugin/engine-python/skillsearch/hub_client.py @@ -29,6 +29,8 @@ import httpx +from skillsearch import provenance + logger = logging.getLogger(__name__) _DEFAULT_TIMEOUT_S = 2.0 @@ -96,12 +98,20 @@ def __init__( # deployment whose set you know. source: str = "cli", cache_dir: Path | None = None, + # Set to the shared skills directory to install there instead of into + # the cache. The difference is not just the path: a skill under the + # cache is deliberately outside every scan, while one here is scanned, + # shared with the other hosts, and carries a provenance marker so + # fusion can tell it is the same thing the catalogue offers. Unset + # keeps the pre-0.4 behaviour exactly. + install_root: Path | None = None, client: httpx.AsyncClient | None = None, ) -> None: self._base = endpoint.rstrip("/") self._api_key = api_key self._source = source self._cache_dir = cache_dir or (Path.home() / ".skillsearch" / "hub") + self._install_root = install_root self._download_timeout_s = download_timeout_s self._owns_client = client is None self._client = client or httpx.AsyncClient(timeout=httpx.Timeout(timeout_s)) @@ -202,10 +212,13 @@ async def install( slug = meta.get("slug") or meta.get("skill_id") or skill_id slug = str(slug).replace("/", "_") version = str(meta.get("version") or "v0") - dest = self._cache_dir / f"{slug}@{version}" - if not dest.exists(): - await self._install_atomically(skill_id, dest) - root = self._bundle_root(dest) + if self._install_root is not None: + root = await self._install_shared(skill_id, slug, version, meta) + else: + dest = self._cache_dir / f"{slug}@{version}" + if not dest.exists(): + await self._install_atomically(skill_id, dest) + root = self._bundle_root(dest) scripts = root / "scripts" return { "slug": slug, @@ -215,6 +228,49 @@ async def install( "skill_md": meta.get("skill_md", ""), } + async def _install_shared(self, skill_id: str, slug: str, version: str, meta: dict[str, Any]) -> Path: + """Install into the shared directory, one directory per identity. + + Per identity rather than per version, unlike the cache: the shared + directory is scanned, so keeping ``x@1`` beside ``x@2`` would put two + ranked copies of one skill in front of the model. An update therefore + replaces, and `swap_into_place` is what makes replacing safe — the + previous version stays live until the new one is complete. + + A same-version reinstall is skipped, which is what keeps a retrieval + that happens to hit an installed skill from rewriting it every turn. + """ + # Only reached when `install` checked it, but read into a local so the + # type is narrowed without an assert the linter rightly objects to. + install_root = self._install_root + if install_root is None: # pragma: no cover - unreachable via `install` + raise RuntimeError("no install root") + dest = provenance.slug_dir(install_root, "hub", slug) + existing = provenance.read_marker(dest) + if existing is not None and existing.version == version: + return self._bundle_root(dest) + + staging = dest.with_name(f"{dest.name}.incoming-{os.getpid()}-{uuid.uuid4().hex[:8]}") + try: + self._safe_extract(await self.download(skill_id), staging) + body_root = self._bundle_root(staging) + provenance.write_marker( + body_root, + provenance.Origin( + origin=provenance.identity("hub", slug), + source="hub", + slug=slug, + version=version, + sha256=provenance.body_digest(str(meta.get("skill_md") or "")), + installed_at=provenance.now(), + ), + ) + provenance.swap_into_place(staging, dest) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + return self._bundle_root(dest) + @staticmethod def _bundle_root(dest: Path) -> Path: """Resolve the real skill directory inside the extracted bundle. diff --git a/skillcorpus_plugin/engine-python/skillsearch/local_store.py b/skillcorpus_plugin/engine-python/skillsearch/local_store.py index a67933a..6582433 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/local_store.py +++ b/skillcorpus_plugin/engine-python/skillsearch/local_store.py @@ -17,6 +17,8 @@ from dataclasses import dataclass from pathlib import Path +from skillsearch.provenance import read_marker + log = logging.getLogger(__name__) _SKILL_FILE = "SKILL.md" @@ -33,6 +35,8 @@ class FileSkill: source: str path: Path always: bool = False + origin: str = "" + """``/`` when this plugin installed it; empty otherwise.""" def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]: @@ -117,6 +121,9 @@ def _scan(self) -> list[FileSkill]: ) continue seen.add(name) + # Read once per scan rather than per hit: the scan is cached + # and the ranking is not, so this is the cheap place for it. + installed = read_marker(path.parent) found.append( FileSkill( name=name, @@ -125,6 +132,7 @@ def _scan(self) -> list[FileSkill]: source=source, path=path, always=str(meta.get("always", "")).lower() in {"1", "true", "yes"}, + origin=installed.origin if installed else "", ), ) return found diff --git a/skillcorpus_plugin/engine-python/skillsearch/provenance.py b/skillcorpus_plugin/engine-python/skillsearch/provenance.py new file mode 100644 index 0000000..4a20ab9 --- /dev/null +++ b/skillcorpus_plugin/engine-python/skillsearch/provenance.py @@ -0,0 +1,251 @@ +"""Where an installed skill came from, recorded beside the skill itself. + +Installing into the shared directory — rather than into a cache excluded from +scanning — creates a problem the cache exclusion existed to avoid: the skill is +now both a local hit (it is on disk, and the scanner sees it) and a remote hit +(the catalogue still returns it), so one skill takes two slots in the ranking. + +Neither existing defence catches that: + +- fusion collapses on ``qualified_id``, and ``local/pdf-tables`` and + ``hub/pdf-tables`` are different ids; +- ``_dedup_exact_bodies`` compares a SHA-256 of the body, so a trailing newline + or a bumped version number is enough to miss. + +The fix is identity rather than coincidence. An install writes a marker inside +the skill's own directory saying what it is and where it came from; the scanner +reads that marker and carries the identity on the hit; fusion collapses on it. +A skill installed from ``hub`` and the same skill offered by ``hub`` are then +one thing by construction, whatever their bytes happen to be. + +The marker doubles as the ledger. Installing puts files on a user's disk, so +they must be able to see what is there and remove it — and a single file per +skill, inside the skill, cannot drift out of sync with the directory the way a +central index can. ``list_installed`` is a directory walk, not a database read. + +Everything here fails open. A marker that cannot be read leaves the skill +looking hand-written, which costs deduplication for that one skill; it must +never cost a turn. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import os +import shutil +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +#: Inside the skill's own directory. Dotted so a host's own scanner ignores it, +#: and named for this plugin so its owner is obvious to someone browsing. +MARKER = ".skillsearch-origin.json" + +_NOTE = "Written by the skillsearch plugin. Delete the directory to uninstall." + + +@dataclass(frozen=True) +class Origin: + """What an installed skill is, and where it came from.""" + + origin: str + """``/``. The identity fusion collapses on.""" + + source: str + slug: str + version: str = "" + sha256: str = "" + installed_at: str = "" + + def as_json(self) -> dict[str, object]: + return { + "_note": _NOTE, + "version": 1, + "origin": self.origin, + "source": self.source, + "slug": self.slug, + "skill_version": self.version, + "sha256": self.sha256, + "installed_at": self.installed_at, + } + + +def identity(source: str, slug: str) -> str: + """The cross-source identity of one skill. + + Deliberately not the ``qualified_id``: that is ``/`` + where source is the *retrieval* source, so the same skill reached two ways + has two of them. This is what the skill *is*. + """ + return f"{str(source).strip()}/{str(slug).strip()}" + + +def body_digest(body: str) -> str: + """SHA-256 of a skill body, for the ledger. + + Recorded so an update can say what changed and a user can tell a modified + skill from an untouched one. Not used for deduplication — that is what the + identity above is for, precisely because a digest misses a bumped version. + """ + return hashlib.sha256((body or "").encode("utf-8")).hexdigest() + + +def write_marker(skill_dir: str | os.PathLike[str], origin: Origin) -> bool: + """Record provenance inside an installed skill. ``False`` on failure. + + Written atomically for the same reason the bundle itself is: a reader + walking the shared directory must never see half a marker. + """ + target = Path(skill_dir) / MARKER + try: + target.parent.mkdir(parents=True, exist_ok=True) + handle, tmp = tempfile.mkstemp(dir=str(target.parent), prefix=".origin-", suffix=".json") + try: + with os.fdopen(handle, "w", encoding="utf-8") as fh: + json.dump(origin.as_json(), fh, indent=2, ensure_ascii=False) + fh.write("\n") + os.replace(tmp, target) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp) + raise + except (OSError, ValueError): + return False + return True + + +def read_marker(skill_dir: str | os.PathLike[str]) -> Origin | None: + """Provenance for one skill, or ``None`` when it was not installed here. + + ``None`` is the ordinary answer, not an error: a hand-written skill has no + marker and must keep working exactly as it did. + """ + try: + raw = json.loads((Path(skill_dir) / MARKER).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(raw, dict): + return None + source = str(raw.get("source") or "").strip() + slug = str(raw.get("slug") or "").strip() + if not source or not slug: + return None + return Origin( + origin=str(raw.get("origin") or identity(source, slug)), + source=source, + slug=slug, + version=str(raw.get("skill_version") or ""), + sha256=str(raw.get("sha256") or ""), + installed_at=str(raw.get("installed_at") or ""), + ) + + +def now() -> str: + """An install timestamp, UTC and second-resolution.""" + return datetime.now(UTC).replace(microsecond=0).isoformat() + + +def slug_dir(root: str | os.PathLike[str], source: str, slug: str) -> Path: + """Where a skill from ``source`` lands under ``root``. + + One directory per identity, not per version: an update replaces what is + there rather than accumulating copies, which is what keeps the shared + directory from growing a second ranked copy of everything. + + The name is sanitised because a slug comes from a catalogue and reaches + the filesystem — anything outside the allow-list becomes ``_``, so a slug + of ``../../etc`` cannot escape ``root``. + """ + safe_source = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(source))[:40] + safe_slug = "".join(c if c.isalnum() or c in "-_.@" else "_" for c in str(slug))[:120] + return Path(root) / f"{safe_source}__{safe_slug or 'skill'}" + + +def list_installed(root: str | os.PathLike[str]) -> list[Origin]: + """Every skill this plugin installed under ``root``, sorted by identity. + + A walk rather than an index read: the directory is the truth, so a skill a + user deleted by hand is simply gone rather than a stale row nobody can + explain. + """ + out: list[Origin] = [] + try: + entries = sorted(Path(root).iterdir(), key=lambda p: p.name) + except OSError: + return out + for entry in entries: + if not entry.is_dir(): + continue + marker = read_marker(entry) + if marker is not None: + out.append(marker) + return sorted(out, key=lambda o: o.origin) + + +def find_installed(root: str | os.PathLike[str], origin: str) -> Path | None: + """The directory holding an installed skill, by identity.""" + try: + entries = sorted(Path(root).iterdir(), key=lambda p: p.name) + except OSError: + return None + for entry in entries: + if not entry.is_dir(): + continue + marker = read_marker(entry) + if marker is not None and marker.origin == origin: + return entry + return None + + +def swap_into_place(staging: str | os.PathLike[str], dest: str | os.PathLike[str]) -> None: + """Move a finished install over whatever is at ``dest``. + + An install must never leave the user worse off than before it started, so + an update is "build the new one, switch, then delete the old" rather than + "delete the old, then build". A failure at any point leaves the previous + version in place and working. + + ``rename`` alone will not do it: on POSIX renaming onto a non-empty + directory fails, and on Windows it fails onto any existing one. So the old + copy is moved aside first, and moved back if the switch does not complete. + + @raises OSError - the install did not happen; ``dest`` is untouched. + """ + staging_path, dest_path = Path(staging), Path(dest) + if not dest_path.exists(): + dest_path.parent.mkdir(parents=True, exist_ok=True) + os.replace(staging_path, dest_path) + return + + retired = dest_path.with_name(f"{dest_path.name}.retiring-{os.getpid()}-{os.urandom(4).hex()}") + os.replace(dest_path, retired) + try: + os.replace(staging_path, dest_path) + except BaseException: + # Put the working copy back before letting the failure out. + with contextlib.suppress(OSError): + os.replace(retired, dest_path) + raise + else: + shutil.rmtree(retired, ignore_errors=True) + + +def uninstall(root: str | os.PathLike[str], origin: str) -> bool: + """Remove an installed skill by identity. ``False`` if it was not there. + + Moved aside and then deleted, so a half-finished delete cannot leave a + directory the scanner still reads as a skill. + """ + found = find_installed(root, origin) + if found is None: + return False + retired = found.with_name(f"{found.name}.removing-{os.getpid()}-{os.urandom(4).hex()}") + try: + os.replace(found, retired) + except OSError: + return False + shutil.rmtree(retired, ignore_errors=True) + return True diff --git a/skillcorpus_plugin/engine-python/skillsearch/shared.py b/skillcorpus_plugin/engine-python/skillsearch/shared.py index c328301..ff91d88 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/shared.py +++ b/skillcorpus_plugin/engine-python/skillsearch/shared.py @@ -322,6 +322,12 @@ def add(directory: str, name: str) -> None: seen.add(resolved) out.append({"path": resolved, "name": name, "enabled": True}) + if not skills_dir: + # A deployment that configured no skills directory is saying it has no + # local skills, and joining the shared library would contradict that: + # the shared directory alone would make the engine "enabled" and start + # it scanning on a deployment that asked for none of this. + return out try: # The host's own main directory is scanned separately by the engine, # so it only goes in `seen` — enough to keep the registry from adding diff --git a/skillcorpus_plugin/engine-python/skillsearch/sources/hub_source.py b/skillcorpus_plugin/engine-python/skillsearch/sources/hub_source.py index ded361d..9055361 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/sources/hub_source.py +++ b/skillcorpus_plugin/engine-python/skillsearch/sources/hub_source.py @@ -20,6 +20,7 @@ import logging from typing import TYPE_CHECKING, Any +from skillsearch.provenance import identity from skillsearch.relevance import check_keyword_relevance from skillsearch.types import RouterHit @@ -80,6 +81,9 @@ async def search(self, query: str, history: list[dict[str, Any]], k: int) -> lis score=float(quality or 0.0), meta={ "source": "hub", + # The same identity the installer records, so a hit + # from here and the installed copy on disk collapse. + "origin": identity("hub", str(sid)), "id": sid, "skill_id": item.get("skill_id"), "description": item.get("description"), diff --git a/skillcorpus_plugin/engine-python/skillsearch/sources/local_source.py b/skillcorpus_plugin/engine-python/skillsearch/sources/local_source.py index 5f5deb4..c72a416 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/sources/local_source.py +++ b/skillcorpus_plugin/engine-python/skillsearch/sources/local_source.py @@ -92,6 +92,10 @@ async def search( "always": meta.always, "skill_dir": skill_dir, "description": meta.description, + # Set only for a skill this plugin installed. Fusion + # collapses on it, so the local copy and the catalogue + # entry it came from are one hit rather than two. + "origin": getattr(meta, "origin", "") or "", }, ), ) diff --git a/skillcorpus_plugin/engine-python/skillsearch/sources/marketplace_source.py b/skillcorpus_plugin/engine-python/skillsearch/sources/marketplace_source.py index 7972558..d34fcc4 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/sources/marketplace_source.py +++ b/skillcorpus_plugin/engine-python/skillsearch/sources/marketplace_source.py @@ -11,8 +11,10 @@ import httpx +from skillsearch import provenance from skillsearch.hub_client import SkillHubClient from skillsearch.local_store import _parse_frontmatter +from skillsearch.provenance import identity from skillsearch.types import RouterHit MarketplaceKind = Literal["clawhub", "skillhub_cn"] @@ -25,6 +27,11 @@ def __init__( endpoint: str, *, cache_dir: Path, + # Set to the shared skills directory to install there instead of into + # the cache. A skill under the cache is deliberately outside every + # scan; one here is scanned, shared with the other hosts, and carries + # a provenance marker so fusion knows it is the catalogue's own entry. + install_root: Path | None = None, timeout_s: float = 5.0, download_timeout_s: float = 30.0, client: httpx.AsyncClient | None = None, @@ -32,6 +39,7 @@ def __init__( self.kind = kind self._base = endpoint.rstrip("/") self._cache_dir = cache_dir + self._install_root = install_root self._download_timeout_s = download_timeout_s self._owns_client = client is None self._client = client or httpx.AsyncClient(timeout=httpx.Timeout(timeout_s)) @@ -49,6 +57,8 @@ async def install(self, hit: RouterHit) -> dict[str, str]: slug = str(hit.meta.get("slug") or hit.meta.get("id")) owner = str(hit.meta.get("owner") or "") version = str(hit.meta.get("version") or "v0") + if self._install_root is not None: + return await self._install_shared(hit, slug, owner, version) key = re.sub(r"[^A-Za-z0-9_.@-]+", "_", f"{self.kind}-{owner + '_' if owner else ''}{slug}@{version}") destination = self._cache_dir / key was_cached = destination.exists() @@ -80,6 +90,51 @@ async def install(self, hit: RouterHit) -> dict[str, str]: return await self.install(hit) raise + async def _install_shared(self, hit: RouterHit, slug: str, owner: str, version: str) -> dict[str, str]: + """Install into the shared directory, one directory per identity. + + Per identity rather than per version, unlike the cache: the shared + directory is scanned, so ``x@1`` beside ``x@2`` would put two ranked + copies of one skill in front of the model. An update replaces, and + `swap_into_place` keeps the previous version live until the new one is + complete. + + A same-version reinstall is skipped, so a retrieval that happens to hit + an installed skill does not rewrite it every turn. + """ + install_root = self._install_root + if install_root is None: # pragma: no cover - unreachable via `install` + raise RuntimeError("no install root") + marketplace_slug = f"{owner}_{slug}" if owner else slug + dest = provenance.slug_dir(install_root, self.kind, marketplace_slug) + existing = provenance.read_marker(dest) + if existing is None or existing.version != version: + staging = dest.with_name(f"{dest.name}.incoming-{os.getpid()}-{uuid.uuid4().hex[:8]}") + try: + SkillHubClient._safe_extract(await self._download(slug, owner, version), staging) + body_root = SkillHubClient._bundle_root(staging) + text = (body_root / "SKILL.md").read_text(encoding="utf-8") + _, body = _parse_frontmatter(text) + provenance.write_marker( + body_root, + provenance.Origin( + origin=provenance.identity(self.kind, marketplace_slug), + source=self.kind, + slug=marketplace_slug, + version=version, + sha256=provenance.body_digest(body), + installed_at=provenance.now(), + ), + ) + provenance.swap_into_place(staging, dest) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + root = SkillHubClient._bundle_root(dest) + text = (root / "SKILL.md").read_text(encoding="utf-8") + _, body = _parse_frontmatter(text) + return {"dir": str(root), "skill_md": body} + async def _search_clawhub(self, query: str, limit: int) -> list[dict[str, Any]]: response = await self._client.get( f"{self._base}/api/v1/search", @@ -181,7 +236,9 @@ async def search(self, query: str, history: list[dict[str, Any]], k: int) -> lis name=str(item["name"]), content="", score=float(item["score"]), - meta={"source": self.name, **item}, + # `origin` before the spread so a catalogue field of that + # name cannot shadow the identity fusion collapses on. + meta={"source": self.name, **item, "origin": identity(self.name, str(item.get("slug") or item["id"]))}, ) for item in items[: min(2, max(0, k))] ] diff --git a/skillcorpus_plugin/engine-python/skillsearch/types.py b/skillcorpus_plugin/engine-python/skillsearch/types.py index bf1c2ab..99e6dda 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/types.py +++ b/skillcorpus_plugin/engine-python/skillsearch/types.py @@ -130,6 +130,14 @@ class SkillMeta: source: str = "local" path: Any = None always: bool = False + origin: str = "" + """``/`` when this plugin installed the skill. + + Empty for a hand-written skill, which is the ordinary case. Set from the + marker `provenance.py` writes, and used by fusion to collapse a skill with + the catalogue entry it came from — the two are one thing, but their + ``qualified_id``s differ, so nothing else would notice. + """ @dataclass diff --git a/skillcorpus_plugin/engine-python/tests/test_provenance.py b/skillcorpus_plugin/engine-python/tests/test_provenance.py new file mode 100644 index 0000000..e15f3eb --- /dev/null +++ b/skillcorpus_plugin/engine-python/tests/test_provenance.py @@ -0,0 +1,270 @@ +"""Installed skills: where they land, why they count once, and managing them. + +Spec sections 3, 4 and 5. They are tested together because they only work +together: installing into a scanned directory (3) is what creates the double +counting that identity dedup (4) exists to prevent, and the marker that +carries the identity is also the ledger management (5) reads. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from skillsearch import provenance +from skillsearch.config import SearchConfig +from skillsearch.engine import SkillSearch +from skillsearch.fusion import rrf_merge_weighted +from skillsearch.types import RouterHit + + +def _origin(source: str = "hub", slug: str = "pdf-tables", version: str = "1.0") -> provenance.Origin: + return provenance.Origin( + origin=provenance.identity(source, slug), + source=source, + slug=slug, + version=version, + sha256=provenance.body_digest("body"), + installed_at=provenance.now(), + ) + + +def _install( + root: Path, + source: str = "hub", + slug: str = "pdf-tables", + version: str = "1.0", + body: str = "OCR scanned pages first.", +) -> Path: + skill = provenance.slug_dir(root, source, slug) + skill.mkdir(parents=True, exist_ok=True) + (skill / "SKILL.md").write_text( + f"---\nname: {slug}\ndescription: Extract tables from PDF documents into CSV.\n---\n\n{body}\n", + encoding="utf-8", + ) + provenance.write_marker(skill, _origin(source, slug, version)) + return skill + + +# --------------------------------------------------------------------------- +# the marker + + +def test_a_marker_round_trips(tmp_path: Path) -> None: + skill = tmp_path / "skill" + skill.mkdir() + written = _origin() + assert provenance.write_marker(skill, written) is True + read = provenance.read_marker(skill) + assert read == written + + +def test_a_skill_without_a_marker_is_not_an_error(tmp_path: Path) -> None: + """A hand-written skill has no marker and must keep working exactly.""" + skill = tmp_path / "handwritten" + skill.mkdir() + assert provenance.read_marker(skill) is None + + +@pytest.mark.parametrize("text", ["{not json", "[]", "null", '{"source": "hub"}', '{"slug": "x"}']) +def test_an_unusable_marker_reads_as_absent(tmp_path: Path, text: str) -> None: + skill = tmp_path / "skill" + skill.mkdir() + (skill / provenance.MARKER).write_text(text, encoding="utf-8") + assert provenance.read_marker(skill) is None + + +def test_a_slug_cannot_escape_the_install_root(tmp_path: Path) -> None: + """Slugs come from a catalogue and reach the filesystem.""" + for slug in ("../../etc/passwd", "a/b", "..", "~/x", ""): + landed = provenance.slug_dir(tmp_path, "hub", slug) + assert landed.parent == tmp_path, slug + assert tmp_path in landed.resolve().parents or landed.resolve().parent == tmp_path + + +# --------------------------------------------------------------------------- +# identity dedup — section 4 + + +def test_fusion_collapses_a_local_copy_with_the_catalogue_entry() -> None: + """The whole reason installing into a scanned directory is safe. + + `qualified_id` cannot do this — `local/pdf-tables` and `hub/pdf-tables` + are different ids — and a body digest cannot either, since the installed + copy and the catalogue's summary rarely have identical bytes. + """ + origin = provenance.identity("hub", "pdf-tables") + local = RouterHit( + qualified_id="local/pdf-tables", + name="pdf-tables", + content="body", + score=1.0, + meta={"source": "local", "origin": origin}, + ) + remote = RouterHit( + qualified_id="hub/pdf-tables", + name="pdf-tables", + content="", + score=0.9, + meta={"source": "hub", "origin": origin}, + ) + + merged = rrf_merge_weighted([("local", 1.0, [local]), ("hub", 1.0, [remote])], k=5, dedup_by="qualified_id") + + assert len(merged) == 1 + assert sorted(merged[0].meta["contributing_sources"]) == ["hub", "local"] + + +def test_fusion_keeps_two_different_skills_apart() -> None: + a = RouterHit( + qualified_id="local/a", name="a", content="", score=1.0, meta={"origin": provenance.identity("hub", "a")} + ) + b = RouterHit( + qualified_id="local/b", name="b", content="", score=1.0, meta={"origin": provenance.identity("hub", "b")} + ) + assert len(rrf_merge_weighted([("local", 1.0, [a, b])], k=5, dedup_by="qualified_id")) == 2 + + +def test_a_skill_without_an_origin_falls_back_to_the_old_key() -> None: + """Every hand-written skill, and every uninstalled catalogue hit.""" + a = RouterHit(qualified_id="local/x", name="x", content="", score=1.0, meta={}) + b = RouterHit(qualified_id="hub/x", name="x", content="", score=1.0, meta={}) + assert len(rrf_merge_weighted([("local", 1.0, [a]), ("hub", 1.0, [b])], k=5, dedup_by="qualified_id")) == 2 + assert len(rrf_merge_weighted([("local", 1.0, [a]), ("hub", 1.0, [b])], k=5, dedup_by="name")) == 1 + + +# --------------------------------------------------------------------------- +# the ledger — section 5 + + +def test_listing_walks_the_directory_rather_than_an_index(tmp_path: Path) -> None: + _install(tmp_path, "hub", "pdf-tables") + _install(tmp_path, "clawhub", "git-bisect") + (tmp_path / "handwritten").mkdir() + (tmp_path / "handwritten" / "SKILL.md").write_text("---\nname: h\n---\n") + + listed = provenance.list_installed(tmp_path) + + # Only what this plugin installed, and the user's own skill untouched. + assert [o.origin for o in listed] == ["clawhub/git-bisect", "hub/pdf-tables"] + + +def test_a_skill_deleted_by_hand_simply_disappears(tmp_path: Path) -> None: + """The directory is the truth — no stale row nobody can explain.""" + import shutil + + skill = _install(tmp_path) + assert len(provenance.list_installed(tmp_path)) == 1 + shutil.rmtree(skill) + assert provenance.list_installed(tmp_path) == [] + + +def test_uninstalling_removes_it_and_reports_whether_it_was_there(tmp_path: Path) -> None: + _install(tmp_path) + assert provenance.uninstall(tmp_path, "hub/pdf-tables") is True + assert provenance.list_installed(tmp_path) == [] + assert provenance.uninstall(tmp_path, "hub/pdf-tables") is False + + +# --------------------------------------------------------------------------- +# replacing safely — section 5's update rule + + +def test_an_update_replaces_in_place(tmp_path: Path) -> None: + root = tmp_path / "skills" + root.mkdir() + _install(root, version="1.0", body="old") + staging = tmp_path / "staging" + staging.mkdir() + (staging / "SKILL.md").write_text("---\nname: pdf-tables\n---\n\nnew\n", encoding="utf-8") + provenance.write_marker(staging, _origin(version="2.0")) + + provenance.swap_into_place(staging, provenance.slug_dir(root, "hub", "pdf-tables")) + + listed = provenance.list_installed(root) + assert [(o.origin, o.version) for o in listed] == [("hub/pdf-tables", "2.0")] + # One directory per identity: an update replaces rather than accumulating + # a second ranked copy of the same skill. + assert len([p for p in root.iterdir() if p.is_dir()]) == 1 + + +def test_a_failed_update_leaves_the_old_version_working(tmp_path: Path) -> None: + """Acceptance 8. The whole point of switching rather than overwriting.""" + root = tmp_path / "skills" + root.mkdir() + _install(root, version="1.0", body="the version that works") + dest = provenance.slug_dir(root, "hub", "pdf-tables") + + with pytest.raises(OSError): + provenance.swap_into_place(tmp_path / "never-extracted", dest) + + assert (dest / "SKILL.md").read_text(encoding="utf-8").endswith("the version that works\n") + assert [(o.origin, o.version) for o in provenance.list_installed(root)] == [("hub/pdf-tables", "1.0")] + + +# --------------------------------------------------------------------------- +# end to end, through the engine + + +@pytest.mark.asyncio +async def test_an_installed_skill_is_retrieved_once_not_twice(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Acceptance 2, the case sections 3 and 4 exist for together. + + Before identity dedup, a skill installed into a scanned directory was both + a local hit and a remote hit and took two of the ranking's slots. + """ + from skillsearch import shared + + monkeypatch.setenv(shared.HOME_ENV, str(tmp_path / "home")) + shared_skills = shared.shared_skills_dir() + shared_skills.mkdir(parents=True) + _install(shared_skills, "hub", "pdf-tables") + + class Catalogue: + """A source that keeps offering what is already installed.""" + + name = "hub" + weight = 1.0 + + async def search(self, query: str, history: list, k: int) -> list[RouterHit]: + del query, history, k + return [ + RouterHit( + qualified_id="hub/pdf-tables", + name="pdf-tables", + content="", + score=0.9, + meta={"source": "hub", "origin": provenance.identity("hub", "pdf-tables")}, + ) + ] + + engine = SkillSearch( + SearchConfig.from_mapping( + { + "skills_dir": str(tmp_path / "own"), + "extra_dirs": [{"path": str(shared_skills), "name": "shared"}], + "hub_endpoint": "", + "clawhub_endpoint": "", + "skillhub_cn_endpoint": "", + "top_k": 5, + } + ), + extra_sources=[Catalogue()], + ) + + block = await engine.retrieve("extract tables from a scanned PDF invoice") + + assert "pdf-tables" in block + assert block.count("### Skill: pdf-tables") == 1, block + + +def test_the_marker_is_json_a_person_can_read(tmp_path: Path) -> None: + """Installing puts files on a user's disk; they must be able to see what.""" + skill = _install(tmp_path) + document = json.loads((skill / provenance.MARKER).read_text(encoding="utf-8")) + assert document["origin"] == "hub/pdf-tables" + assert document["source"] == "hub" + assert document["skill_version"] == "1.0" + assert "_note" in document and "uninstall" in document["_note"] diff --git a/skillcorpus_plugin/engine-python/tests/test_shared.py b/skillcorpus_plugin/engine-python/tests/test_shared.py index af05f6c..aab6982 100644 --- a/skillcorpus_plugin/engine-python/tests/test_shared.py +++ b/skillcorpus_plugin/engine-python/tests/test_shared.py @@ -303,9 +303,9 @@ async def test_a_skill_dropped_into_the_shared_directory_is_found_next_turn( engine, so it took a restart — which for a *shared* library is fatal: a skill installed in one agent stayed invisible in the others. """ + from skillsearch import shared from skillsearch.config import SearchConfig from skillsearch.engine import SkillSearch - from skillsearch import shared root = tmp_path / "home" monkeypatch.setenv(shared.HOME_ENV, str(root)) @@ -315,12 +315,18 @@ async def test_a_skill_dropped_into_the_shared_directory_is_found_next_turn( own = tmp_path / "own" own.mkdir() - engine = SkillSearch(SearchConfig.from_mapping({ - "skills_dir": str(own), - "extra_dirs": [{"path": str(shared_skills), "name": "shared"}], - "hub_endpoint": "", "clawhub_endpoint": "", "skillhub_cn_endpoint": "", - "top_k": 3, - })) + engine = SkillSearch( + SearchConfig.from_mapping( + { + "skills_dir": str(own), + "extra_dirs": [{"path": str(shared_skills), "name": "shared"}], + "hub_endpoint": "", + "clawhub_endpoint": "", + "skillhub_cn_endpoint": "", + "top_k": 3, + } + ) + ) assert await engine.retrieve("extract tables from a scanned PDF invoice") == "" diff --git a/skillcorpus_plugin/engine-typescript/src/fusion.ts b/skillcorpus_plugin/engine-typescript/src/fusion.ts index d38f3fe..2be3b44 100644 --- a/skillcorpus_plugin/engine-typescript/src/fusion.ts +++ b/skillcorpus_plugin/engine-typescript/src/fusion.ts @@ -50,6 +50,24 @@ export interface SourceResult { * 60 any weight gap between sources outweighs every rank gap within one. * @returns the fused hits, best first, at most `k` long. */ +/** + * What counts as "the same skill" across sources. + * + * `meta.origin` when the skill has one, because that is what it *is*: a skill + * installed from a catalogue and the catalogue entry it came from are one + * thing, and nothing else here would notice — their `qualifiedId`s differ + * (`local/x` versus `hub/x`), and a body digest misses a bumped version or a + * changed line ending. + * + * Falls back to `dedupBy` for everything without an origin, which is every + * hand-written skill and every uninstalled catalogue hit. + */ +function collapseKey(hit: RouterHit, dedupBy: 'name' | 'qualifiedId'): string { + const origin = hit.meta?.origin + if (typeof origin === 'string' && origin.trim()) return origin.trim() + return hit[dedupBy] +} + export function rrfMergeWeighted( sourceResults: readonly SourceResult[], k: number, @@ -69,7 +87,7 @@ export function rrfMergeWeighted( for (const { name: sourceName, weight, hits } of sourceResults) { for (const [i, hit] of hits.entries()) { const rank = i + 1 - const key = hit[dedupBy] + const key = collapseKey(hit, dedupBy) const contribution = weight / (rrfK + rank) const seen = merged.get(key) if (seen === undefined) { diff --git a/skillcorpus_plugin/engine-typescript/src/hub-source.ts b/skillcorpus_plugin/engine-typescript/src/hub-source.ts index 3cc7b41..2f706ad 100644 --- a/skillcorpus_plugin/engine-typescript/src/hub-source.ts +++ b/skillcorpus_plugin/engine-typescript/src/hub-source.ts @@ -14,8 +14,9 @@ * @module */ -import { existsSync } from 'node:fs' +import { existsSync, rmSync } from 'node:fs' import { join } from 'node:path' +import { bodyDigest, identity, now, readMarker, slugDir, swapIntoPlace, writeMarker } from './provenance.js' import { bundleRoot, extractBundle } from './bundle.js' import type { RouterHit, SearchOptions, SkillSource } from './types.js' import { checkKeywordRelevance } from './relevance.js' @@ -52,6 +53,16 @@ export interface HubClientOptions { * at a writable directory. */ readonly cacheDir?: string + /** + * Install into the shared skills directory instead of the cache. + * + * The difference is not only the path: a bundle under the cache is + * deliberately outside every scan, thrown away and re-downloaded next turn + * and never seen by another agent. One here is kept, scanned, shared, and + * carries a provenance marker so fusion knows it is the catalogue's own + * entry rather than a second skill. + */ + readonly installRoot?: string /** * Download-stats tag, not a free label: a catalog validates it against its * own fixed set and answers 422 for anything outside it. `cli` is the safe @@ -67,6 +78,7 @@ export class SkillHubClient { private readonly timeoutMs: number private readonly downloadTimeoutMs: number private readonly cacheDir: string | undefined + private readonly installRoot: string | undefined private readonly source: string constructor(endpoint: string, options: HubClientOptions = {}) { @@ -75,6 +87,7 @@ export class SkillHubClient { this.timeoutMs = options.timeoutMs ?? 2000 this.downloadTimeoutMs = options.downloadTimeoutMs ?? 30_000 this.cacheDir = options.cacheDir + this.installRoot = options.installRoot this.source = options.source ?? 'cli' } @@ -95,20 +108,67 @@ export class SkillHubClient { meta?: Record, signal?: AbortSignal, ): Promise<{ dir: string; skillMd: string }> { - if (!this.cacheDir) throw new Error('no cache directory is configured for bundles') + if (!this.cacheDir && !this.installRoot) { + throw new Error('no cache directory is configured for bundles') + } const record = meta ?? (await this.get(id, signal)) const slug = String(record.slug ?? record.skill_id ?? id).replace(/\//g, '_') const version = String(record.version ?? 'v0') - const destination = join(this.cacheDir, `${slug}@${version}`) + const skillMd = typeof record.skill_md === 'string' ? record.skill_md : '' + if (this.installRoot) { + return { dir: await this.installShared(id, slug, version, skillMd, signal), skillMd } + } + + const destination = join(this.cacheDir as string, `${slug}@${version}`) if (!existsSync(destination)) { const archive = await this.download(id, signal) await extractBundle(archive, destination) } - return { - dir: await bundleRoot(destination), - skillMd: typeof record.skill_md === 'string' ? record.skill_md : '', + return { dir: await bundleRoot(destination), skillMd } + } + + /** + * Install into the shared directory, one directory per identity. + * + * Per identity rather than per version, unlike the cache: the shared + * directory is scanned, so `x@1` beside `x@2` would put two ranked copies of + * one skill in front of the model. An update therefore replaces, and + * `swapIntoPlace` is what makes replacing safe — the previous version stays + * live until the new one is complete. + * + * A same-version reinstall is skipped, which keeps a retrieval that happens + * to hit an installed skill from rewriting it every turn. + */ + private async installShared( + id: string, + slug: string, + version: string, + skillMd: string, + signal?: AbortSignal, + ): Promise { + const root = this.installRoot as string + const destination = slugDir(root, 'hub', slug) + if (readMarker(destination)?.version === version) return bundleRoot(destination) + + const staging = `${destination}.incoming-${process.pid}-${Math.trunc(Number(process.hrtime.bigint() % 100000n))}` + try { + await extractBundle(await this.download(id, signal), staging) + const body = await bundleRoot(staging) + writeMarker(body, { + origin: identity('hub', slug), + source: 'hub', + slug, + version, + sha256: bodyDigest(skillMd), + installedAt: now(), + }) + swapIntoPlace(staging, destination) + } catch (error) { + rmSync(staging, { recursive: true, force: true }) + throw error } + return bundleRoot(destination) } /** diff --git a/skillcorpus_plugin/engine-typescript/src/index.ts b/skillcorpus_plugin/engine-typescript/src/index.ts index 8bcaec7..80e18ab 100644 --- a/skillcorpus_plugin/engine-typescript/src/index.ts +++ b/skillcorpus_plugin/engine-typescript/src/index.ts @@ -16,6 +16,7 @@ * @module @deepseek-ai/dsh-skill-search */ +import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' @@ -31,7 +32,7 @@ import type { SkillSource } from './types.js' import { LLMGateFilter } from './gate.js' import { HubSkillSource, SkillHubClient } from './hub-source.js' import { LocalSkillSource } from './local-source.js' -import { scanDirs } from './shared.js' +import { scanDirs, sharedSkillsDir } from './shared.js' import { MarketplaceClient, MarketplaceSkillSource } from './marketplace-source.js' import { QueryRewriter } from './rewriter.js' @@ -342,12 +343,33 @@ export function apply(ctx: Context, config: Config = {}): void { }) } +/** + * Where a retrieved skill is kept, or `undefined` to use the cache. + * + * The shared skills directory when the deployment opted in, created here + * rather than lazily: a directory that does not exist is not scanned, and a + * skill installed into an unscanned directory is the exact bug this feature + * exists to fix. + */ +function installRootFor(share: boolean): string | undefined { + if (!share) return undefined + try { + const root = sharedSkillsDir() + mkdirSync(root, { recursive: true }) + return root + } catch { + // Sharing is never worth a failed turn. + return undefined + } +} + function buildEngine(ctx: Context, cfg: Config): SkillSearchEngine { const sources: SkillSource[] = [] const dirs = cfg.skillsDirs ?? [] // Registers this harness's skills directory so the other four hosts can // scan it, and appends the shared directory plus whatever they registered. + const installRoot = installRootFor(cfg.shareSkills) const roots = scanDirs('deepseek-harness', dirs, cfg.shareSkills) if (roots.length > 0) { const local = new LocalSkillSource(roots, { indexBody: cfg.indexBody ?? false }) @@ -358,6 +380,7 @@ function buildEngine(ctx: Context, cfg: Config): SkillSearchEngine { let client: SkillHubClient | undefined if (cfg.hubEndpoint) { client = new SkillHubClient(cfg.hubEndpoint, { + ...(installRoot ? { installRoot } : {}), ...(cfg.hubApiKey ? { apiKey: cfg.hubApiKey } : {}), timeoutMs: cfg.hubTimeoutMs ?? 5000, // Beside the scanned directories, never inside one: an extracted @@ -376,6 +399,7 @@ function buildEngine(ctx: Context, cfg: Config): SkillSearchEngine { for (const [kind, endpoint] of [['clawhub', cfg.clawhubEndpoint], ['skillhub_cn', cfg.skillhubCnEndpoint]] as const) { if (!endpoint) continue const marketplace = new MarketplaceClient(kind, endpoint, { + ...(installRoot ? { installRoot } : {}), cacheDir: cfg.bundleCacheDir || join(homedir(), '.dsh', 'skillsearch-bundles'), timeoutMs: cfg.hubTimeoutMs ?? 5000, }) diff --git a/skillcorpus_plugin/engine-typescript/src/local-source.ts b/skillcorpus_plugin/engine-typescript/src/local-source.ts index b41157c..1052302 100644 --- a/skillcorpus_plugin/engine-typescript/src/local-source.ts +++ b/skillcorpus_plugin/engine-typescript/src/local-source.ts @@ -15,6 +15,7 @@ import { readFile, readdir } from 'node:fs/promises' import { basename, join } from 'node:path' +import { readMarker } from './provenance.js' import { BM25Okapi, tokenize } from './bm25.js' import type { RouterHit, SearchOptions, SkillSource } from './types.js' @@ -90,6 +91,10 @@ export class LocalSkillSource implements SkillSource { // to a file tool; without it a body saying `scripts/x.sh` resolves // against the agent's cwd, which is the wrong directory. skillDir: skill.dir, + // Set only for a skill this plugin installed. Fusion collapses on + // it, so the local copy and the catalogue entry it came from are + // one hit rather than two. + origin: readMarker(skill.dir)?.origin ?? '', }, })) } diff --git a/skillcorpus_plugin/engine-typescript/src/marketplace-source.ts b/skillcorpus_plugin/engine-typescript/src/marketplace-source.ts index a422f81..0e76511 100644 --- a/skillcorpus_plugin/engine-typescript/src/marketplace-source.ts +++ b/skillcorpus_plugin/engine-typescript/src/marketplace-source.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { readFile, rm } from 'node:fs/promises' import { join } from 'node:path' +import { bodyDigest, identity, now, readMarker, slugDir, swapIntoPlace, writeMarker } from './provenance.js' import { bundleRoot, extractBundle } from './bundle.js' import type { RouterHit, SearchOptions, SkillSource } from './types.js' @@ -24,15 +25,20 @@ export class MarketplaceClient { readonly kind: MarketplaceKind private readonly base: string private readonly cacheDir: string + private readonly installRoot: string | undefined private readonly timeoutMs: number private readonly downloadTimeoutMs: number constructor(kind: MarketplaceKind, endpoint: string, options: { - cacheDir: string; timeoutMs?: number; downloadTimeoutMs?: number + cacheDir: string + /** Install into the shared skills directory instead of the cache. */ + installRoot?: string + timeoutMs?: number; downloadTimeoutMs?: number }) { this.kind = kind this.base = endpoint.replace(/\/+$/, '') this.cacheDir = options.cacheDir + this.installRoot = options.installRoot this.timeoutMs = options.timeoutMs ?? 5000 this.downloadTimeoutMs = options.downloadTimeoutMs ?? 30_000 } @@ -47,6 +53,7 @@ export class MarketplaceClient { const slug = String(hit.meta.slug ?? hit.meta.id) const owner = String(hit.meta.owner ?? '') const version = String(hit.meta.version ?? 'v0') + if (this.installRoot) return this.installShared(slug, owner, version, signal) const key = `${this.kind}-${owner ? `${owner}_` : ''}${slug}@${version}`.replace(/[^A-Za-z0-9_.@-]+/g, '_') const destination = join(this.cacheDir, key) if (!existsSync(destination)) { @@ -73,6 +80,52 @@ export class MarketplaceClient { throw new Error(`read skill failed: ${errorMessage(error)}`, { cause: error }) } } + /** + * Install into the shared directory, one directory per identity. + * + * Per identity rather than per version, unlike the cache: the shared + * directory is scanned, so `x@1` beside `x@2` would put two ranked copies of + * one skill in front of the model. An update replaces, and `swapIntoPlace` + * keeps the previous version live until the new one is complete. + * + * A same-version reinstall is skipped, so a retrieval that happens to hit an + * installed skill does not rewrite it every turn. + */ + private async installShared( + slug: string, + owner: string, + version: string, + signal?: AbortSignal, + ): Promise<{ dir: string; body: string }> { + const root = this.installRoot as string + const marketplaceSlug = owner ? `${owner}_${slug}` : slug + const destination = slugDir(root, this.kind, marketplaceSlug) + + if (readMarker(destination)?.version !== version) { + const staging = `${destination}.incoming-${process.pid}-${Math.trunc(Number(process.hrtime.bigint() % 100000n))}` + try { + await extractBundle(await this.download(slug, owner, version, signal), staging) + const body = await bundleRoot(staging) + const skillMd = await readFile(join(body, 'SKILL.md'), 'utf8') + writeMarker(body, { + origin: identity(this.kind, marketplaceSlug), + source: this.kind, + slug: marketplaceSlug, + version, + sha256: bodyDigest(stripFrontmatter(skillMd)), + installedAt: now(), + }) + swapIntoPlace(staging, destination) + } catch (error) { + await rm(staging, { recursive: true, force: true }).catch(() => {}) + throw error + } + } + const dir = await bundleRoot(destination) + const skillMd = await readFile(join(dir, 'SKILL.md'), 'utf8') + return { dir, body: stripFrontmatter(skillMd) } + } + private async searchClawHub(query: string, signal: AbortSignal | undefined, limit: number) { const url = new URL(`${this.base}/api/v1/search`) @@ -150,6 +203,7 @@ export class MarketplaceClient { export class MarketplaceSkillSource implements SkillSource { readonly name: MarketplaceKind weight: number + constructor(readonly client: MarketplaceClient, options: { weight?: number } = {}) { this.name = client.kind this.weight = options.weight ?? 0.75 diff --git a/skillcorpus_plugin/engine-typescript/src/provenance.ts b/skillcorpus_plugin/engine-typescript/src/provenance.ts new file mode 100644 index 0000000..0fd4330 --- /dev/null +++ b/skillcorpus_plugin/engine-typescript/src/provenance.ts @@ -0,0 +1,266 @@ +/** + * Where an installed skill came from, recorded beside the skill itself. + * + * The counterpart of `engine-python/skillsearch/provenance.py`. Both ports + * read and write these markers in the same shared directory on one machine, + * so the marker format and the identity string are a contract, not a + * convention; `tests/parity.test.ts` pins them. + * + * Installing into the shared directory — rather than a cache excluded from + * scanning — recreates the problem the exclusion existed to avoid: the skill + * is now both a local hit (it is on disk) and a remote hit (the catalogue + * still returns it), so one skill takes two slots in the ranking. + * + * Neither existing defence catches that. Fusion collapses on `qualifiedId`, + * and `local/pdf-tables` and `hub/pdf-tables` are different ids; the exact-body + * dedup compares a digest, so a trailing newline or a bumped version misses. + * + * The fix is identity rather than coincidence. An install writes a marker + * inside the skill's own directory; the scanner reads it and carries the + * identity on the hit; fusion collapses on that. A skill installed from `hub` + * and the same skill offered by `hub` are one thing by construction. + * + * The marker doubles as the ledger. Installing puts files on a user's disk, so + * they must be able to see what is there and remove it — and one file per + * skill, inside the skill, cannot drift out of sync with the directory the way + * a central index can. + * + * Everything here fails open: an unreadable marker leaves the skill looking + * hand-written, which costs deduplication for that one skill and never a turn. + * + * @module + */ + +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Inside the skill's own directory. Dotted so a host's own scanner ignores it, + * and named for this plugin so its owner is obvious to someone browsing. + */ +export const MARKER = '.skillsearch-origin.json' + +const NOTE = 'Written by the skillsearch plugin. Delete the directory to uninstall.' + +/** What an installed skill is, and where it came from. */ +export interface Origin { + /** `/`. The identity fusion collapses on. */ + readonly origin: string + readonly source: string + readonly slug: string + readonly version: string + readonly sha256: string + readonly installedAt: string +} + +/** + * The cross-source identity of one skill. + * + * Deliberately not the `qualifiedId`: that is `/` where + * source is the *retrieval* source, so one skill reached two ways has two of + * them. This is what the skill *is*. + */ +export function identity(source: string, slug: string): string { + return `${String(source).trim()}/${String(slug).trim()}` +} + +/** + * SHA-256 of a skill body, for the ledger. + * + * Recorded so an update can say what changed. Not used for deduplication — + * that is what the identity is for, precisely because a digest misses a + * bumped version. + */ +export function bodyDigest(body: string): string { + return createHash('sha256').update(body ?? '', 'utf8').digest('hex') +} + +/** An install timestamp, UTC and second resolution. */ +export function now(clock: () => Date = () => new Date()): string { + return `${clock().toISOString().slice(0, 19)}+00:00` +} + +/** + * Where a skill from `source` lands under `root`. + * + * One directory per identity, not per version: an update replaces what is + * there rather than accumulating copies, which is what keeps the shared + * directory from growing a second ranked copy of everything. + * + * The name is sanitised because a slug comes from a catalogue and reaches the + * filesystem — anything outside the allow-list becomes `_`, so a slug of + * `../../etc` cannot escape `root`. + */ +export function slugDir(root: string, source: string, slug: string): string { + const safeSource = String(source).replace(/[^A-Za-z0-9\-_]/g, '_').slice(0, 40) + const safeSlug = String(slug).replace(/[^A-Za-z0-9\-_.@]/g, '_').slice(0, 120) + return join(root, `${safeSource}__${safeSlug || 'skill'}`) +} + +/** + * Record provenance inside an installed skill. `false` on failure. + * + * Written atomically for the same reason the bundle is: a reader walking the + * shared directory must never see half a marker. + */ +export function writeMarker(skillDir: string, origin: Origin): boolean { + const payload = { + _note: NOTE, + version: 1, + origin: origin.origin, + source: origin.source, + slug: origin.slug, + skill_version: origin.version, + sha256: origin.sha256, + installed_at: origin.installedAt, + } + let staging: string | undefined + try { + mkdirSync(skillDir, { recursive: true }) + staging = mkdtempSync(join(skillDir, '.origin-')) + const scratch = join(staging, 'marker.json') + writeFileSync(scratch, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + renameSync(scratch, join(skillDir, MARKER)) + return true + } catch { + return false + } finally { + if (staging) { + try { + rmSync(staging, { recursive: true, force: true }) + } catch { + // A scratch directory left behind is not worth a thrown error. + } + } + } +} + +/** + * Provenance for one skill, or `undefined` when it was not installed here. + * + * `undefined` is the ordinary answer, not an error: a hand-written skill has + * no marker and must keep working exactly as it did. + */ +export function readMarker(skillDir: string): Origin | undefined { + let raw: unknown + try { + raw = JSON.parse(readFileSync(join(skillDir, MARKER), 'utf8')) + } catch { + return undefined + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const record = raw as Record + const source = String(record.source ?? '').trim() + const slug = String(record.slug ?? '').trim() + if (!source || !slug) return undefined + return { + origin: String(record.origin ?? identity(source, slug)), + source, + slug, + version: String(record.skill_version ?? ''), + sha256: String(record.sha256 ?? ''), + installedAt: String(record.installed_at ?? ''), + } +} + +function directoriesIn(root: string): string[] { + try { + return readdirSync(root, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort() + } catch { + return [] + } +} + +/** + * Every skill this plugin installed under `root`, sorted by identity. + * + * A walk rather than an index read: the directory is the truth, so a skill the + * user deleted by hand is simply gone rather than a stale row nobody can + * explain. + */ +export function listInstalled(root: string): Origin[] { + const out: Origin[] = [] + for (const name of directoriesIn(root)) { + const marker = readMarker(join(root, name)) + if (marker) out.push(marker) + } + return out.sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0)) +} + +/** The directory holding an installed skill, by identity. */ +export function findInstalled(root: string, origin: string): string | undefined { + for (const name of directoriesIn(root)) { + const path = join(root, name) + if (readMarker(path)?.origin === origin) return path + } + return undefined +} + +function scratchName(path: string, kind: string): string { + return `${path}.${kind}-${process.pid}-${Math.trunc(Number(process.hrtime.bigint() % 100000n))}` +} + +/** + * Move a finished install over whatever is at `dest`. + * + * An install must never leave the user worse off than before it started, so an + * update is "build the new one, switch, then delete the old" rather than + * "delete the old, then build". A failure at any point leaves the previous + * version in place and working. + * + * `rename` alone will not do it: on POSIX renaming onto a non-empty directory + * fails, and on Windows onto any existing one. So the old copy is moved aside + * first, and moved back if the switch does not complete. + * + * @throws when the install did not happen; `dest` is untouched. + */ +export function swapIntoPlace(staging: string, dest: string): void { + let destExists = false + try { + destExists = statSync(dest).isDirectory() + } catch { + destExists = false + } + if (!destExists) { + renameSync(staging, dest) + return + } + + const retired = scratchName(dest, 'retiring') + renameSync(dest, retired) + try { + renameSync(staging, dest) + } catch (error) { + // Put the working copy back before letting the failure out. + try { + renameSync(retired, dest) + } catch { + // Nothing further to try; the original error is the one that matters. + } + throw error + } + rmSync(retired, { recursive: true, force: true }) +} + +/** + * Remove an installed skill by identity. `false` if it was not there. + * + * Moved aside and then deleted, so a half-finished delete cannot leave a + * directory the scanner still reads as a skill. + */ +export function uninstall(root: string, origin: string): boolean { + const found = findInstalled(root, origin) + if (!found) return false + const retired = scratchName(found, 'removing') + try { + renameSync(found, retired) + } catch { + return false + } + rmSync(retired, { recursive: true, force: true }) + return true +} diff --git a/skillcorpus_plugin/engine-typescript/src/shared.ts b/skillcorpus_plugin/engine-typescript/src/shared.ts index 663626e..5ca0c9c 100644 --- a/skillcorpus_plugin/engine-typescript/src/shared.ts +++ b/skillcorpus_plugin/engine-typescript/src/shared.ts @@ -347,6 +347,11 @@ export function scanDirs( for (const dir of ownDirs) add(dir, 'local') if (!share) return out + // A deployment that configured no skills directory is saying it has no + // local skills, and joining the shared library would contradict that: the + // shared directory alone would make the plugin "enabled" and start it + // scanning on a deployment that asked for none of this. + if (ownDirs.length === 0) return out try { for (const [dir, name] of sharedDirs(hostId, ownDirs[0], path, env)) add(dir, name) diff --git a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts index 8ba152a..4dee34f 100644 --- a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts +++ b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts @@ -26,6 +26,7 @@ import { resolvePlaceholders, resolveRefs } from '../src/refs.ts' import { QueryRewriter } from '../src/rewriter.ts' import { readRegistry, registerHost, registeredDirs, sharedDirs, sharedRoot } from '../src/shared.ts' import { DirectoryWatch } from '../src/watch.ts' +import * as provenance from '../src/provenance.ts' import { checkKeywordRelevance, queryTerms } from '../src/relevance.ts' import { SkillSearchEngine } from '../src/engine.ts' import { HubSkillSource, SkillHubClient } from '../src/hub-source.ts' @@ -701,3 +702,125 @@ test('a skill dropped into a watched directory is found on the next retrieval', // Exactly once: the shared directory is scanned by one source, not two. assert.equal(block.split('### Skill: pdf-tables').length - 1, 1) }) + +// --------------------------------------------------------------------------- +// installed skills: identity, the ledger, and replacing safely + +function marker(source = 'hub', slug = 'pdf-tables', version = '1.0'): provenance.Origin { + return { + origin: provenance.identity(source, slug), + source, + slug, + version, + sha256: provenance.bodyDigest('body'), + installedAt: provenance.now(), + } +} + +async function installed(root: string, source = 'hub', slug = 'pdf-tables', + version = '1.0', body = 'OCR scanned pages first.'): Promise { + const dir = provenance.slugDir(root, source, slug) + await mkdir(dir, { recursive: true }) + await writeFile( + join(dir, 'SKILL.md'), + `---\nname: ${slug}\ndescription: Extract tables from PDF documents into CSV.\n---\n\n${body}\n`, + ) + provenance.writeMarker(dir, marker(source, slug, version)) + return dir +} + +test('a marker round-trips, and its absence is not an error', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillsearch-marker-')) + const dir = join(root, 'skill') + await mkdir(dir) + const written = marker() + assert.equal(provenance.writeMarker(dir, written), true) + assert.deepEqual(provenance.readMarker(dir), written) + + // A hand-written skill has no marker and must keep working exactly. + const plain = join(root, 'handwritten') + await mkdir(plain) + assert.equal(provenance.readMarker(plain), undefined) + + for (const text of ['{not json', '[]', 'null', '{"source":"hub"}', '{"slug":"x"}']) { + const bad = await mkdtemp(join(root, 'bad-')) + await writeFile(join(bad, provenance.MARKER), text) + assert.equal(provenance.readMarker(bad), undefined, text) + } +}) + +test('a slug cannot escape the install root', async () => { + // Slugs come from a catalogue and reach the filesystem. + const root = await mkdtemp(join(tmpdir(), 'skillsearch-slug-')) + for (const slug of ['../../etc/passwd', 'a/b', '..', '~/x', '']) { + const landed = provenance.slugDir(root, 'hub', slug) + assert.equal(landed.slice(0, root.length + 1), `${root}/`, slug) + assert.equal(landed.slice(root.length + 1).includes('/'), false, slug) + } +}) + +test('fusion collapses an installed copy with the catalogue entry it came from', () => { + // The whole reason installing into a scanned directory is safe. + // `qualifiedId` cannot do this — `local/x` and `hub/x` differ — and a body + // digest cannot either, since the two rarely have identical bytes. + const origin = provenance.identity('hub', 'pdf-tables') + const local: RouterHit = { + qualifiedId: 'local/pdf-tables', name: 'pdf-tables', content: 'body', score: 1, + meta: { source: 'local', origin }, + } + const remote: RouterHit = { + qualifiedId: 'hub/pdf-tables', name: 'pdf-tables', content: '', score: 0.9, + meta: { source: 'hub', origin }, + } + + const merged = rrfMergeWeighted( + [{ name: 'local', weight: 1, hits: [local] }, { name: 'hub', weight: 1, hits: [remote] }], + 5, 'qualifiedId', + ) + assert.equal(merged.length, 1) + assert.deepEqual([...(merged[0].meta.contributingSources as string[])].sort(), ['hub', 'local']) + + // And a skill with no origin keeps the old key: every hand-written skill. + const a: RouterHit = { qualifiedId: 'local/x', name: 'x', content: '', score: 1, meta: {} } + const b: RouterHit = { qualifiedId: 'hub/x', name: 'x', content: '', score: 1, meta: {} } + assert.equal(rrfMergeWeighted( + [{ name: 'local', weight: 1, hits: [a] }, { name: 'hub', weight: 1, hits: [b] }], 5, 'qualifiedId', + ).length, 2) +}) + +test('the ledger is the directory, and uninstall removes what is there', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillsearch-ledger-')) + await installed(root, 'hub', 'pdf-tables') + await installed(root, 'clawhub', 'git-bisect') + await mkdir(join(root, 'handwritten')) + await writeFile(join(root, 'handwritten', 'SKILL.md'), '---\nname: h\n---\n') + + // Only what this plugin installed; the user's own skill is left alone. + assert.deepEqual(provenance.listInstalled(root).map(o => o.origin), + ['clawhub/git-bisect', 'hub/pdf-tables']) + + assert.equal(provenance.uninstall(root, 'hub/pdf-tables'), true) + assert.equal(provenance.uninstall(root, 'hub/pdf-tables'), false) + assert.deepEqual(provenance.listInstalled(root).map(o => o.origin), ['clawhub/git-bisect']) +}) + +test('an update replaces in place, and a failed one leaves the old version working', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillsearch-update-')) + const dest = await installed(root, 'hub', 'pdf-tables', '1.0', 'the version that works') + + const staging = join(root, 'staging') + await mkdir(staging) + await writeFile(join(staging, 'SKILL.md'), '---\nname: pdf-tables\n---\n\nnew\n') + provenance.writeMarker(staging, marker('hub', 'pdf-tables', '2.0')) + provenance.swapIntoPlace(staging, dest) + + assert.deepEqual(provenance.listInstalled(root).map(o => [o.origin, o.version]), + [['hub/pdf-tables', '2.0']]) + // One directory per identity: an update replaces rather than accumulating a + // second ranked copy of the same skill. + assert.equal(provenance.listInstalled(root).length, 1) + + await writeFile(join(dest, 'SKILL.md'), '---\nname: pdf-tables\n---\n\nthe version that works\n') + assert.throws(() => { provenance.swapIntoPlace(join(root, 'never-extracted'), dest) }) + assert.match(readFileSync(join(dest, 'SKILL.md'), 'utf8'), /the version that works/) +}) diff --git a/skillcorpus_plugin/plugin-openclaw/src/register.ts b/skillcorpus_plugin/plugin-openclaw/src/register.ts index ad48801..f6f19cc 100644 --- a/skillcorpus_plugin/plugin-openclaw/src/register.ts +++ b/skillcorpus_plugin/plugin-openclaw/src/register.ts @@ -8,6 +8,7 @@ * @module */ +import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { isAbsolute, join } from 'node:path' import { SkillSearchEngine } from '../../engine-typescript/src/engine.js' @@ -16,7 +17,7 @@ import { HubSkillSource, SkillHubClient } from '../../engine-typescript/src/hub- import { MarketplaceClient, MarketplaceSkillSource } from '../../engine-typescript/src/marketplace-source.js' import { LocalSkillSource } from '../../engine-typescript/src/local-source.js' import { QueryRewriter } from '../../engine-typescript/src/rewriter.js' -import { scanDirs } from '../../engine-typescript/src/shared.js' +import { scanDirs, sharedSkillsDir } from '../../engine-typescript/src/shared.js' import type { SkillSource } from '../../engine-typescript/src/types.js' import { loadConfig, unknownMode, type SkillSearchConfig } from './config.js' import { createChatModel } from './model.js' @@ -42,6 +43,26 @@ export function expandHome(path: string, home: string = homedir()): string { * @returns the engine, which reports `enabled: false` when nothing is * configured to search. */ +/** + * Where a retrieved skill is kept, or `undefined` to use the cache. + * + * The shared skills directory when the deployment opted in, created here + * rather than lazily: a directory that does not exist is not scanned, and a + * skill installed into an unscanned directory is the exact bug this feature + * exists to fix. + */ +function installRootFor(share: boolean): string | undefined { + if (!share) return undefined + try { + const root = sharedSkillsDir() + mkdirSync(root, { recursive: true }) + return root + } catch { + // Sharing is never worth a failed turn. + return undefined + } +} + export function buildEngine( config: SkillSearchConfig, workspaceDir?: string, @@ -53,6 +74,7 @@ export function buildEngine( // Registers this host's own directory so the other four can scan it, and // appends the shared directory plus whatever they registered. Returns // `dirs` unchanged when the deployment opted out or anything went wrong. + const installRoot = installRootFor(config.shareSkills) const roots = scanDirs('openclaw', dirs, config.shareSkills) if (roots.length > 0) { sources.push(new LocalSkillSource(roots, { indexBody: config.indexBody })) @@ -65,6 +87,7 @@ export function buildEngine( // Outside every scanned directory: an extracted bundle inside one // would be picked up as a local skill on the next scan. cacheDir: config.bundleCacheDir || join(homedir(), '.openclaw', 'skillsearch-bundles'), + ...(installRoot ? { installRoot } : {}), }) sources.push(new HubSkillSource(client)) } @@ -77,6 +100,7 @@ export function buildEngine( if (!endpoint) continue const marketplace = new MarketplaceClient(kind, endpoint, { cacheDir: config.bundleCacheDir ? expandHome(config.bundleCacheDir) : join(homedir(), '.openclaw', 'skillsearch-bundles'), + ...(installRoot ? { installRoot } : {}), }) marketplaceClients.set(kind, marketplace) sources.push(new MarketplaceSkillSource(marketplace)) diff --git a/skillcorpus_plugin/plugin-openclaw2/src/register.ts b/skillcorpus_plugin/plugin-openclaw2/src/register.ts index a78d521..6fb3a13 100644 --- a/skillcorpus_plugin/plugin-openclaw2/src/register.ts +++ b/skillcorpus_plugin/plugin-openclaw2/src/register.ts @@ -40,6 +40,7 @@ * @module */ +import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { isAbsolute, join } from 'node:path' import { SkillSearchEngine } from '../../engine-typescript/src/engine.js' @@ -48,7 +49,7 @@ import { HubSkillSource, SkillHubClient } from '../../engine-typescript/src/hub- import { MarketplaceClient, MarketplaceSkillSource } from '../../engine-typescript/src/marketplace-source.js' import { LocalSkillSource } from '../../engine-typescript/src/local-source.js' import { QueryRewriter } from '../../engine-typescript/src/rewriter.js' -import { scanDirs } from '../../engine-typescript/src/shared.js' +import { scanDirs, sharedSkillsDir } from '../../engine-typescript/src/shared.js' import type { SkillSource } from '../../engine-typescript/src/types.js' import { loadConfig, unknownMode, type SkillSearchConfig } from './config.js' import { createChatModel } from './model.js' @@ -80,6 +81,26 @@ export function expandHome(path: string, home: string = homedir()): string { * @returns the engine, which reports `enabled: false` when nothing is * configured to search. */ +/** + * Where a retrieved skill is kept, or `undefined` to use the cache. + * + * The shared skills directory when the deployment opted in, created here + * rather than lazily: a directory that does not exist is not scanned, and a + * skill installed into an unscanned directory is the exact bug this feature + * exists to fix. + */ +function installRootFor(share: boolean): string | undefined { + if (!share) return undefined + try { + const root = sharedSkillsDir() + mkdirSync(root, { recursive: true }) + return root + } catch { + // Sharing is never worth a failed turn. + return undefined + } +} + export function buildEngine( config: SkillSearchConfig, workspaceDir?: string, @@ -91,6 +112,7 @@ export function buildEngine( // Registers this host's own directory so the other four can scan it, and // appends the shared directory plus whatever they registered. Returns // `dirs` unchanged when the deployment opted out or anything went wrong. + const installRoot = installRootFor(config.shareSkills) const roots = scanDirs('openclaw2', dirs, config.shareSkills) if (roots.length > 0) { sources.push(new LocalSkillSource(roots, { indexBody: config.indexBody })) @@ -103,6 +125,7 @@ export function buildEngine( // Outside every scanned directory: an extracted bundle inside one // would be picked up as a local skill on the next scan. cacheDir: config.bundleCacheDir || join(homedir(), '.openclaw', 'skillsearch-bundles'), + ...(installRoot ? { installRoot } : {}), }) sources.push(new HubSkillSource(client)) } @@ -115,6 +138,7 @@ export function buildEngine( if (!endpoint) continue const marketplace = new MarketplaceClient(kind, endpoint, { cacheDir: config.bundleCacheDir ? expandHome(config.bundleCacheDir) : join(homedir(), '.openclaw', 'skillsearch-bundles'), + ...(installRoot ? { installRoot } : {}), }) marketplaceClients.set(kind, marketplace) sources.push(new MarketplaceSkillSource(marketplace)) diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs index 09ca692..5426cb2 100755 --- a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // src/hook.ts -import { appendFileSync, mkdirSync as mkdirSync3 } from "node:fs"; +import { appendFileSync, mkdirSync as mkdirSync5 } from "node:fs"; import { dirname as dirname3 } from "node:path"; // src/config.ts @@ -190,20 +190,26 @@ function loadConfig(document, env = process.env) { } // src/retrieve.ts +import { mkdirSync as mkdirSync4 } from "node:fs"; import { homedir as homedir3 } from "node:os"; -import { join as join10 } from "node:path"; +import { join as join11 } from "node:path"; // ../engine-typescript/src/engine.ts import { createHash } from "node:crypto"; // ../engine-typescript/src/fusion.ts var RRF_K = 60; +function collapseKey(hit, dedupBy) { + const origin = hit.meta?.origin; + if (typeof origin === "string" && origin.trim()) return origin.trim(); + return hit[dedupBy]; +} function rrfMergeWeighted(sourceResults, k, dedupBy = "name", rrfK = RRF_K) { const merged = /* @__PURE__ */ new Map(); for (const { name: sourceName2, weight, hits } of sourceResults) { for (const [i, hit] of hits.entries()) { const rank = i + 1; - const key = hit[dedupBy]; + const key = collapseKey(hit, dedupBy); const contribution = weight / (rrfK + rank); const seen = merged.get(key); if (seen === void 0) { @@ -922,13 +928,113 @@ function errorMessage(error) { } // ../engine-typescript/src/hub-source.ts -import { existsSync as existsSync2 } from "node:fs"; -import { join as join5 } from "node:path"; +import { existsSync as existsSync2, rmSync as rmSync2 } from "node:fs"; +import { join as join6 } from "node:path"; + +// ../engine-typescript/src/provenance.ts +import { createHash as createHash2 } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; +import { join as join4 } from "node:path"; +var MARKER = ".skillsearch-origin.json"; +var NOTE = "Written by the skillsearch plugin. Delete the directory to uninstall."; +function identity(source, slug) { + return `${String(source).trim()}/${String(slug).trim()}`; +} +function bodyDigest(body) { + return createHash2("sha256").update(body ?? "", "utf8").digest("hex"); +} +function now(clock = () => /* @__PURE__ */ new Date()) { + return `${clock().toISOString().slice(0, 19)}+00:00`; +} +function slugDir(root, source, slug) { + const safeSource = String(source).replace(/[^A-Za-z0-9\-_]/g, "_").slice(0, 40); + const safeSlug = String(slug).replace(/[^A-Za-z0-9\-_.@]/g, "_").slice(0, 120); + return join4(root, `${safeSource}__${safeSlug || "skill"}`); +} +function writeMarker(skillDir, origin) { + const payload = { + _note: NOTE, + version: 1, + origin: origin.origin, + source: origin.source, + slug: origin.slug, + skill_version: origin.version, + sha256: origin.sha256, + installed_at: origin.installedAt + }; + let staging; + try { + mkdirSync(skillDir, { recursive: true }); + staging = mkdtempSync(join4(skillDir, ".origin-")); + const scratch = join4(staging, "marker.json"); + writeFileSync(scratch, `${JSON.stringify(payload, null, 2)} +`, "utf8"); + renameSync(scratch, join4(skillDir, MARKER)); + return true; + } catch { + return false; + } finally { + if (staging) { + try { + rmSync(staging, { recursive: true, force: true }); + } catch { + } + } + } +} +function readMarker(skillDir) { + let raw; + try { + raw = JSON.parse(readFileSync2(join4(skillDir, MARKER), "utf8")); + } catch { + return void 0; + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0; + const record = raw; + const source = String(record.source ?? "").trim(); + const slug = String(record.slug ?? "").trim(); + if (!source || !slug) return void 0; + return { + origin: String(record.origin ?? identity(source, slug)), + source, + slug, + version: String(record.skill_version ?? ""), + sha256: String(record.sha256 ?? ""), + installedAt: String(record.installed_at ?? "") + }; +} +function scratchName(path, kind) { + return `${path}.${kind}-${process.pid}-${Math.trunc(Number(process.hrtime.bigint() % 100000n))}`; +} +function swapIntoPlace(staging, dest) { + let destExists = false; + try { + destExists = statSync3(dest).isDirectory(); + } catch { + destExists = false; + } + if (!destExists) { + renameSync(staging, dest); + return; + } + const retired = scratchName(dest, "retiring"); + renameSync(dest, retired); + try { + renameSync(staging, dest); + } catch (error) { + try { + renameSync(retired, dest); + } catch { + } + throw error; + } + rmSync(retired, { recursive: true, force: true }); +} // ../engine-typescript/src/bundle.ts import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import { readdir } from "node:fs/promises"; -import { isAbsolute, join as join4, relative, resolve } from "node:path"; +import { isAbsolute, join as join5, relative, resolve } from "node:path"; // ../engine-typescript/src/zip.ts import { inflateRawSync } from "node:zlib"; @@ -1066,7 +1172,7 @@ async function extractBundle(archive, destination) { } const data = entry.read(); total += data.length; - await mkdir(join4(target, ".."), { recursive: true }); + await mkdir(join5(target, ".."), { recursive: true }); await writeFile(target, data); } try { @@ -1092,7 +1198,7 @@ async function bundleRoot(destination) { } const visible = entries.filter((entry) => !entry.name.startsWith(".")); const only = visible[0]; - if (visible.length === 1 && only?.isDirectory()) return join4(destination, only.name); + if (visible.length === 1 && only?.isDirectory()) return join5(destination, only.name); return destination; } @@ -1226,6 +1332,7 @@ var SkillHubClient = class { timeoutMs; downloadTimeoutMs; cacheDir; + installRoot; source; constructor(endpoint, options = {}) { this.base = endpoint.replace(/\/+$/, ""); @@ -1233,6 +1340,7 @@ var SkillHubClient = class { this.timeoutMs = options.timeoutMs ?? 2e3; this.downloadTimeoutMs = options.downloadTimeoutMs ?? 3e4; this.cacheDir = options.cacheDir; + this.installRoot = options.installRoot; this.source = options.source ?? "cli"; } /** @@ -1248,19 +1356,57 @@ var SkillHubClient = class { * unusable. The caller keeps the unresolved body either way. */ async install(id, meta, signal) { - if (!this.cacheDir) throw new Error("no cache directory is configured for bundles"); + if (!this.cacheDir && !this.installRoot) { + throw new Error("no cache directory is configured for bundles"); + } const record = meta ?? await this.get(id, signal); const slug = String(record.slug ?? record.skill_id ?? id).replace(/\//g, "_"); const version = String(record.version ?? "v0"); - const destination = join5(this.cacheDir, `${slug}@${version}`); + const skillMd = typeof record.skill_md === "string" ? record.skill_md : ""; + if (this.installRoot) { + return { dir: await this.installShared(id, slug, version, skillMd, signal), skillMd }; + } + const destination = join6(this.cacheDir, `${slug}@${version}`); if (!existsSync2(destination)) { const archive = await this.download(id, signal); await extractBundle(archive, destination); } - return { - dir: await bundleRoot(destination), - skillMd: typeof record.skill_md === "string" ? record.skill_md : "" - }; + return { dir: await bundleRoot(destination), skillMd }; + } + /** + * Install into the shared directory, one directory per identity. + * + * Per identity rather than per version, unlike the cache: the shared + * directory is scanned, so `x@1` beside `x@2` would put two ranked copies of + * one skill in front of the model. An update therefore replaces, and + * `swapIntoPlace` is what makes replacing safe — the previous version stays + * live until the new one is complete. + * + * A same-version reinstall is skipped, which keeps a retrieval that happens + * to hit an installed skill from rewriting it every turn. + */ + async installShared(id, slug, version, skillMd, signal) { + const root = this.installRoot; + const destination = slugDir(root, "hub", slug); + if (readMarker(destination)?.version === version) return bundleRoot(destination); + const staging = `${destination}.incoming-${process.pid}-${Math.trunc(Number(process.hrtime.bigint() % 100000n))}`; + try { + await extractBundle(await this.download(id, signal), staging); + const body = await bundleRoot(staging); + writeMarker(body, { + origin: identity("hub", slug), + source: "hub", + slug, + version, + sha256: bodyDigest(skillMd), + installedAt: now() + }); + swapIntoPlace(staging, destination); + } catch (error) { + rmSync2(staging, { recursive: true, force: true }); + throw error; + } + return bundleRoot(destination); } /** * Fetch one bundle's bytes. @@ -1405,17 +1551,19 @@ function randomId() { // ../engine-typescript/src/marketplace-source.ts import { existsSync as existsSync3 } from "node:fs"; import { readFile, rm as rm2 } from "node:fs/promises"; -import { join as join6 } from "node:path"; +import { join as join7 } from "node:path"; var MarketplaceClient = class { kind; base; cacheDir; + installRoot; timeoutMs; downloadTimeoutMs; constructor(kind, endpoint, options) { this.kind = kind; this.base = endpoint.replace(/\/+$/, ""); this.cacheDir = options.cacheDir; + this.installRoot = options.installRoot; this.timeoutMs = options.timeoutMs ?? 5e3; this.downloadTimeoutMs = options.downloadTimeoutMs ?? 3e4; } @@ -1426,8 +1574,9 @@ var MarketplaceClient = class { const slug = String(hit.meta.slug ?? hit.meta.id); const owner = String(hit.meta.owner ?? ""); const version = String(hit.meta.version ?? "v0"); + if (this.installRoot) return this.installShared(slug, owner, version, signal); const key = `${this.kind}-${owner ? `${owner}_` : ""}${slug}@${version}`.replace(/[^A-Za-z0-9_.@-]+/g, "_"); - const destination = join6(this.cacheDir, key); + const destination = join7(this.cacheDir, key); if (!existsSync3(destination)) { let archive; try { @@ -1443,7 +1592,7 @@ var MarketplaceClient = class { } try { const dir = await bundleRoot(destination); - const skillMd = await readFile(join6(dir, "SKILL.md"), "utf8"); + const skillMd = await readFile(join7(dir, "SKILL.md"), "utf8"); return { dir, body: stripFrontmatter(skillMd) }; } catch (error) { await rm2(destination, { recursive: true, force: true }).catch(() => { @@ -1451,6 +1600,46 @@ var MarketplaceClient = class { throw new Error(`read skill failed: ${errorMessage2(error)}`, { cause: error }); } } + /** + * Install into the shared directory, one directory per identity. + * + * Per identity rather than per version, unlike the cache: the shared + * directory is scanned, so `x@1` beside `x@2` would put two ranked copies of + * one skill in front of the model. An update replaces, and `swapIntoPlace` + * keeps the previous version live until the new one is complete. + * + * A same-version reinstall is skipped, so a retrieval that happens to hit an + * installed skill does not rewrite it every turn. + */ + async installShared(slug, owner, version, signal) { + const root = this.installRoot; + const marketplaceSlug = owner ? `${owner}_${slug}` : slug; + const destination = slugDir(root, this.kind, marketplaceSlug); + if (readMarker(destination)?.version !== version) { + const staging = `${destination}.incoming-${process.pid}-${Math.trunc(Number(process.hrtime.bigint() % 100000n))}`; + try { + await extractBundle(await this.download(slug, owner, version, signal), staging); + const body = await bundleRoot(staging); + const skillMd2 = await readFile(join7(body, "SKILL.md"), "utf8"); + writeMarker(body, { + origin: identity(this.kind, marketplaceSlug), + source: this.kind, + slug: marketplaceSlug, + version, + sha256: bodyDigest(stripFrontmatter(skillMd2)), + installedAt: now() + }); + swapIntoPlace(staging, destination); + } catch (error) { + await rm2(staging, { recursive: true, force: true }).catch(() => { + }); + throw error; + } + } + const dir = await bundleRoot(destination); + const skillMd = await readFile(join7(dir, "SKILL.md"), "utf8"); + return { dir, body: stripFrontmatter(skillMd) }; + } async searchClawHub(query, signal, limit) { const url = new URL(`${this.base}/api/v1/search`); url.searchParams.set("q", query); @@ -1572,30 +1761,30 @@ function errorMessage2(error) { } // ../engine-typescript/src/shared.ts -import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; +import { mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync3, statSync as statSync4, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir as homedir2 } from "node:os"; -import { isAbsolute as isAbsolute2, join as join7, resolve as resolve2 } from "node:path"; +import { isAbsolute as isAbsolute2, join as join8, resolve as resolve2 } from "node:path"; var HOME_ENV = "SKILLSEARCH_HOME"; -var NOTE = "To exclude a directory, set its `enabled` to false. Deleting the line does not work \u2014 that agent re-registers it on its next start."; +var NOTE2 = "To exclude a directory, set its `enabled` to false. Deleting the line does not work \u2014 that agent re-registers it on its next start."; function expandHome(path, home = homedir2()) { if (path === "~") return home; - if (path.startsWith("~/")) return join7(home, path.slice(2)); + if (path.startsWith("~/")) return join8(home, path.slice(2)); return path; } function sharedRoot(env = process.env) { const override = (env[HOME_ENV] ?? "").trim(); if (override) return expandHome(override); - return join7(homedir2(), ".evermind-skillsearch"); + return join8(homedir2(), ".evermind-skillsearch"); } function sharedSkillsDir(env = process.env) { - return join7(sharedRoot(env), "skills"); + return join8(sharedRoot(env), "skills"); } function registryPath(env = process.env) { - return join7(sharedRoot(env), "registry.json"); + return join8(sharedRoot(env), "registry.json"); } function isDirectory2(path) { try { - return statSync3(path).isDirectory(); + return statSync4(path).isDirectory(); } catch { return false; } @@ -1604,7 +1793,7 @@ function readRegistry(path, env = process.env) { const target = path ?? registryPath(env); let raw; try { - raw = JSON.parse(readFileSync2(target, "utf8")); + raw = JSON.parse(readFileSync3(target, "utf8")); } catch { return []; } @@ -1626,25 +1815,25 @@ function readRegistry(path, env = process.env) { } function writeRegistry(entries, path) { const payload = { - _note: NOTE, + _note: NOTE2, hosts: entries.map((entry) => ({ id: entry.id, dir: entry.dir, enabled: entry.enabled })) }; let staging; try { const parent = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))); - mkdirSync(parent, { recursive: true }); - staging = mkdtempSync(join7(parent, ".registry-")); - const scratch = join7(staging, "registry.json"); - writeFileSync(scratch, `${JSON.stringify(payload, null, 2)} + mkdirSync2(parent, { recursive: true }); + staging = mkdtempSync2(join8(parent, ".registry-")); + const scratch = join8(staging, "registry.json"); + writeFileSync2(scratch, `${JSON.stringify(payload, null, 2)} `, "utf8"); - renameSync(scratch, path); + renameSync2(scratch, path); return true; } catch { return false; } finally { if (staging) { try { - rmSync(staging, { recursive: true, force: true }); + rmSync3(staging, { recursive: true, force: true }); } catch { } } @@ -1716,6 +1905,7 @@ function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { }; for (const dir of ownDirs) add(dir, "local"); if (!share) return out; + if (ownDirs.length === 0) return out; try { for (const [dir, name] of sharedDirs(hostId, ownDirs[0], path, env)) add(dir, name); } catch { @@ -1724,12 +1914,12 @@ function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { } // src/cached-local-source.ts -import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync as renameSync2, statSync as statSync4, writeFileSync as writeFileSync2 } from "node:fs"; -import { dirname as dirname2, join as join9 } from "node:path"; +import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync3, renameSync as renameSync3, statSync as statSync5, writeFileSync as writeFileSync3 } from "node:fs"; +import { dirname as dirname2, join as join10 } from "node:path"; // ../engine-typescript/src/local-source.ts import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises"; -import { basename, join as join8 } from "node:path"; +import { basename, join as join9 } from "node:path"; // ../engine-typescript/src/bm25.ts var TOKEN_RE = /[a-z0-9]{2,}|[一-鿿]+/g; @@ -1854,7 +2044,11 @@ var LocalSkillSource = class { // The renderer turns this into an absolute path the model can hand // to a file tool; without it a body saying `scripts/x.sh` resolves // against the agent's cwd, which is the wrong directory. - skillDir: skill.dir + skillDir: skill.dir, + // Set only for a skill this plugin installed. Fusion collapses on + // it, so the local copy and the catalogue entry it came from are + // one hit rather than two. + origin: readMarker(skill.dir)?.origin ?? "" } })); } @@ -1918,9 +2112,9 @@ async function* walk(root, maxDepth) { } for (const entry of entries) { if (entry.isDirectory()) { - if (!SKIP_DIRS2.has(entry.name)) stack.push({ dir: join8(dir, entry.name), depth: depth + 1 }); + if (!SKIP_DIRS2.has(entry.name)) stack.push({ dir: join9(dir, entry.name), depth: depth + 1 }); } else if (entry.name === SKILL_FILE2) { - yield join8(dir, entry.name); + yield join9(dir, entry.name); } } } @@ -1976,7 +2170,7 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } read() { try { - const parsed = JSON.parse(readFileSync3(this.cachePath, "utf8")); + const parsed = JSON.parse(readFileSync4(this.cachePath, "utf8")); if (!parsed || typeof parsed !== "object") return void 0; const file = parsed; if (file.version !== 1 || typeof file.fingerprint !== "string") return void 0; @@ -1987,10 +2181,10 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } write(file) { try { - mkdirSync2(dirname2(this.cachePath), { recursive: true }); + mkdirSync3(dirname2(this.cachePath), { recursive: true }); const temp = `${this.cachePath}.${process.pid}.tmp`; - writeFileSync2(temp, JSON.stringify(file)); - renameSync2(temp, this.cachePath); + writeFileSync3(temp, JSON.stringify(file)); + renameSync3(temp, this.cachePath); } catch { } } @@ -1999,17 +2193,17 @@ function collect2(dir, depth, out) { if (depth < 0) return; let entries; try { - entries = readdirSync2(dir, { withFileTypes: true }); + entries = readdirSync3(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (SKIP_DIRS3.has(entry.name)) continue; - const path = join9(dir, entry.name); + const path = join10(dir, entry.name); if (entry.isDirectory()) collect2(path, depth - 1, out); else if (entry.name === SKILL_FILE3) { try { - out.push(`${path}:${statSync4(path).mtimeMs}`); + out.push(`${path}:${statSync5(path).mtimeMs}`); } catch { } } @@ -2054,12 +2248,23 @@ function createChatModel(options) { // src/retrieve.ts function expandHome2(path, home = homedir3()) { if (path === "~") return home; - if (path.startsWith("~/")) return join10(home, path.slice(2)); + if (path.startsWith("~/")) return join11(home, path.slice(2)); return path; } +function installRootFor(share) { + if (!share) return void 0; + try { + const root = sharedSkillsDir(); + mkdirSync4(root, { recursive: true }); + return root; + } catch { + return void 0; + } +} function buildEngine(config, onDiagnostic, workspaceDir) { const sources = []; const dirs = config.skillsDirs.map((dir) => expandHome2(dir)).filter(Boolean); + const installRoot = installRootFor(config.shareSkills); const roots = scanDirs("workbuddy", dirs, config.shareSkills); if (roots.length > 0) { const local = new CachedLocalSkillSource( @@ -2072,11 +2277,12 @@ function buildEngine(config, onDiagnostic, workspaceDir) { let client; if (config.hubEndpoint) { client = new SkillHubClient(config.hubEndpoint, { + ...installRoot ? { installRoot } : {}, ...config.hubApiKey ? { apiKey: config.hubApiKey } : {}, // Outside every scanned directory. `~/.workbuddy-ai/plugins/cache` is // one of the defaults, so a bundle extracted under it would come back // as a local skill on the next scan. - cacheDir: expandHome2(config.bundleCacheDir) || join10(homedir3(), ".workbuddy-ai", "skillsearch-bundles") + cacheDir: expandHome2(config.bundleCacheDir) || join11(homedir3(), ".workbuddy-ai", "skillsearch-bundles") }); const hub = new HubSkillSource(client); hub.weight = config.hubWeight; @@ -2089,7 +2295,8 @@ function buildEngine(config, onDiagnostic, workspaceDir) { ]) { if (!endpoint) continue; const marketplace = new MarketplaceClient(kind, endpoint, { - cacheDir: expandHome2(config.bundleCacheDir) || join10(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), + ...installRoot ? { installRoot } : {}, + cacheDir: expandHome2(config.bundleCacheDir) || join11(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), // ClawHub measured 4–5s on the supported route. Give search headroom, // but leave time under the hook's global deadline for body hydration. timeoutMs: Math.max(1, Math.min(config.timeoutMs, 6500)), @@ -2150,7 +2357,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // falling back to the hook process's cwd when the payload reports none. outputDir: workspaceDir || process.cwd(), homeDir: homedir3(), - stateDir: join10(homedir3(), ".workbuddy-ai"), + stateDir: join11(homedir3(), ".workbuddy-ai"), resolvePlaceholders: config.resolvePlaceholders } ); @@ -2203,7 +2410,7 @@ function resultFor(block) { function log(config, entry) { if (!config.logPath) return; try { - mkdirSync3(dirname3(config.logPath), { recursive: true }); + mkdirSync5(dirname3(config.logPath), { recursive: true }); appendFileSync(config.logPath, `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry })} `); } catch { diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs index e4fe0ab..f8aab6b 100644 --- a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs @@ -181,20 +181,26 @@ function loadConfig(document, env = process.env) { } // src/retrieve.ts +import { mkdirSync as mkdirSync4 } from "node:fs"; import { homedir as homedir3 } from "node:os"; -import { join as join10 } from "node:path"; +import { join as join11 } from "node:path"; // ../engine-typescript/src/engine.ts import { createHash } from "node:crypto"; // ../engine-typescript/src/fusion.ts var RRF_K = 60; +function collapseKey(hit, dedupBy) { + const origin = hit.meta?.origin; + if (typeof origin === "string" && origin.trim()) return origin.trim(); + return hit[dedupBy]; +} function rrfMergeWeighted(sourceResults, k, dedupBy = "name", rrfK = RRF_K) { const merged = /* @__PURE__ */ new Map(); for (const { name: sourceName2, weight, hits } of sourceResults) { for (const [i, hit] of hits.entries()) { const rank = i + 1; - const key = hit[dedupBy]; + const key = collapseKey(hit, dedupBy); const contribution = weight / (rrfK + rank); const seen = merged.get(key); if (seen === void 0) { @@ -913,13 +919,113 @@ function errorMessage(error) { } // ../engine-typescript/src/hub-source.ts -import { existsSync as existsSync2 } from "node:fs"; -import { join as join5 } from "node:path"; +import { existsSync as existsSync2, rmSync as rmSync2 } from "node:fs"; +import { join as join6 } from "node:path"; + +// ../engine-typescript/src/provenance.ts +import { createHash as createHash2 } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; +import { join as join4 } from "node:path"; +var MARKER = ".skillsearch-origin.json"; +var NOTE = "Written by the skillsearch plugin. Delete the directory to uninstall."; +function identity(source, slug) { + return `${String(source).trim()}/${String(slug).trim()}`; +} +function bodyDigest(body) { + return createHash2("sha256").update(body ?? "", "utf8").digest("hex"); +} +function now(clock = () => /* @__PURE__ */ new Date()) { + return `${clock().toISOString().slice(0, 19)}+00:00`; +} +function slugDir(root, source, slug) { + const safeSource = String(source).replace(/[^A-Za-z0-9\-_]/g, "_").slice(0, 40); + const safeSlug = String(slug).replace(/[^A-Za-z0-9\-_.@]/g, "_").slice(0, 120); + return join4(root, `${safeSource}__${safeSlug || "skill"}`); +} +function writeMarker(skillDir, origin) { + const payload = { + _note: NOTE, + version: 1, + origin: origin.origin, + source: origin.source, + slug: origin.slug, + skill_version: origin.version, + sha256: origin.sha256, + installed_at: origin.installedAt + }; + let staging; + try { + mkdirSync(skillDir, { recursive: true }); + staging = mkdtempSync(join4(skillDir, ".origin-")); + const scratch = join4(staging, "marker.json"); + writeFileSync(scratch, `${JSON.stringify(payload, null, 2)} +`, "utf8"); + renameSync(scratch, join4(skillDir, MARKER)); + return true; + } catch { + return false; + } finally { + if (staging) { + try { + rmSync(staging, { recursive: true, force: true }); + } catch { + } + } + } +} +function readMarker(skillDir) { + let raw; + try { + raw = JSON.parse(readFileSync2(join4(skillDir, MARKER), "utf8")); + } catch { + return void 0; + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0; + const record = raw; + const source = String(record.source ?? "").trim(); + const slug = String(record.slug ?? "").trim(); + if (!source || !slug) return void 0; + return { + origin: String(record.origin ?? identity(source, slug)), + source, + slug, + version: String(record.skill_version ?? ""), + sha256: String(record.sha256 ?? ""), + installedAt: String(record.installed_at ?? "") + }; +} +function scratchName(path, kind) { + return `${path}.${kind}-${process.pid}-${Math.trunc(Number(process.hrtime.bigint() % 100000n))}`; +} +function swapIntoPlace(staging, dest) { + let destExists = false; + try { + destExists = statSync3(dest).isDirectory(); + } catch { + destExists = false; + } + if (!destExists) { + renameSync(staging, dest); + return; + } + const retired = scratchName(dest, "retiring"); + renameSync(dest, retired); + try { + renameSync(staging, dest); + } catch (error) { + try { + renameSync(retired, dest); + } catch { + } + throw error; + } + rmSync(retired, { recursive: true, force: true }); +} // ../engine-typescript/src/bundle.ts import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import { readdir } from "node:fs/promises"; -import { isAbsolute, join as join4, relative, resolve } from "node:path"; +import { isAbsolute, join as join5, relative, resolve } from "node:path"; // ../engine-typescript/src/zip.ts import { inflateRawSync } from "node:zlib"; @@ -1057,7 +1163,7 @@ async function extractBundle(archive, destination) { } const data = entry.read(); total += data.length; - await mkdir(join4(target, ".."), { recursive: true }); + await mkdir(join5(target, ".."), { recursive: true }); await writeFile(target, data); } try { @@ -1083,7 +1189,7 @@ async function bundleRoot(destination) { } const visible = entries.filter((entry) => !entry.name.startsWith(".")); const only = visible[0]; - if (visible.length === 1 && only?.isDirectory()) return join4(destination, only.name); + if (visible.length === 1 && only?.isDirectory()) return join5(destination, only.name); return destination; } @@ -1217,6 +1323,7 @@ var SkillHubClient = class { timeoutMs; downloadTimeoutMs; cacheDir; + installRoot; source; constructor(endpoint, options = {}) { this.base = endpoint.replace(/\/+$/, ""); @@ -1224,6 +1331,7 @@ var SkillHubClient = class { this.timeoutMs = options.timeoutMs ?? 2e3; this.downloadTimeoutMs = options.downloadTimeoutMs ?? 3e4; this.cacheDir = options.cacheDir; + this.installRoot = options.installRoot; this.source = options.source ?? "cli"; } /** @@ -1239,19 +1347,57 @@ var SkillHubClient = class { * unusable. The caller keeps the unresolved body either way. */ async install(id, meta, signal) { - if (!this.cacheDir) throw new Error("no cache directory is configured for bundles"); + if (!this.cacheDir && !this.installRoot) { + throw new Error("no cache directory is configured for bundles"); + } const record = meta ?? await this.get(id, signal); const slug = String(record.slug ?? record.skill_id ?? id).replace(/\//g, "_"); const version = String(record.version ?? "v0"); - const destination = join5(this.cacheDir, `${slug}@${version}`); + const skillMd = typeof record.skill_md === "string" ? record.skill_md : ""; + if (this.installRoot) { + return { dir: await this.installShared(id, slug, version, skillMd, signal), skillMd }; + } + const destination = join6(this.cacheDir, `${slug}@${version}`); if (!existsSync2(destination)) { const archive = await this.download(id, signal); await extractBundle(archive, destination); } - return { - dir: await bundleRoot(destination), - skillMd: typeof record.skill_md === "string" ? record.skill_md : "" - }; + return { dir: await bundleRoot(destination), skillMd }; + } + /** + * Install into the shared directory, one directory per identity. + * + * Per identity rather than per version, unlike the cache: the shared + * directory is scanned, so `x@1` beside `x@2` would put two ranked copies of + * one skill in front of the model. An update therefore replaces, and + * `swapIntoPlace` is what makes replacing safe — the previous version stays + * live until the new one is complete. + * + * A same-version reinstall is skipped, which keeps a retrieval that happens + * to hit an installed skill from rewriting it every turn. + */ + async installShared(id, slug, version, skillMd, signal) { + const root = this.installRoot; + const destination = slugDir(root, "hub", slug); + if (readMarker(destination)?.version === version) return bundleRoot(destination); + const staging = `${destination}.incoming-${process.pid}-${Math.trunc(Number(process.hrtime.bigint() % 100000n))}`; + try { + await extractBundle(await this.download(id, signal), staging); + const body = await bundleRoot(staging); + writeMarker(body, { + origin: identity("hub", slug), + source: "hub", + slug, + version, + sha256: bodyDigest(skillMd), + installedAt: now() + }); + swapIntoPlace(staging, destination); + } catch (error) { + rmSync2(staging, { recursive: true, force: true }); + throw error; + } + return bundleRoot(destination); } /** * Fetch one bundle's bytes. @@ -1396,17 +1542,19 @@ function randomId() { // ../engine-typescript/src/marketplace-source.ts import { existsSync as existsSync3 } from "node:fs"; import { readFile, rm as rm2 } from "node:fs/promises"; -import { join as join6 } from "node:path"; +import { join as join7 } from "node:path"; var MarketplaceClient = class { kind; base; cacheDir; + installRoot; timeoutMs; downloadTimeoutMs; constructor(kind, endpoint, options) { this.kind = kind; this.base = endpoint.replace(/\/+$/, ""); this.cacheDir = options.cacheDir; + this.installRoot = options.installRoot; this.timeoutMs = options.timeoutMs ?? 5e3; this.downloadTimeoutMs = options.downloadTimeoutMs ?? 3e4; } @@ -1417,8 +1565,9 @@ var MarketplaceClient = class { const slug = String(hit.meta.slug ?? hit.meta.id); const owner = String(hit.meta.owner ?? ""); const version = String(hit.meta.version ?? "v0"); + if (this.installRoot) return this.installShared(slug, owner, version, signal); const key = `${this.kind}-${owner ? `${owner}_` : ""}${slug}@${version}`.replace(/[^A-Za-z0-9_.@-]+/g, "_"); - const destination = join6(this.cacheDir, key); + const destination = join7(this.cacheDir, key); if (!existsSync3(destination)) { let archive; try { @@ -1434,7 +1583,7 @@ var MarketplaceClient = class { } try { const dir = await bundleRoot(destination); - const skillMd = await readFile(join6(dir, "SKILL.md"), "utf8"); + const skillMd = await readFile(join7(dir, "SKILL.md"), "utf8"); return { dir, body: stripFrontmatter(skillMd) }; } catch (error) { await rm2(destination, { recursive: true, force: true }).catch(() => { @@ -1442,6 +1591,46 @@ var MarketplaceClient = class { throw new Error(`read skill failed: ${errorMessage2(error)}`, { cause: error }); } } + /** + * Install into the shared directory, one directory per identity. + * + * Per identity rather than per version, unlike the cache: the shared + * directory is scanned, so `x@1` beside `x@2` would put two ranked copies of + * one skill in front of the model. An update replaces, and `swapIntoPlace` + * keeps the previous version live until the new one is complete. + * + * A same-version reinstall is skipped, so a retrieval that happens to hit an + * installed skill does not rewrite it every turn. + */ + async installShared(slug, owner, version, signal) { + const root = this.installRoot; + const marketplaceSlug = owner ? `${owner}_${slug}` : slug; + const destination = slugDir(root, this.kind, marketplaceSlug); + if (readMarker(destination)?.version !== version) { + const staging = `${destination}.incoming-${process.pid}-${Math.trunc(Number(process.hrtime.bigint() % 100000n))}`; + try { + await extractBundle(await this.download(slug, owner, version, signal), staging); + const body = await bundleRoot(staging); + const skillMd2 = await readFile(join7(body, "SKILL.md"), "utf8"); + writeMarker(body, { + origin: identity(this.kind, marketplaceSlug), + source: this.kind, + slug: marketplaceSlug, + version, + sha256: bodyDigest(stripFrontmatter(skillMd2)), + installedAt: now() + }); + swapIntoPlace(staging, destination); + } catch (error) { + await rm2(staging, { recursive: true, force: true }).catch(() => { + }); + throw error; + } + } + const dir = await bundleRoot(destination); + const skillMd = await readFile(join7(dir, "SKILL.md"), "utf8"); + return { dir, body: stripFrontmatter(skillMd) }; + } async searchClawHub(query, signal, limit) { const url = new URL(`${this.base}/api/v1/search`); url.searchParams.set("q", query); @@ -1563,30 +1752,30 @@ function errorMessage2(error) { } // ../engine-typescript/src/shared.ts -import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; +import { mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync3, statSync as statSync4, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir as homedir2 } from "node:os"; -import { isAbsolute as isAbsolute2, join as join7, resolve as resolve2 } from "node:path"; +import { isAbsolute as isAbsolute2, join as join8, resolve as resolve2 } from "node:path"; var HOME_ENV = "SKILLSEARCH_HOME"; -var NOTE = "To exclude a directory, set its `enabled` to false. Deleting the line does not work \u2014 that agent re-registers it on its next start."; +var NOTE2 = "To exclude a directory, set its `enabled` to false. Deleting the line does not work \u2014 that agent re-registers it on its next start."; function expandHome(path, home = homedir2()) { if (path === "~") return home; - if (path.startsWith("~/")) return join7(home, path.slice(2)); + if (path.startsWith("~/")) return join8(home, path.slice(2)); return path; } function sharedRoot(env = process.env) { const override = (env[HOME_ENV] ?? "").trim(); if (override) return expandHome(override); - return join7(homedir2(), ".evermind-skillsearch"); + return join8(homedir2(), ".evermind-skillsearch"); } function sharedSkillsDir(env = process.env) { - return join7(sharedRoot(env), "skills"); + return join8(sharedRoot(env), "skills"); } function registryPath(env = process.env) { - return join7(sharedRoot(env), "registry.json"); + return join8(sharedRoot(env), "registry.json"); } function isDirectory2(path) { try { - return statSync3(path).isDirectory(); + return statSync4(path).isDirectory(); } catch { return false; } @@ -1595,7 +1784,7 @@ function readRegistry(path, env = process.env) { const target = path ?? registryPath(env); let raw; try { - raw = JSON.parse(readFileSync2(target, "utf8")); + raw = JSON.parse(readFileSync3(target, "utf8")); } catch { return []; } @@ -1617,25 +1806,25 @@ function readRegistry(path, env = process.env) { } function writeRegistry(entries, path) { const payload = { - _note: NOTE, + _note: NOTE2, hosts: entries.map((entry) => ({ id: entry.id, dir: entry.dir, enabled: entry.enabled })) }; let staging; try { const parent = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))); - mkdirSync(parent, { recursive: true }); - staging = mkdtempSync(join7(parent, ".registry-")); - const scratch = join7(staging, "registry.json"); - writeFileSync(scratch, `${JSON.stringify(payload, null, 2)} + mkdirSync2(parent, { recursive: true }); + staging = mkdtempSync2(join8(parent, ".registry-")); + const scratch = join8(staging, "registry.json"); + writeFileSync2(scratch, `${JSON.stringify(payload, null, 2)} `, "utf8"); - renameSync(scratch, path); + renameSync2(scratch, path); return true; } catch { return false; } finally { if (staging) { try { - rmSync(staging, { recursive: true, force: true }); + rmSync3(staging, { recursive: true, force: true }); } catch { } } @@ -1707,6 +1896,7 @@ function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { }; for (const dir of ownDirs) add(dir, "local"); if (!share) return out; + if (ownDirs.length === 0) return out; try { for (const [dir, name] of sharedDirs(hostId, ownDirs[0], path, env)) add(dir, name); } catch { @@ -1715,12 +1905,12 @@ function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { } // src/cached-local-source.ts -import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync as renameSync2, statSync as statSync4, writeFileSync as writeFileSync2 } from "node:fs"; -import { dirname as dirname2, join as join9 } from "node:path"; +import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync3, renameSync as renameSync3, statSync as statSync5, writeFileSync as writeFileSync3 } from "node:fs"; +import { dirname as dirname2, join as join10 } from "node:path"; // ../engine-typescript/src/local-source.ts import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises"; -import { basename, join as join8 } from "node:path"; +import { basename, join as join9 } from "node:path"; // ../engine-typescript/src/bm25.ts var TOKEN_RE = /[a-z0-9]{2,}|[一-鿿]+/g; @@ -1845,7 +2035,11 @@ var LocalSkillSource = class { // The renderer turns this into an absolute path the model can hand // to a file tool; without it a body saying `scripts/x.sh` resolves // against the agent's cwd, which is the wrong directory. - skillDir: skill.dir + skillDir: skill.dir, + // Set only for a skill this plugin installed. Fusion collapses on + // it, so the local copy and the catalogue entry it came from are + // one hit rather than two. + origin: readMarker(skill.dir)?.origin ?? "" } })); } @@ -1909,9 +2103,9 @@ async function* walk(root, maxDepth) { } for (const entry of entries) { if (entry.isDirectory()) { - if (!SKIP_DIRS2.has(entry.name)) stack.push({ dir: join8(dir, entry.name), depth: depth + 1 }); + if (!SKIP_DIRS2.has(entry.name)) stack.push({ dir: join9(dir, entry.name), depth: depth + 1 }); } else if (entry.name === SKILL_FILE2) { - yield join8(dir, entry.name); + yield join9(dir, entry.name); } } } @@ -1967,7 +2161,7 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } read() { try { - const parsed = JSON.parse(readFileSync3(this.cachePath, "utf8")); + const parsed = JSON.parse(readFileSync4(this.cachePath, "utf8")); if (!parsed || typeof parsed !== "object") return void 0; const file = parsed; if (file.version !== 1 || typeof file.fingerprint !== "string") return void 0; @@ -1978,10 +2172,10 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } write(file) { try { - mkdirSync2(dirname2(this.cachePath), { recursive: true }); + mkdirSync3(dirname2(this.cachePath), { recursive: true }); const temp = `${this.cachePath}.${process.pid}.tmp`; - writeFileSync2(temp, JSON.stringify(file)); - renameSync2(temp, this.cachePath); + writeFileSync3(temp, JSON.stringify(file)); + renameSync3(temp, this.cachePath); } catch { } } @@ -1990,17 +2184,17 @@ function collect2(dir, depth, out) { if (depth < 0) return; let entries; try { - entries = readdirSync2(dir, { withFileTypes: true }); + entries = readdirSync3(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (SKIP_DIRS3.has(entry.name)) continue; - const path = join9(dir, entry.name); + const path = join10(dir, entry.name); if (entry.isDirectory()) collect2(path, depth - 1, out); else if (entry.name === SKILL_FILE3) { try { - out.push(`${path}:${statSync4(path).mtimeMs}`); + out.push(`${path}:${statSync5(path).mtimeMs}`); } catch { } } @@ -2045,12 +2239,23 @@ function createChatModel(options) { // src/retrieve.ts function expandHome2(path, home = homedir3()) { if (path === "~") return home; - if (path.startsWith("~/")) return join10(home, path.slice(2)); + if (path.startsWith("~/")) return join11(home, path.slice(2)); return path; } +function installRootFor(share) { + if (!share) return void 0; + try { + const root = sharedSkillsDir(); + mkdirSync4(root, { recursive: true }); + return root; + } catch { + return void 0; + } +} function buildEngine(config, onDiagnostic, workspaceDir) { const sources = []; const dirs = config.skillsDirs.map((dir) => expandHome2(dir)).filter(Boolean); + const installRoot = installRootFor(config.shareSkills); const roots = scanDirs("workbuddy", dirs, config.shareSkills); if (roots.length > 0) { const local = new CachedLocalSkillSource( @@ -2063,11 +2268,12 @@ function buildEngine(config, onDiagnostic, workspaceDir) { let client; if (config.hubEndpoint) { client = new SkillHubClient(config.hubEndpoint, { + ...installRoot ? { installRoot } : {}, ...config.hubApiKey ? { apiKey: config.hubApiKey } : {}, // Outside every scanned directory. `~/.workbuddy-ai/plugins/cache` is // one of the defaults, so a bundle extracted under it would come back // as a local skill on the next scan. - cacheDir: expandHome2(config.bundleCacheDir) || join10(homedir3(), ".workbuddy-ai", "skillsearch-bundles") + cacheDir: expandHome2(config.bundleCacheDir) || join11(homedir3(), ".workbuddy-ai", "skillsearch-bundles") }); const hub = new HubSkillSource(client); hub.weight = config.hubWeight; @@ -2080,7 +2286,8 @@ function buildEngine(config, onDiagnostic, workspaceDir) { ]) { if (!endpoint) continue; const marketplace = new MarketplaceClient(kind, endpoint, { - cacheDir: expandHome2(config.bundleCacheDir) || join10(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), + ...installRoot ? { installRoot } : {}, + cacheDir: expandHome2(config.bundleCacheDir) || join11(homedir3(), ".workbuddy-ai", "skillsearch-bundles"), // ClawHub measured 4–5s on the supported route. Give search headroom, // but leave time under the hook's global deadline for body hydration. timeoutMs: Math.max(1, Math.min(config.timeoutMs, 6500)), @@ -2141,7 +2348,7 @@ function buildEngine(config, onDiagnostic, workspaceDir) { // falling back to the hook process's cwd when the payload reports none. outputDir: workspaceDir || process.cwd(), homeDir: homedir3(), - stateDir: join10(homedir3(), ".workbuddy-ai"), + stateDir: join11(homedir3(), ".workbuddy-ai"), resolvePlaceholders: config.resolvePlaceholders } ); diff --git a/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts b/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts index bb92112..ec267f9 100644 --- a/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts +++ b/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts @@ -8,13 +8,14 @@ * @module */ +import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { SkillSearchEngine, type SourceDiagnostic } from '../../engine-typescript/src/engine.js' import { LLMGateFilter } from '../../engine-typescript/src/gate.js' import { HubSkillSource, SkillHubClient } from '../../engine-typescript/src/hub-source.js' import { MarketplaceClient, MarketplaceSkillSource } from '../../engine-typescript/src/marketplace-source.js' -import { scanDirs } from '../../engine-typescript/src/shared.js' +import { scanDirs, sharedSkillsDir } from '../../engine-typescript/src/shared.js' import type { SkillSource } from '../../engine-typescript/src/types.js' import { QueryRewriter } from '../../engine-typescript/src/rewriter.js' import { CachedLocalSkillSource } from './cached-local-source.js' @@ -35,6 +36,26 @@ export function expandHome(path: string, home: string = homedir()): string { * @returns the engine, which reports `enabled: false` when nothing is * configured to search. */ +/** + * Where a retrieved skill is kept, or `undefined` to use the cache. + * + * The shared skills directory when the deployment opted in, created here + * rather than lazily: a directory that does not exist is not scanned, and a + * skill installed into an unscanned directory is the exact bug this feature + * exists to fix. + */ +function installRootFor(share: boolean): string | undefined { + if (!share) return undefined + try { + const root = sharedSkillsDir() + mkdirSync(root, { recursive: true }) + return root + } catch { + // Sharing is never worth a failed turn. + return undefined + } +} + export function buildEngine( config: SkillSearchConfig, onDiagnostic?: (diagnostic: SourceDiagnostic) => void, @@ -52,6 +73,7 @@ export function buildEngine( // turn's hot path, inside an 8s budget, where a throw blocks the user's // message. `scanDirs` is built for that: the steady state is one small file // read and no write, and it swallows everything. + const installRoot = installRootFor(config.shareSkills) const roots = scanDirs('workbuddy', dirs, config.shareSkills) if (roots.length > 0) { const local = new CachedLocalSkillSource( @@ -67,6 +89,7 @@ export function buildEngine( let client: SkillHubClient | undefined if (config.hubEndpoint) { client = new SkillHubClient(config.hubEndpoint, { + ...(installRoot ? { installRoot } : {}), ...(config.hubApiKey ? { apiKey: config.hubApiKey } : {}), // Outside every scanned directory. `~/.workbuddy-ai/plugins/cache` is // one of the defaults, so a bundle extracted under it would come back @@ -86,6 +109,7 @@ export function buildEngine( ] as const) { if (!endpoint) continue const marketplace = new MarketplaceClient(kind, endpoint, { + ...(installRoot ? { installRoot } : {}), cacheDir: expandHome(config.bundleCacheDir) || join(homedir(), '.workbuddy-ai', 'skillsearch-bundles'), // ClawHub measured 4–5s on the supported route. Give search headroom, From f4f9c1641f2ee34adfe7f153dc20d1c16ed53069 Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 04:07:35 +0000 Subject: [PATCH 04/14] test(host-e2e): prove the cross-agent claim on two real hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything else about the shared library is unit-tested in-process, which proves the mechanism and cannot prove the claim. The claim is that two different agents on one machine see each other's skills, and there is no way to observe that without running two of them — so the acceptance table in the previous commits was asserting a mechanism and reading as an observation. `e2e_shared.py` runs one Python host and one TypeScript host against one `SKILLSEARCH_HOME` and a real model. Two hosts rather than five because two is what the two *ports* are: a disagreement between them is the failure a single-host run cannot see, and a third host of either kind re-runs the same code. Observed, at this commit, against Qwen3.6-27B: - OpenClaw 2.0 retrieved a skill that exists only in **Raven's** directory — its reply carries `Wombat-Ledger-7`, a token present in nothing but that file, which Raven registered and OpenClaw read from the registry; - a skill dragged into the shared directory by hand was found on the next turn, with nothing restarted; - setting `enabled: false` on Raven's line made OpenClaw answer that it has no such internal procedure — and Raven re-registering afterwards left the flag off, which is the rule that makes the file editable; - a registry corrupted to `{ this is not json` left retrieval working. That is acceptance 3, 4, 5, 6 and 9. Acceptance 1, 2, 7 and 8 are about installing and need a live catalogue, so they stay unit-tested, and the script's own docstring says which is which rather than implying otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- skillcorpus_plugin/tests/host-e2e/ruff.toml | 3 + .../tests/host-e2e/scripts/e2e_shared.py | 206 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py diff --git a/skillcorpus_plugin/tests/host-e2e/ruff.toml b/skillcorpus_plugin/tests/host-e2e/ruff.toml index d977033..b6bb4df 100644 --- a/skillcorpus_plugin/tests/host-e2e/ruff.toml +++ b/skillcorpus_plugin/tests/host-e2e/ruff.toml @@ -37,3 +37,6 @@ ignore = [ # Runs the host CLI the operator named on the command line. That is the # script's entire job, so flagging the call is noise. "scripts/e2e_openclaw.py" = ["S603"] +# Runs two host CLIs and an interpreter, all named by the operator. Running +# them is the script. +"scripts/e2e_shared.py" = ["S603"] diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py new file mode 100644 index 0000000..0551915 --- /dev/null +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py @@ -0,0 +1,206 @@ +"""The cross-agent acceptance list, on two real hosts. + +Everything else about the shared library is unit-tested in-process, which +proves the mechanism and cannot prove the claim. The claim is about two +different agents on one machine seeing each other's skills, and there is no +way to observe that without running two of them. + +So: one Python host (Raven) and one TypeScript host (OpenClaw), sharing one +`SKILLSEARCH_HOME`, driven against a real model. Two hosts rather than five +because this is what the two *ports* are — a disagreement between them is the +failure mode that a single-host run cannot see, and a third host of either +kind exercises the same code again. + +Covers, from the spec's own list: + + 3 a skill in agent A's own directory is retrievable in agent B + 5 a skill dropped into the shared directory by hand reaches both, + with neither restarted + 6 `enabled: false` hides A's directory from B, and survives A restarting + 9 a corrupt registry costs sharing and not retrieval + +Acceptance 1, 2, 4, 7 and 8 are about installing, which needs a live +catalogue; they stay unit-tested and this file says so rather than implying +otherwise. + +Usage: + + export SKILLSEARCH_E2E_BASE_URL=... SKILLSEARCH_E2E_MODEL=... + python e2e_shared.py --raven /path/to/raven --raven-site /path/to/site-packages \\ + --openclaw /path/to/node_modules/.bin/openclaw +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _e2e +import e2e_openclaw + +RUN_TIMEOUT_S = 600.0 + +#: A second skill, so a host can be shown finding *the other one's* rather +#: than merely finding something. Facts nowhere else, same rule as `_e2e`. +RAVEN_SKILL = """\ +--- +name: invoice-audit +description: Audit a batch of supplier invoices for duplicate and out-of-policy charges. +--- + +House rule: reconcile against the `Wombat-Ledger-7` register and flag anything +above the `Tapir Threshold` for a second reviewer. +""" +RAVEN_FACTS = ("Wombat-Ledger-7", "Tapir Threshold") +RAVEN_PROMPT = "What is our internal procedure for auditing supplier invoices?" + + +def run_openclaw(openclaw: Path, profile: str, prompt: str, env: dict) -> dict: + """One OpenClaw turn, reading the answer off the host's own transcript.""" + session = str(uuid.uuid4()) + proc = subprocess.run( + [str(openclaw), "--profile", profile, "agent", "--local", + "--thinking", "off", "--session-id", session, "--json", "-m", prompt], + capture_output=True, text=True, timeout=RUN_TIMEOUT_S, check=False, env=env, + ) + profile_dir = Path(env["HOME"]) / f".openclaw-{profile}" + turn = e2e_openclaw.read_turn(e2e_openclaw.transcript(profile_dir, session)) + turn["returncode"] = proc.returncode + turn["stderr"] = proc.stderr[-3000:] + return turn + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--openclaw", type=Path, default=os.environ.get("SKILLSEARCH_E2E_OPENCLAW")) + ap.add_argument("--generation", type=int, default=2, choices=(1, 2)) + ap.add_argument("--raven", type=Path, default=os.environ.get("SKILLSEARCH_E2E_RAVEN_CHECKOUT")) + ap.add_argument("--raven-site", default=os.environ.get("SKILLSEARCH_E2E_RAVEN_SITE")) + ap.add_argument("--raven-python", default=sys.executable, + help="an interpreter that can import Raven and the plugin") + ap.add_argument("--dump", type=Path, default=None) + args = ap.parse_args() + if not args.openclaw or not args.raven: + ap.error("--openclaw and --raven are both required; this measures two hosts") + + model = _e2e.model_config() + home = Path(tempfile.mkdtemp(prefix="skillsearch-two-hosts-")) + shared_home = home / ".evermind-skillsearch" + (shared_home / "skills").mkdir(parents=True) + registry = shared_home / "registry.json" + + # Raven's own directory, with a skill only it has. + raven_ws = home / "raven-project" + raven_skills = raven_ws / "skills" / "invoice-audit" + raven_skills.mkdir(parents=True) + (raven_skills / "SKILL.md").write_text(RAVEN_SKILL, encoding="utf-8") + + # OpenClaw's own directory, with the standard fixture, so each host has + # something of its own and "found the other's" is unambiguous. + oc_skills = home / "openclaw-skills" + _e2e.corpus(home / "openclaw") + shutil.move(str(home / "openclaw" / "skills"), str(oc_skills)) + + env = {**os.environ, "HOME": str(home), "SKILLSEARCH_HOME": str(shared_home)} + results: dict = {"home": str(home)} + failures: list[str] = [] + + def check(name: str, ok: bool, detail: str) -> None: + results[name] = {"pass": ok, "detail": detail} + print(f" {'PASS' if ok else 'FAIL'} {name}") + print(f" {detail}") + if not ok: + failures.append(name) + + print(f"shared home: {shared_home}") + + # -- Raven registers its directory ------------------------------------ + raven_env = {**env, "PYTHONPATH": args.raven_site} + probe = subprocess.run( + [args.raven_python, "-c", + "import sys, site, json;" + f"sys.path.insert(0, {str(args.raven)!r});" + f"site.addsitedir({args.raven_site!r});" + "from skillsearch import shared;" + f"shared.register_host('raven', {str(raven_ws / 'skills')!r});" + "print(json.dumps([e.as_json() for e in shared.read_registry()]))"], + capture_output=True, text=True, timeout=120, check=False, env=raven_env, + ) + registered = json.loads(probe.stdout.strip().splitlines()[-1]) if probe.returncode == 0 else [] + check("raven registers its own skills directory", + any(e["id"] == "raven" for e in registered), + f"registry.json now holds {[e['id'] for e in registered]}" + + ("" if probe.returncode == 0 else f"; stderr={probe.stderr[-400:]}")) + + # -- Acceptance 3/4: OpenClaw finds Raven's skill ---------------------- + e2e_openclaw.write_profile( + home / ".openclaw-shared-e2e", args.generation, "on_demand", oc_skills, + home / "oc-workspace", model, + ) + turn = run_openclaw(Path(args.openclaw), "shared-e2e", RAVEN_PROMPT, env) + delivered = "\n".join(turn["tool_results"]) + "\n" + turn["reply"] + check("openclaw retrieves a skill that lives in raven's directory", + all(fact in delivered for fact in RAVEN_FACTS), + f"tools={[c['name'] for c in turn['tool_calls']]} reply={turn['reply'][:160]!r}") + + # -- Acceptance 5: dropped in by hand, no restart ---------------------- + dropped = shared_home / "skills" / "pdf-tables" + dropped.mkdir(parents=True) + (dropped / "SKILL.md").write_text(_e2e.SKILL_BODY, encoding="utf-8") + turn = run_openclaw(Path(args.openclaw), "shared-e2e", _e2e.PROMPT_INTERNAL, env) + delivered = "\n".join(turn["tool_results"]) + "\n" + turn["reply"] + check("a skill dropped into the shared directory is found without a restart", + _e2e.sentinel_in(delivered), + f"tools={[c['name'] for c in turn['tool_calls']]} reply={turn['reply'][:160]!r}") + + # -- Acceptance 6: enabled:false hides it, and survives a restart ------ + document = json.loads(registry.read_text(encoding="utf-8")) + for entry in document["hosts"]: + if entry["id"] == "raven": + entry["enabled"] = False + registry.write_text(json.dumps(document, indent=2), encoding="utf-8") + + turn = run_openclaw(Path(args.openclaw), "shared-e2e", RAVEN_PROMPT, env) + delivered = "\n".join(turn["tool_results"]) + "\n" + turn["reply"] + check("disabling raven's entry hides its skills from openclaw", + not any(fact in delivered for fact in RAVEN_FACTS), + f"reply={turn['reply'][:160]!r}") + + subprocess.run( + [args.raven_python, "-c", + "import sys, site;" + f"sys.path.insert(0, {str(args.raven)!r});" + f"site.addsitedir({args.raven_site!r});" + "from skillsearch import shared;" + f"shared.register_host('raven', {str(raven_ws / 'skills')!r})"], + capture_output=True, text=True, timeout=120, check=False, env=raven_env, + ) + after = json.loads(registry.read_text(encoding="utf-8")) + still_off = [e for e in after["hosts"] if e["id"] == "raven" and e["enabled"] is False] + check("and raven restarting does not switch it back on", + bool(still_off), + f"registry after re-registration: {[(e['id'], e['enabled']) for e in after['hosts']]}") + + # -- Acceptance 9: a corrupt registry costs sharing, not retrieval ----- + registry.write_text("{ this is not json", encoding="utf-8") + turn = run_openclaw(Path(args.openclaw), "shared-e2e", _e2e.PROMPT_POSITIVE, env) + check("a corrupt registry leaves retrieval working", + turn["returncode"] == 0 and bool(turn["reply"]), + f"rc={turn['returncode']} reply={turn['reply'][:160]!r}") + + if args.dump: + args.dump.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8") + print("all passed" if not failures else f"failed: {failures}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a823d26b65f69674c2c88c3a5f46327a07445739 Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 04:26:31 +0000 Subject: [PATCH 05/14] fix(plugin): find installs whose bundle wraps the skill in a directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the install path against the real EverMind SkillHub instead of a fixture, which is the whole reason to do that: a hand-built fixture agrees with itself, and this shape only exists because a real catalogue sends it. Hub bundles wrap the skill in one directory, so `SKILL.md` — and the marker beside it, which is where the scanner reads provenance from — sits one level below the directory the install created. `list_installed` looked only at the top level and found nothing. The effect was a split brain rather than an outright failure, which is worse: deduplication worked, because the scanner reads the marker from the `SKILL.md`'s own directory, while every management call was blind. `listInstalled` reported an empty library with two skills in it, and `uninstall` refused to remove anything. Resolution goes through the wrapper the same way the bundle root does, one level and no further — a marker deeper than that was not written by this code, and treating arbitrary depth as an install would let a skill that ships another skill be uninstalled out from under its owner. `find_installed` returns the directory the install *created*, so removing it does not leave an empty husk. `e2e_install.py` is the run that found it: retrieve from the live catalogue, then assert on acceptance 1, 2 and 7. Acceptance 8 stays unit-tested, and the script says so — it is a property of the swap primitive, not of the catalogue, and forcing a real download to fail halfway would be theatre. Three defects in the harness itself, all of which had made a broken thing look fine or a working thing look broken: - it counted `### Skill: `, but the block prints the *frontmatter name*, and the two differ for every catalogue skill — a working dedup reported as 0 occurrences; - each turn ran under its own `asyncio.run`, so the engine's HTTP client died with `Event loop is closed` after the first, silently turning the dedup check into a local-only check that could not fail. Real hosts have one loop; - it looked for the marker at the outer directory, which is exactly the bug above, and so would have passed against the pre-fix code. Co-Authored-By: Claude Opus 5 (1M context) --- .../engine-python/skillsearch/provenance.py | 66 ++++-- .../engine-python/tests/test_provenance.py | 36 ++++ .../tests/host-e2e/scripts/e2e_install.py | 196 ++++++++++++++++++ 3 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py diff --git a/skillcorpus_plugin/engine-python/skillsearch/provenance.py b/skillcorpus_plugin/engine-python/skillsearch/provenance.py index 4a20ab9..0baf79b 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/provenance.py +++ b/skillcorpus_plugin/engine-python/skillsearch/provenance.py @@ -164,14 +164,21 @@ def slug_dir(root: str | os.PathLike[str], source: str, slug: str) -> Path: return Path(root) / f"{safe_source}__{safe_slug or 'skill'}" -def list_installed(root: str | os.PathLike[str]) -> list[Origin]: - """Every skill this plugin installed under ``root``, sorted by identity. - - A walk rather than an index read: the directory is the truth, so a skill a - user deleted by hand is simply gone rather than a stale row nobody can - explain. +def _entries(root: str | os.PathLike[str]) -> list[tuple[Path, Origin]]: + """Every install under ``root``, as ``(top-level directory, marker)``. + + The two paths are not always the same directory, which is what makes this + more than an ``iterdir``. Catalogue bundles usually wrap the whole skill in + one directory, so the ``SKILL.md`` — and therefore the marker, which lives + beside it because that is what the scanner reads — sits one level below the + directory the install created. Uninstalling has to remove the outer one, or + an empty husk stays behind. + + Only one level down. A marker deeper than that is not something this code + wrote, and treating an arbitrary depth as an install would let a skill + bundled inside another skill be uninstalled out from under it. """ - out: list[Origin] = [] + out: list[tuple[Path, Origin]] = [] try: entries = sorted(Path(root).iterdir(), key=lambda p: p.name) except OSError: @@ -181,22 +188,41 @@ def list_installed(root: str | os.PathLike[str]) -> list[Origin]: continue marker = read_marker(entry) if marker is not None: - out.append(marker) - return sorted(out, key=lambda o: o.origin) + out.append((entry, marker)) + continue + try: + nested = sorted(entry.iterdir(), key=lambda p: p.name) + except OSError: + continue + for child in nested: + if not child.is_dir(): + continue + marker = read_marker(child) + if marker is not None: + out.append((entry, marker)) + break + return out + + +def list_installed(root: str | os.PathLike[str]) -> list[Origin]: + """Every skill this plugin installed under ``root``, sorted by identity. + + A walk rather than an index read: the directory is the truth, so a skill a + user deleted by hand is simply gone rather than a stale row nobody can + explain. + """ + return sorted((marker for _, marker in _entries(root)), key=lambda o: o.origin) def find_installed(root: str | os.PathLike[str], origin: str) -> Path | None: - """The directory holding an installed skill, by identity.""" - try: - entries = sorted(Path(root).iterdir(), key=lambda p: p.name) - except OSError: - return None - for entry in entries: - if not entry.is_dir(): - continue - marker = read_marker(entry) - if marker is not None and marker.origin == origin: - return entry + """The directory to remove for an installed skill, by identity. + + The directory the install *created*, not the one holding the ``SKILL.md`` + — see `_entries`. + """ + for directory, marker in _entries(root): + if marker.origin == origin: + return directory return None diff --git a/skillcorpus_plugin/engine-python/tests/test_provenance.py b/skillcorpus_plugin/engine-python/tests/test_provenance.py index e15f3eb..4d97897 100644 --- a/skillcorpus_plugin/engine-python/tests/test_provenance.py +++ b/skillcorpus_plugin/engine-python/tests/test_provenance.py @@ -268,3 +268,39 @@ def test_the_marker_is_json_a_person_can_read(tmp_path: Path) -> None: assert document["source"] == "hub" assert document["skill_version"] == "1.0" assert "_note" in document and "uninstall" in document["_note"] + + +def test_a_bundle_that_wraps_the_skill_is_still_listed_and_removable(tmp_path: Path) -> None: + """The shape a real catalogue actually sends. + + Hub bundles wrap the whole skill in one directory, so the `SKILL.md` — and + the marker beside it, which is where the scanner reads it — sits one level + below the directory the install created. Listing only the top level found + nothing, so dedup worked while every management call was blind. Caught by + running against the live catalogue; no hand-built fixture has a wrapper. + """ + dest = provenance.slug_dir(tmp_path, "hub", "extract-tables-from-pdf") + body = dest / "extract-tables-from-pdf" + body.mkdir(parents=True) + (body / "SKILL.md").write_text("---\nname: extract-tables-from-pdf\n---\n\nbody\n", encoding="utf-8") + provenance.write_marker(body, _origin(slug="extract-tables-from-pdf")) + + assert [o.origin for o in provenance.list_installed(tmp_path)] == ["hub/extract-tables-from-pdf"] + # The *outer* directory, or uninstalling leaves an empty husk behind. + assert provenance.find_installed(tmp_path, "hub/extract-tables-from-pdf") == dest + assert provenance.uninstall(tmp_path, "hub/extract-tables-from-pdf") is True + assert not dest.exists() + assert provenance.list_installed(tmp_path) == [] + + +def test_a_skill_bundled_inside_another_skill_is_not_treated_as_an_install(tmp_path: Path) -> None: + """One level down, not arbitrary depth. + + Otherwise a skill that ships another skill in its own tree could be + uninstalled out from under the one that owns it. + """ + outer = tmp_path / "handwritten" + deep = outer / "vendor" / "nested" + deep.mkdir(parents=True) + provenance.write_marker(deep, _origin(slug="nested")) + assert provenance.list_installed(tmp_path) == [] diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py new file mode 100644 index 0000000..5cca3a0 --- /dev/null +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py @@ -0,0 +1,196 @@ +"""Installing, against the real EverMind SkillHub. + +The other half of the acceptance list. `e2e_shared.py` proves two agents see +each other's directories; this proves the part that puts something in one — +retrieve from a live catalogue, keep what came back, and count it once. + +It talks to the real hub rather than a fake because the failure this guards +against is precisely a real catalogue's shape: a hit whose slug, version and +bundle layout are whatever the service decided, arriving through the real +download and extract path. A hand-built fixture agrees with itself. + +Covers, from the spec's list: + + 1 a retrieved skill appears under `/skills/` + 2 the next turn finds it locally, **exactly once**, with no restart + 7 uninstalling removes it, and it stops being retrievable + +Acceptance 8 — a failed update leaves the old version working — is not here. +It is a property of the swap primitive, not of the catalogue, and forcing a +real download to fail halfway would be theatre; `engine-python/tests` and +`engine-typescript/tests` assert it directly on `swap_into_place`. + +Usage: + + python e2e_install.py # local-only assertions + python e2e_install.py --openclaw /path/to/openclaw --generation 2 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +#: The default in every host's config, and a service that answers without a +#: key. Overridable so this can be pointed at a staging deployment. +HUB = os.environ.get("SKILLSEARCH_E2E_HUB", "https://skillhub.evermind.ai") + +#: Words that match a real skill in that catalogue. Checked against the live +#: service by `--probe` before anything is asserted, so a catalogue that +#: changed its contents fails loudly rather than looking like a broken plugin. +QUERY = "extract tables from a PDF" + + +def engine_for(home: Path, skills: Path, top_k: int = 3): + from skillsearch.config import SearchConfig + from skillsearch.engine import SkillSearch + + return SkillSearch(SearchConfig.from_mapping({ + "skills_dir": str(skills), + "hub_endpoint": HUB, + # One catalogue at a time: two would make "which source installed it" + # a guess, and this is about the install path, not about fusion. + "clawhub_endpoint": "", + "skillhub_cn_endpoint": "", + "top_k": top_k, + # No model, so no gate and no rewriter: this measures installing, and + # a gate that decides the turn wants no skills measures nothing. + "model": "", + "gate": False, + })) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--engine-python", type=Path, + default=Path(__file__).resolve().parents[3] / "engine-python") + ap.add_argument("--dump", type=Path, default=None) + args = ap.parse_args() + + sys.path.insert(0, str(args.engine_python)) + from skillsearch import provenance, shared + + home = Path(tempfile.mkdtemp(prefix="skillsearch-install-")) + os.environ[shared.HOME_ENV] = str(home) + own = home / "own-skills" + own.mkdir() + shared_skills = shared.shared_skills_dir() + + results: dict = {"home": str(home), "hub": HUB} + failures: list[str] = [] + + def check(name: str, ok: bool, detail: str) -> None: + results[name] = {"pass": ok, "detail": detail} + print(f" {'PASS' if ok else 'FAIL'} {name}") + print(f" {detail}") + if not ok: + failures.append(name) + + print(f"hub: {HUB}\nshared home: {home}") + + def skill_name(directory: Path) -> str: + """The name the renderer prints, which is not the install slug. + + The slug is sanitised from the catalogue's own id — here + `mzlzyCA_html-markdown_extract-tables-from-pdf` — while the block says + `### Skill: extract-tables-from-pdf`, from the body's frontmatter. + Counting occurrences of the wrong one of those is how this script first + reported a working dedup as broken. + """ + for candidate in (directory, *sorted(directory.iterdir())): + skill_md = candidate / "SKILL.md" + if not skill_md.is_file(): + continue + for line in skill_md.read_text(encoding="utf-8", errors="replace").splitlines()[:10]: + if line.startswith("name:"): + return line.split(":", 1)[1].strip() + return "" + + async def run() -> dict: + """Everything in one event loop. + + The engine holds an HTTP client bound to the loop that first used it, + so a second `asyncio.run` kills the catalogue source with + `Event loop is closed` — which silently turns the dedup check into a + local-only check that cannot fail. Real hosts have one loop; so does + this. + """ + engine = engine_for(home, own) + first = await engine.retrieve(QUERY) + if not first.strip(): + return {"empty": True} + + installed = provenance.list_installed(shared_skills) + if not installed: + return {"first": first, "installed": []} + + second = await engine.retrieve(QUERY) + fresh = engine_for(home, own) + third = await fresh.retrieve(QUERY) + return {"first": first, "installed": installed, "second": second, "third": third} + + turns = asyncio.run(run()) + + if turns.get("empty"): + check("the catalogue returned something for the probe query", False, + f"empty block for {QUERY!r} — the catalogue's contents may have changed") + print(f"failed: {failures}") + return 1 + + # -- Acceptance 1 ----------------------------------------------------- + installed = turns["installed"] + check("a retrieved skill is kept under the shared skills directory", + bool(installed), + f"{[o.origin for o in installed]} under {shared_skills}") + if not installed: + print(f"failed: {failures}") + return 1 + + origin = installed[0].origin + outer = provenance.find_installed(shared_skills, origin) + # The marker sits beside the `SKILL.md`, which a wrapped bundle puts one + # level below the directory the install created — so it is read through + # the same resolution the ledger uses, not by guessing a path. + ledger = [o.origin for o in provenance.list_installed(shared_skills)] + check("and it carries a readable provenance marker", + outer is not None and origin in ledger, + f"{origin} version {installed[0].version!r} at {outer}") + + # -- Acceptance 2 ------------------------------------------------------ + name = skill_name(outer) if outer else "" + heading = f"### Skill: {name}" + check("the next turn finds it exactly once, not twice", + name != "" and turns["second"].count(heading) == 1, + f"{heading!r} appears {turns['second'].count(heading)}x — the installed copy and " + f"the catalogue hit must collapse on identity {origin!r}") + check("a newly built engine sees it too, and still once", + name != "" and turns["third"].count(heading) == 1, + f"{turns['third'].count(heading)}x in an engine with no cached scan") + + # -- Acceptance 7 ------------------------------------------------------ + removed = [o.origin for o in installed if provenance.uninstall(shared_skills, o.origin)] + check("uninstalling reports that it removed what was there", + removed == [o.origin for o in installed], + f"removed {removed}") + check("and nothing is left in the ledger or on disk", + provenance.list_installed(shared_skills) == [] + and not any(p.is_dir() for p in shared_skills.iterdir()), + f"remaining: {sorted(p.name for p in shared_skills.iterdir())}") + + if args.dump: + args.dump.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8") + shutil.rmtree(home, ignore_errors=True) + print("all passed" if not failures else f"failed: {failures}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9b44c2bd3ea0d9a407f4f016b43a2ea7900c4f7f Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 04:44:03 +0000 Subject: [PATCH 06/14] fix(plugin): make the two ports agree on where a skill installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs, both of the same kind, and the second only found because the first made me go looking: a fix applied to one port and not the other. **The wrapper bug, in TypeScript.** `listInstalled` and `findInstalled` looked only at the top level, so a catalogue bundle that wraps the skill in a directory was invisible to them — the identical defect fixed in the Python port one commit ago, still sitting here. Measured before fixing: `listInstalled` returned `[]` and `uninstall` returned `false` against a correctly installed skill. Both suites were green because both fixtures were flat, which is the shape no real catalogue sends. **Where a skill lands, disagreeing across the ports.** Worse, because it is silent and permanent. `slug_dir` sanitises a catalogue slug into a directory name, and the two ports did not compute the same one: 中文技能 Python hub__中文技能 TypeScript hub______ café-export Python hub__café-export TypeScript hub__caf_-export Python's `str.isalnum` is Unicode-aware; the TypeScript character class is ASCII. So one skill installed by Raven and by OpenClaw occupies two directories in the shared library — two ranked copies of one skill, which is precisely what identity dedup exists to prevent. And every all-CJK slug collapsed to the same `hub______`, so distinct skills overwrote each other. Both ports are now ASCII-only and identical, and both append the identity's digest. Sanitising alone is lossy: two skills collide whenever their slugs differ only in dropped characters, and one silently overwrites the other. With the digest the map from identity to directory is injective, which is what "one directory per identity" has to mean. One more character of drift after that: a JS regex walks UTF-16 code units, so an emoji is two of them and became two underscores against Python's one. Fixed by iterating code points. `fixtures-slugdir.json` pins thirteen cases — CJK, accented, astral, traversal, over-length, empty — and both suites assert against it. The class of bug here is that each suite only ever compared a port with itself. Re-verified on real hosts after the change: the install run against the live EverMind SkillHub still passes acceptance 1, 2 and 7, and the two-host run still passes 3, 4, 5, 6 and 9. Co-Authored-By: Claude Opus 5 (1M context) --- .../engine-python/skillsearch/provenance.py | 31 ++++++-- .../engine-python/tests/test_provenance.py | 14 ++++ .../engine-typescript/src/provenance.ts | 78 +++++++++++++++---- .../tests/fixtures-slugdir.json | 70 +++++++++++++++++ .../engine-typescript/tests/parity.test.ts | 56 ++++++++++++- .../plugin-workbuddy/dist/hook.mjs | 8 +- .../plugin-workbuddy/dist/mcp.mjs | 8 +- 7 files changed, 239 insertions(+), 26 deletions(-) create mode 100644 skillcorpus_plugin/engine-typescript/tests/fixtures-slugdir.json diff --git a/skillcorpus_plugin/engine-python/skillsearch/provenance.py b/skillcorpus_plugin/engine-python/skillsearch/provenance.py index 0baf79b..4c20513 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/provenance.py +++ b/skillcorpus_plugin/engine-python/skillsearch/provenance.py @@ -148,6 +148,17 @@ def now() -> str: return datetime.now(UTC).replace(microsecond=0).isoformat() +#: ASCII only, deliberately. A directory name computed from a catalogue slug +#: has to come out identical in both ports and on all three platforms, and +#: "which characters are alphanumeric" does not agree across them: Python's +#: ``str.isalnum`` is Unicode-aware, so `中文技能` survives it, while the +#: TypeScript port's character class is ASCII and turns the same slug into +#: underscores. One skill would then occupy two directories in the shared +#: library — the exact duplication identity dedup exists to prevent. +_SAFE_SOURCE = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") +_SAFE_SLUG = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.@") + + def slug_dir(root: str | os.PathLike[str], source: str, slug: str) -> Path: """Where a skill from ``source`` lands under ``root``. @@ -155,13 +166,21 @@ def slug_dir(root: str | os.PathLike[str], source: str, slug: str) -> Path: there rather than accumulating copies, which is what keeps the shared directory from growing a second ranked copy of everything. - The name is sanitised because a slug comes from a catalogue and reaches - the filesystem — anything outside the allow-list becomes ``_``, so a slug - of ``../../etc`` cannot escape ``root``. + The name is sanitised because a slug comes from a catalogue and reaches the + filesystem — anything outside the ASCII allow-list becomes ``_``, so + ``../../etc`` cannot escape ``root``. + + Sanitising alone is lossy, so the identity's digest is appended. Without it + two different skills collide whenever their slugs differ only in characters + the allow-list drops — every all-CJK slug sanitises to the same string of + underscores — and one would silently overwrite the other. With it the map + from identity to directory is injective, which is what "one directory per + identity" has to mean. """ - safe_source = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(source))[:40] - safe_slug = "".join(c if c.isalnum() or c in "-_.@" else "_" for c in str(slug))[:120] - return Path(root) / f"{safe_source}__{safe_slug or 'skill'}" + safe_source = "".join(c if c in _SAFE_SOURCE else "_" for c in str(source))[:40] + safe_slug = "".join(c if c in _SAFE_SLUG else "_" for c in str(slug))[:120] + digest = hashlib.sha256(identity(source, slug).encode("utf-8")).hexdigest()[:8] + return Path(root) / f"{safe_source}__{safe_slug or 'skill'}__{digest}" def _entries(root: str | os.PathLike[str]) -> list[tuple[Path, Origin]]: diff --git a/skillcorpus_plugin/engine-python/tests/test_provenance.py b/skillcorpus_plugin/engine-python/tests/test_provenance.py index 4d97897..e84da0a 100644 --- a/skillcorpus_plugin/engine-python/tests/test_provenance.py +++ b/skillcorpus_plugin/engine-python/tests/test_provenance.py @@ -304,3 +304,17 @@ def test_a_skill_bundled_inside_another_skill_is_not_treated_as_an_install(tmp_p deep.mkdir(parents=True) provenance.write_marker(deep, _origin(slug="nested")) assert provenance.list_installed(tmp_path) == [] + + +FIXTURES = Path(__file__).resolve().parents[2] / "engine-typescript" / "tests" / "fixtures-slugdir.json" + + +@pytest.mark.parametrize("case", json.loads(FIXTURES.read_text(encoding="utf-8"))["cases"], ids=lambda c: c["dir"]) +def test_slug_dir_matches_the_typescript_port(case: dict, tmp_path: Path) -> None: + """Both ports install into the same directory on one machine. + + A disagreement puts one skill in two directories, which is exactly the + duplication identity dedup exists to prevent — and it stayed invisible + because each suite only ever compared a port with itself. + """ + assert provenance.slug_dir(tmp_path, case["source"], case["slug"]).name == case["dir"] diff --git a/skillcorpus_plugin/engine-typescript/src/provenance.ts b/skillcorpus_plugin/engine-typescript/src/provenance.ts index 0fd4330..250f152 100644 --- a/skillcorpus_plugin/engine-typescript/src/provenance.ts +++ b/skillcorpus_plugin/engine-typescript/src/provenance.ts @@ -93,9 +93,26 @@ export function now(clock: () => Date = () => new Date()): string { * `../../etc` cannot escape `root`. */ export function slugDir(root: string, source: string, slug: string): string { - const safeSource = String(source).replace(/[^A-Za-z0-9\-_]/g, '_').slice(0, 40) - const safeSlug = String(slug).replace(/[^A-Za-z0-9\-_.@]/g, '_').slice(0, 120) - return join(root, `${safeSource}__${safeSlug || 'skill'}`) + // ASCII only, deliberately, and matching the Python port character for + // character. "Which characters are alphanumeric" does not agree across the + // two: Python's `str.isalnum` is Unicode-aware, so a CJK slug survives it + // there and becomes underscores here. One skill would then occupy two + // directories in the shared library — the exact duplication identity dedup + // exists to prevent. + // Spread rather than `replace`, so iteration is by code point. A regex walks + // UTF-16 code units, so an astral character like an emoji is two of them and + // becomes *two* underscores here against Python's one — the ports would + // disagree again, one character further along than the last time. + const sanitise = (text: string, allowed: RegExp, limit: number): string => + [...String(text)].map(character => (allowed.test(character) ? character : '_')).join('').slice(0, limit) + const safeSource = sanitise(source, /^[A-Za-z0-9\-_]$/, 40) + const safeSlug = sanitise(slug, /^[A-Za-z0-9\-_.@]$/, 120) + // Sanitising alone is lossy, so the identity's digest is appended: without + // it two skills collide whenever their slugs differ only in dropped + // characters — every all-CJK slug sanitises to the same underscores — and + // one would silently overwrite the other. + const digest = createHash('sha256').update(identity(source, slug), 'utf8').digest('hex').slice(0, 8) + return join(root, `${safeSource}__${safeSlug || 'skill'}__${digest}`) } /** @@ -175,6 +192,40 @@ function directoriesIn(root: string): string[] { } } +/** + * Every install under `root`, as `[top-level directory, marker]`. + * + * The two are not always the same directory, which is what makes this more + * than a `readdir`. Catalogue bundles usually wrap the whole skill in one + * directory, so the `SKILL.md` — and therefore the marker, which lives beside + * it because that is what the scanner reads — sits one level below the + * directory the install created. Uninstalling has to remove the outer one, or + * an empty husk stays behind. + * + * One level and no further. A marker deeper than that was not written by this + * code, and treating arbitrary depth as an install would let a skill that + * ships another skill be uninstalled out from under its owner. + */ +function entries(root: string): Array<[string, Origin]> { + const out: Array<[string, Origin]> = [] + for (const name of directoriesIn(root)) { + const outer = join(root, name) + const marker = readMarker(outer) + if (marker) { + out.push([outer, marker]) + continue + } + for (const child of directoriesIn(outer)) { + const nested = readMarker(join(outer, child)) + if (nested) { + out.push([outer, nested]) + break + } + } + } + return out +} + /** * Every skill this plugin installed under `root`, sorted by identity. * @@ -183,19 +234,20 @@ function directoriesIn(root: string): string[] { * explain. */ export function listInstalled(root: string): Origin[] { - const out: Origin[] = [] - for (const name of directoriesIn(root)) { - const marker = readMarker(join(root, name)) - if (marker) out.push(marker) - } - return out.sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0)) + return entries(root) + .map(([, marker]) => marker) + .sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0)) } -/** The directory holding an installed skill, by identity. */ +/** + * The directory to remove for an installed skill, by identity. + * + * The directory the install *created*, not the one holding the `SKILL.md` — + * see `entries`. + */ export function findInstalled(root: string, origin: string): string | undefined { - for (const name of directoriesIn(root)) { - const path = join(root, name) - if (readMarker(path)?.origin === origin) return path + for (const [directory, marker] of entries(root)) { + if (marker.origin === origin) return directory } return undefined } diff --git a/skillcorpus_plugin/engine-typescript/tests/fixtures-slugdir.json b/skillcorpus_plugin/engine-typescript/tests/fixtures-slugdir.json new file mode 100644 index 0000000..9f7d126 --- /dev/null +++ b/skillcorpus_plugin/engine-typescript/tests/fixtures-slugdir.json @@ -0,0 +1,70 @@ +{ + "_note": "Shared by engine-python/tests/test_provenance.py and engine-typescript/tests/parity.test.ts. Both ports install into the same shared directory on one machine, so a disagreement here puts one skill in two directories — the duplication identity dedup exists to prevent. Non-ASCII and astral characters are here because that is where the two ports drifted: Python's isalnum is Unicode-aware, and a JS regex walks UTF-16 code units rather than code points.", + "cases": [ + { + "source": "hub", + "slug": "pdf-tables", + "dir": "hub__pdf-tables__987ad8e8" + }, + { + "source": "hub", + "slug": "中文技能", + "dir": "hub________dc2b95ad" + }, + { + "source": "hub", + "slug": "café-export", + "dir": "hub__caf_-export__b924ed89" + }, + { + "source": "hub", + "slug": "a/b", + "dir": "hub__a_b__e8143795" + }, + { + "source": "hub", + "slug": "../../etc", + "dir": "hub__.._.._etc__58e5dd88" + }, + { + "source": "hub", + "slug": "emoji-🚀-skill", + "dir": "hub__emoji-_-skill__66f4d836" + }, + { + "source": "hub", + "slug": "Ünïcode", + "dir": "hub___n_code__738323f5" + }, + { + "source": "hub", + "slug": "𝕏-skill", + "dir": "hub___-skill__3b612bdf" + }, + { + "source": "clawhub", + "slug": "owner_name@1.2.3", + "dir": "clawhub__owner_name@1.2.3__318df007" + }, + { + "source": "skillhub_cn", + "slug": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "dir": "skillhub_cn__xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx__69741f0c" + }, + { + "source": "hub", + "slug": "", + "dir": "hub__skill__78e34a61" + }, + { + "source": "weird source!", + "slug": "ok-slug", + "dir": "weird_source___ok-slug__824862ec" + }, + { + "source": "hub", + "slug": "UPPER-and-lower_1.2@x", + "dir": "hub__UPPER-and-lower_1.2@x__92474e2e" + } + ] +} diff --git a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts index 4dee34f..27108c4 100644 --- a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts +++ b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts @@ -12,7 +12,7 @@ */ import assert from 'node:assert/strict' -import { readFileSync, statSync } from 'node:fs' +import { existsSync, readFileSync, statSync } from 'node:fs' import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -824,3 +824,57 @@ test('an update replaces in place, and a failed one leaves the old version worki assert.throws(() => { provenance.swapIntoPlace(join(root, 'never-extracted'), dest) }) assert.match(readFileSync(join(dest, 'SKILL.md'), 'utf8'), /the version that works/) }) + +test('a bundle that wraps the skill is still listed and removable', async () => { + // The shape a real catalogue actually sends. Hub bundles wrap the whole + // skill in one directory, so the `SKILL.md` — and the marker beside it, + // which is where the scanner reads it — sits one level below the directory + // the install created. Listing only the top level found nothing, so dedup + // worked while every management call was blind. + // + // Found against the live catalogue on the Python side, and this port had + // exactly the same bug: no hand-built fixture has a wrapper, so both suites + // were green. That is why it is asserted in both. + const root = await mkdtemp(join(tmpdir(), 'skillsearch-wrapped-')) + const dest = provenance.slugDir(root, 'hub', 'extract-tables-from-pdf') + const body = join(dest, 'extract-tables-from-pdf') + await mkdir(body, { recursive: true }) + await writeFile(join(body, 'SKILL.md'), '---\nname: extract-tables-from-pdf\n---\n\nbody\n') + provenance.writeMarker(body, marker('hub', 'extract-tables-from-pdf')) + + assert.deepEqual(provenance.listInstalled(root).map(o => o.origin), ['hub/extract-tables-from-pdf']) + // The *outer* directory, or uninstalling leaves an empty husk behind. + assert.equal(provenance.findInstalled(root, 'hub/extract-tables-from-pdf'), dest) + assert.equal(provenance.uninstall(root, 'hub/extract-tables-from-pdf'), true) + assert.equal(existsSync(dest), false) + assert.deepEqual(provenance.listInstalled(root), []) +}) + +test('a skill bundled inside another skill is not treated as an install', async () => { + // One level down, not arbitrary depth — otherwise a skill that ships another + // skill could be uninstalled out from under the one that owns it. + const root = await mkdtemp(join(tmpdir(), 'skillsearch-nested-')) + const deep = join(root, 'handwritten', 'vendor', 'nested') + await mkdir(deep, { recursive: true }) + provenance.writeMarker(deep, marker('hub', 'nested')) + assert.deepEqual(provenance.listInstalled(root), []) +}) + +test('slugDir matches the Python port, character for character', () => { + // Both ports install into the same directory on one machine, so a + // disagreement puts one skill in two directories — the duplication identity + // dedup exists to prevent. It stayed invisible because each suite only ever + // compared a port with itself; the non-ASCII and astral cases in the fixture + // are where they actually drifted. + const fixtures = JSON.parse( + readFileSync(new URL('./fixtures-slugdir.json', import.meta.url), 'utf8'), + ) as { cases: Array<{ source: string; slug: string; dir: string }> } + + for (const item of fixtures.cases) { + assert.equal( + provenance.slugDir('/r', item.source, item.slug).slice(3), + item.dir, + `${item.source}/${item.slug}`, + ) + } +}) diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs index 5426cb2..f3427e8 100755 --- a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs @@ -947,9 +947,11 @@ function now(clock = () => /* @__PURE__ */ new Date()) { return `${clock().toISOString().slice(0, 19)}+00:00`; } function slugDir(root, source, slug) { - const safeSource = String(source).replace(/[^A-Za-z0-9\-_]/g, "_").slice(0, 40); - const safeSlug = String(slug).replace(/[^A-Za-z0-9\-_.@]/g, "_").slice(0, 120); - return join4(root, `${safeSource}__${safeSlug || "skill"}`); + const sanitise = (text, allowed, limit) => [...String(text)].map((character) => allowed.test(character) ? character : "_").join("").slice(0, limit); + const safeSource = sanitise(source, /^[A-Za-z0-9\-_]$/, 40); + const safeSlug = sanitise(slug, /^[A-Za-z0-9\-_.@]$/, 120); + const digest = createHash2("sha256").update(identity(source, slug), "utf8").digest("hex").slice(0, 8); + return join4(root, `${safeSource}__${safeSlug || "skill"}__${digest}`); } function writeMarker(skillDir, origin) { const payload = { diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs index f8aab6b..353d287 100644 --- a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs @@ -938,9 +938,11 @@ function now(clock = () => /* @__PURE__ */ new Date()) { return `${clock().toISOString().slice(0, 19)}+00:00`; } function slugDir(root, source, slug) { - const safeSource = String(source).replace(/[^A-Za-z0-9\-_]/g, "_").slice(0, 40); - const safeSlug = String(slug).replace(/[^A-Za-z0-9\-_.@]/g, "_").slice(0, 120); - return join4(root, `${safeSource}__${safeSlug || "skill"}`); + const sanitise = (text2, allowed, limit) => [...String(text2)].map((character) => allowed.test(character) ? character : "_").join("").slice(0, limit); + const safeSource = sanitise(source, /^[A-Za-z0-9\-_]$/, 40); + const safeSlug = sanitise(slug, /^[A-Za-z0-9\-_.@]$/, 120); + const digest = createHash2("sha256").update(identity(source, slug), "utf8").digest("hex").slice(0, 8); + return join4(root, `${safeSource}__${safeSlug || "skill"}__${digest}`); } function writeMarker(skillDir, origin) { const payload = { From cd5f7bda9accca5605ebd8029a466e288adc239a Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 05:00:58 +0000 Subject: [PATCH 07/14] fix(plugin): a whitespace-only env var is unset, not an override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third divergence between the ports, found by finally doing the function-by-function differential I had said was missing rather than asserting the two agreed. `SKILLSEARCH_SKILLS_DIRS=" "` read as an override on the TypeScript side and as unset on the Python side: python ['/cfg'] typescript [] Emptying the list takes the host's own skills directory with it, and since a host with no directory of its own does not join the shared library, it silently takes cross-agent sharing too. A variable holding spaces is indistinguishable from an absent one to whoever set it, so Python's reading is the right one; `pick()` in all three TypeScript packages now trims before deciding. The empty-string case was already handled — this is the same judgement, one character wider. Pre-existing on that side, but this branch is what made it costly. `tests/parity/` is the tool that found it, checked in with its README. Nine comparisons on identical inputs, and the two that matter most are byte-level: Python writes a registry and a marker, TypeScript reads both and rewrites the registry, and the file must come out byte for byte identical. A shape-only comparison passes while the two quietly write different JSON. It is a hand-run tool, not a CI job, and the README says why: it needs both toolchains in one place and the pipeline runs the two languages in separate images. The durable half is the two shared fixtures, which do run in CI in both languages. Three bugs have now come out of this one blind spot — a fix applied to one port, two ports computing different install directories, and this. The README names all three, because the lesson is about the method: every suite here compares a port with itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../plugin-openclaw/src/config.ts | 9 +++- .../plugin-openclaw/test/register.test.ts | 16 ++++++ .../plugin-openclaw2/src/config.ts | 9 +++- .../plugin-openclaw2/test/register.test.ts | 18 ++++++- .../plugin-workbuddy/dist/hook.mjs | 2 +- .../plugin-workbuddy/dist/mcp.mjs | 2 +- .../plugin-workbuddy/src/config.ts | 9 +++- .../plugin-workbuddy/test/config.test.ts | 16 ++++++ skillcorpus_plugin/tests/parity/README.md | 51 +++++++++++++++++++ skillcorpus_plugin/tests/parity/_compare.py | 40 +++++++++++++++ skillcorpus_plugin/tests/parity/_py_side.py | 45 ++++++++++++++++ skillcorpus_plugin/tests/parity/_ts_side.ts | 46 +++++++++++++++++ skillcorpus_plugin/tests/parity/check.sh | 15 ++++++ 13 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 skillcorpus_plugin/tests/parity/README.md create mode 100644 skillcorpus_plugin/tests/parity/_compare.py create mode 100644 skillcorpus_plugin/tests/parity/_py_side.py create mode 100644 skillcorpus_plugin/tests/parity/_ts_side.ts create mode 100755 skillcorpus_plugin/tests/parity/check.sh diff --git a/skillcorpus_plugin/plugin-openclaw/src/config.ts b/skillcorpus_plugin/plugin-openclaw/src/config.ts index 60a9b22..af5d151 100644 --- a/skillcorpus_plugin/plugin-openclaw/src/config.ts +++ b/skillcorpus_plugin/plugin-openclaw/src/config.ts @@ -226,7 +226,14 @@ export function loadConfig( const pick = (key: K): unknown => { const variable = ENV_KEYS[key] const fromEnv = variable ? env[variable] : undefined - return fromEnv !== undefined && fromEnv !== '' ? fromEnv : document[key] + // Whitespace-only counts as unset, not as an override. A variable holding + // spaces is indistinguishable from an absent one to whoever set it, and + // treating it as a value is destructive rather than merely odd: for + // `SKILLSEARCH_SKILLS_DIRS` it emptied the list, taking the host's own + // skills directory with it — and, since a host with no directory of its + // own does not join the shared library, the cross-agent sharing too. The + // Python port already read it this way. + return fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : document[key] } return { diff --git a/skillcorpus_plugin/plugin-openclaw/test/register.test.ts b/skillcorpus_plugin/plugin-openclaw/test/register.test.ts index fa60aea..1f063b0 100644 --- a/skillcorpus_plugin/plugin-openclaw/test/register.test.ts +++ b/skillcorpus_plugin/plugin-openclaw/test/register.test.ts @@ -341,3 +341,19 @@ test('a source that is down is reported, not swallowed', async () => { assert.ok(complaint, `expected a source warning, got ${JSON.stringify(warnings)}`) assert.ok(!complaint.includes('apiKey'), 'a diagnostic must not carry a credential') }) + +test('a whitespace-only environment variable counts as unset', () => { + // It is indistinguishable from an absent one to whoever set it, and + // treating it as a value is destructive rather than odd: for + // `SKILLSEARCH_SKILLS_DIRS` it emptied the list, taking the host's own + // skills directory with it — and with no directory of its own a host does + // not join the shared library either, so cross-agent sharing went too. + // Measured against the Python port, which already read it this way. + const configured = { skillsDirs: ['/cfg'] } + assert.deepEqual(loadConfig(configured, {}).skillsDirs, ['/cfg']) + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: '' }).skillsDirs, ['/cfg']) + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: ' ' }).skillsDirs, ['/cfg']) + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: '\t\n' }).skillsDirs, ['/cfg']) + // A real value still wins. + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: '/e1,/e2' }).skillsDirs, ['/e1', '/e2']) +}) diff --git a/skillcorpus_plugin/plugin-openclaw2/src/config.ts b/skillcorpus_plugin/plugin-openclaw2/src/config.ts index 60a9b22..af5d151 100644 --- a/skillcorpus_plugin/plugin-openclaw2/src/config.ts +++ b/skillcorpus_plugin/plugin-openclaw2/src/config.ts @@ -226,7 +226,14 @@ export function loadConfig( const pick = (key: K): unknown => { const variable = ENV_KEYS[key] const fromEnv = variable ? env[variable] : undefined - return fromEnv !== undefined && fromEnv !== '' ? fromEnv : document[key] + // Whitespace-only counts as unset, not as an override. A variable holding + // spaces is indistinguishable from an absent one to whoever set it, and + // treating it as a value is destructive rather than merely odd: for + // `SKILLSEARCH_SKILLS_DIRS` it emptied the list, taking the host's own + // skills directory with it — and, since a host with no directory of its + // own does not join the shared library, the cross-agent sharing too. The + // Python port already read it this way. + return fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : document[key] } return { diff --git a/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts b/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts index 9f4ae7c..4fa0590 100644 --- a/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts +++ b/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts @@ -25,7 +25,7 @@ import test from 'node:test' // have installed. Pointed at a scratch directory before anything imports the // modules under test. process.env.SKILLSEARCH_HOME = mkdtempSync(join(tmpdir(), 'skillsearch-home-')) -import { DEFAULTS } from '../src/config.ts' +import { DEFAULTS, loadConfig } from '../src/config.ts' import { buildEngine, expandHome, recentUserText, register } from '../src/register.ts' import { VERSION } from '../src/version.ts' import type { @@ -382,3 +382,19 @@ test('a source that is down is reported, not swallowed', async () => { assert.ok(complaint, `expected a source warning, got ${JSON.stringify(warnings)}`) assert.ok(!complaint.includes('apiKey'), 'a diagnostic must not carry a credential') }) + +test('a whitespace-only environment variable counts as unset', () => { + // It is indistinguishable from an absent one to whoever set it, and + // treating it as a value is destructive rather than odd: for + // `SKILLSEARCH_SKILLS_DIRS` it emptied the list, taking the host's own + // skills directory with it — and with no directory of its own a host does + // not join the shared library either, so cross-agent sharing went too. + // Measured against the Python port, which already read it this way. + const configured = { skillsDirs: ['/cfg'] } + assert.deepEqual(loadConfig(configured, {}).skillsDirs, ['/cfg']) + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: '' }).skillsDirs, ['/cfg']) + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: ' ' }).skillsDirs, ['/cfg']) + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: '\t\n' }).skillsDirs, ['/cfg']) + // A real value still wins. + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: '/e1,/e2' }).skillsDirs, ['/e1', '/e2']) +}) diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs index f3427e8..2d84e64 100755 --- a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs @@ -151,7 +151,7 @@ function loadConfig(document, env = process.env) { const pick = (key) => { const variable = ENV_KEYS[key]; const fromEnv = variable ? env[variable] : void 0; - return fromEnv !== void 0 && fromEnv !== "" ? fromEnv : source[key]; + return fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : source[key]; }; return { skillsDirs: asList(pick("skillsDirs")) ?? DEFAULTS.skillsDirs, diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs index 353d287..b16cd59 100644 --- a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs @@ -142,7 +142,7 @@ function loadConfig(document, env = process.env) { const pick = (key) => { const variable = ENV_KEYS[key]; const fromEnv = variable ? env[variable] : void 0; - return fromEnv !== void 0 && fromEnv !== "" ? fromEnv : source[key]; + return fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : source[key]; }; return { skillsDirs: asList(pick("skillsDirs")) ?? DEFAULTS.skillsDirs, diff --git a/skillcorpus_plugin/plugin-workbuddy/src/config.ts b/skillcorpus_plugin/plugin-workbuddy/src/config.ts index d6d03b8..b6bdb31 100644 --- a/skillcorpus_plugin/plugin-workbuddy/src/config.ts +++ b/skillcorpus_plugin/plugin-workbuddy/src/config.ts @@ -317,7 +317,14 @@ export function loadConfig( const pick = (key: K): unknown => { const variable = ENV_KEYS[key] const fromEnv = variable ? env[variable] : undefined - return fromEnv !== undefined && fromEnv !== '' ? fromEnv : source[key] + // Whitespace-only counts as unset, not as an override. A variable holding + // spaces is indistinguishable from an absent one to whoever set it, and + // treating it as a value is destructive rather than merely odd: for + // `SKILLSEARCH_SKILLS_DIRS` it emptied the list, taking the host's own + // skills directory with it — and, since a host with no directory of its + // own does not join the shared library, the cross-agent sharing too. The + // Python port already read it this way. + return fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : source[key] } return { diff --git a/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts b/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts index dd8183c..d3b6cdd 100644 --- a/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts +++ b/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts @@ -165,3 +165,19 @@ test('the environment is what gets reported when the environment is wrong', () = assert.equal(loadConfig({ mode: 'auto' }, env).mode, 'on_demand') assert.equal(unknownMode({ mode: 'auto' }, env), 'aut0') }) + +test('a whitespace-only environment variable counts as unset', () => { + // It is indistinguishable from an absent one to whoever set it, and + // treating it as a value is destructive rather than odd: for + // `SKILLSEARCH_SKILLS_DIRS` it emptied the list, taking the host's own + // skills directory with it — and with no directory of its own a host does + // not join the shared library either, so cross-agent sharing went too. + // Measured against the Python port, which already read it this way. + const configured = { skillsDirs: ['/cfg'] } + assert.deepEqual(loadConfig(configured, {}).skillsDirs, ['/cfg']) + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: '' }).skillsDirs, ['/cfg']) + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: ' ' }).skillsDirs, ['/cfg']) + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: '\t\n' }).skillsDirs, ['/cfg']) + // A real value still wins. + assert.deepEqual(loadConfig(configured, { SKILLSEARCH_SKILLS_DIRS: '/e1,/e2' }).skillsDirs, ['/e1', '/e2']) +}) diff --git a/skillcorpus_plugin/tests/parity/README.md b/skillcorpus_plugin/tests/parity/README.md new file mode 100644 index 0000000..5f56015 --- /dev/null +++ b/skillcorpus_plugin/tests/parity/README.md @@ -0,0 +1,51 @@ +# Differential checks between the two ports + +The engine exists twice — `engine-python/` and `engine-typescript/` — and the +two are independent ports. Each package's own suite compares a port with +itself, which is exactly the blind spot that let three bugs ship into this +branch: + +1. a fix applied to Python and not to TypeScript (`listInstalled` walking past + a bundle's wrapper directory); +2. the two ports computing **different directory names** for one skill, because + Python's `str.isalnum` is Unicode-aware and a JS character class is ASCII — + so a CJK slug installed by Raven and by OpenClaw occupied two directories; +3. the two ports disagreeing on whether a whitespace-only environment variable + is an override, where TypeScript's answer silently emptied the host's skills + directory. + +None of those are visible from inside one port. All three crossed the boundary +that matters: **both ports read and write the same files, in one shared +directory, on one machine.** + +## What is pinned where + +| Contract | Fixture | Read by | +| --- | --- | --- | +| registry parsing | `engine-typescript/tests/fixtures-registry.json` | both suites | +| install directory naming | `engine-typescript/tests/fixtures-slugdir.json` | both suites | +| everything else below | `check.sh`, run by hand | — | + +The fixtures are the durable half: they run in CI, on every push, in both +languages. `check.sh` is the exploratory half — it needs both toolchains in one +place, which no CI job here has, so it is a tool for whoever is changing these +modules rather than a gate. + +## Running it + +```bash +cd skillcorpus_plugin/tests/parity && ./check.sh +``` + +It compares, on identical inputs: + +- `identity`, `bodyDigest`, `optedIn`, and the shape of `now()`; +- **the bytes**: Python writes a registry and a marker, TypeScript reads both, + rewrites the registry, and the file must come out byte-identical. This is the + strongest check here, and the one a shape-only comparison would miss; +- environment-variable precedence, including the whitespace case above. + +Not compared, deliberately: the directory fingerprint in `watch.py` / +`watch.ts`. It never leaves the process that computed it, so the two ports have +no contract there — only the *behaviour* has to match, and each suite asserts +that itself. diff --git a/skillcorpus_plugin/tests/parity/_compare.py b/skillcorpus_plugin/tests/parity/_compare.py new file mode 100644 index 0000000..14a8fec --- /dev/null +++ b/skillcorpus_plugin/tests/parity/_compare.py @@ -0,0 +1,40 @@ +"""Compare the two ports' output. Exits non-zero on any disagreement.""" + +import json +import sys + +py, ts = (json.load(open(p, encoding="utf-8")) for p in sys.argv[1:3]) + +CHECKS = [ + ("identity", py.get("identity"), ts.get("identity")), + ("bodyDigest", py.get("body_digest"), ts.get("body_digest")), + ("optedIn", py.get("opted_in"), ts.get("opted_in")), + ("now() shape", list(py.get("now_shape") or []), ts.get("now_shape")), + ("env precedence", py.get("env_precedence"), ts.get("env_precedence")), + # The strongest one: TypeScript rewrites what Python wrote, and the file + # must come out byte for byte the same. A shape-only comparison passes + # while the two quietly write different JSON. + ("registry bytes", py.get("registry_written"), ts.get("registry_after_ts_rewrite")), + ("marker bytes", py.get("marker_written"), ts.get("marker_written")), + ( + "TypeScript reads Python's registry", + ["raven", "openclaw2"], + [e["id"] for e in ts.get("reads_python_registry") or []], + ), + ( + "TypeScript reads Python's marker", + {"origin": "hub/s", "source": "hub", "slug": "s", "version": "1.0"}, + {k: (ts.get("reads_python_marker") or {}).get(k) for k in ("origin", "source", "slug", "version")}, + ), +] + +bad = 0 +for name, a, b in CHECKS: + if a == b: + print(f" ok {name}") + continue + bad += 1 + print(f" MISMATCH {name}\n python: {a!r}\n node: {b!r}") + +print("\nthe two ports agree" if not bad else f"\n{bad} disagreement(s)") +sys.exit(1 if bad else 0) diff --git a/skillcorpus_plugin/tests/parity/_py_side.py b/skillcorpus_plugin/tests/parity/_py_side.py new file mode 100644 index 0000000..a5c8cf9 --- /dev/null +++ b/skillcorpus_plugin/tests/parity/_py_side.py @@ -0,0 +1,45 @@ +"""One side of the differential. See README.md — the output is compared +against `_ts_side.ts` run on the same inputs.""" + +import json, os, sys, tempfile +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "engine-python")) +from skillsearch import provenance as pv, shared as sh, watch as wt + +out = {} +out["identity"] = [sh.HOME_ENV, pv.identity("hub","x"), pv.identity(" hub "," y "), pv.identity("","")] +out["body_digest"] = [pv.body_digest(""), pv.body_digest("abc"), pv.body_digest("中文\n"), pv.body_digest("🚀")] +out["now_shape"] = len(pv.now()), pv.now()[4], pv.now()[10], pv.now()[-6:] +out["opted_in"] = [sh.opted_in(v) for v in + [None, True, False, 1, 0, "true","True","TRUE","false","0","no","off","yes","on","", " ", "banana", 2, -1, 0.0, 1.5]] +out["configured_dirs"] = [ + sh.configured_dirs(None, {}), sh.configured_dirs("", {}), + sh.configured_dirs("/a, /b ,, /c", {}), sh.configured_dirs(["/a"," /b ",""], {}), + sh.configured_dirs("/x", {"SKILLSEARCH_SKILLS_DIRS":"/env1,/env2"}), + sh.configured_dirs("/x", {"SKILLSEARCH_SKILLS_DIRS":" "}), +] +# fingerprint 的字符串形状 +d = Path(tempfile.mkdtemp()) +(d/"a").mkdir(); (d/"a"/"SKILL.md").write_text("x") +os.utime(d/"a"/"SKILL.md", ns=(1234567890123456789, 1234567890123456789)) +fp = wt.fingerprint([d]) +out["fingerprint"] = fp.replace(str(d), "") +# registry 往返:Python 写 +reg = Path(tempfile.mkdtemp())/"registry.json" +sh.register_host("raven", d, reg) +sh.register_host("openclaw2", d.parent, reg) +out["registry_written"] = reg.read_text() +out["registry_path"] = str(reg) +# marker 往返:Python 写 +m = Path(tempfile.mkdtemp()) +pv.write_marker(m, pv.Origin(pv.identity("hub","s"), "hub", "s", "1.0", pv.body_digest("b"), "2026-01-01T00:00:00+00:00")) +out["marker_written"] = (m/pv.MARKER).read_text() +out["marker_path"] = str(m) +out["env_precedence"] = [ + sh.configured_dirs(["/cfg"], {}), + sh.configured_dirs(["/cfg"], {"SKILLSEARCH_SKILLS_DIRS": ""}), + sh.configured_dirs(["/cfg"], {"SKILLSEARCH_SKILLS_DIRS": " "}), + sh.configured_dirs(["/cfg"], {"SKILLSEARCH_SKILLS_DIRS": "\t\n"}), + sh.configured_dirs(["/cfg"], {"SKILLSEARCH_SKILLS_DIRS": "/e1,/e2"}), +] +print(json.dumps(out, ensure_ascii=False)) diff --git a/skillcorpus_plugin/tests/parity/_ts_side.ts b/skillcorpus_plugin/tests/parity/_ts_side.ts new file mode 100644 index 0000000..43ff5d4 --- /dev/null +++ b/skillcorpus_plugin/tests/parity/_ts_side.ts @@ -0,0 +1,46 @@ +/** + * One side of the differential. See README.md — this is run against the + * output of `_py_side.py` on the same inputs. + */ + +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pv from '../../engine-typescript/src/provenance.ts' +import * as sh from '../../engine-typescript/src/shared.ts' +import * as wt from '../../engine-typescript/src/watch.ts' +import { loadConfig as ocLoadConfig } from '../../plugin-openclaw/src/config.ts' + +const py = JSON.parse(readFileSync(process.argv[2] ?? 'py.json', 'utf8')) +const out: Record = {} +out.identity = [sh.HOME_ENV, pv.identity('hub','x'), pv.identity(' hub ',' y '), pv.identity('','')] +out.body_digest = [pv.bodyDigest(''), pv.bodyDigest('abc'), pv.bodyDigest('中文\n'), pv.bodyDigest('🚀')] +const n = pv.now() +out.now_shape = [n.length, n[4], n[10], n.slice(-6)] +out.opted_in = [null, true, false, 1, 0, 'true','True','TRUE','false','0','no','off','yes','on','',' ','banana',2,-1,0.0,1.5] + .map(v => sh.optedIn(v)) + +// fingerprint +const d = mkdtempSync(join(tmpdir(),'fp-')) +mkdirSync(join(d,'a')); writeFileSync(join(d,'a','SKILL.md'),'x') +const t = new Date(1234567890123.456789) +utimesSync(join(d,'a','SKILL.md'), t, t) +out.fingerprint_shape = wt.fingerprint([d]).replace(d,'').replace(/:\d+$/,':') + +// 交叉读:Python 写的 registry / marker,TS 能不能一模一样地读出来 +out.reads_python_registry = sh.readRegistry(py.registry_path) +out.reads_python_marker = pv.readMarker(py.marker_path) +// TS 重写一遍,字节要和 Python 写的一致 +sh.registerHost('raven', sh.readRegistry(py.registry_path)[0].dir, py.registry_path) +out.registry_after_ts_rewrite = readFileSync(py.registry_path,'utf8') +const m2 = mkdtempSync(join(tmpdir(),'mk-')) +pv.writeMarker(m2, { origin: pv.identity('hub','s'), source:'hub', slug:'s', version:'1.0', + sha256: pv.bodyDigest('b'), installedAt:'2026-01-01T00:00:00+00:00' }) +out.marker_written = readFileSync(join(m2, pv.MARKER),'utf8') +// Environment precedence, including the whitespace case the ports disagreed +// on: a variable holding only spaces must read as unset, not as an override +// that empties the list. +out.env_precedence = [{}, { SKILLSEARCH_SKILLS_DIRS: '' }, { SKILLSEARCH_SKILLS_DIRS: ' ' }, + { SKILLSEARCH_SKILLS_DIRS: '\t\n' }, { SKILLSEARCH_SKILLS_DIRS: '/e1,/e2' }] + .map(env => ocLoadConfig({ skillsDirs: ['/cfg'] }, env).skillsDirs) +console.log(JSON.stringify(out)) diff --git a/skillcorpus_plugin/tests/parity/check.sh b/skillcorpus_plugin/tests/parity/check.sh new file mode 100755 index 0000000..50708a7 --- /dev/null +++ b/skillcorpus_plugin/tests/parity/check.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Compare the two ports on identical inputs. See README.md. +# +# Needs python3 and npx in one place, which is why this is a hand-run tool +# rather than a CI job: the pipeline runs the two languages in separate images. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +python3 "$here/_py_side.py" > "$work/py.json" +# Run in place: the TypeScript side imports the engine by relative path, so +# copying it to a scratch directory would break every one of those. +(cd "$here" && npx --yes tsx _ts_side.ts "$work/py.json") > "$work/ts.json" +python3 "$here/_compare.py" "$work/py.json" "$work/ts.json" From 8677687b44c57f9fa34079287bec19ca447502bb Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 05:53:13 +0000 Subject: [PATCH 08/14] feat(plugin): record removals, and verify the acceptance list as written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-read the spec's nine acceptance items against what had actually been run, rather than against my summary of it, and four did not hold up. **Acceptance 7 was missing a feature, not a test.** "卸载 → 目录消失、记录留痕、 检索不到" — uninstall deleted the directory and left nothing behind. A user asking what this plugin ever put on their machine had no way to find out, and a skill that vanished was indistinguishable from one never installed. Removals now append to `/uninstalled.log`: JSON Lines, never rewritten, a sibling of `skills/` rather than a file inside it, because everything under `skills/` is walked by the scanner every turn. Its last clause is now asserted by retrieving rather than by looking at the disk. "The ledger is empty" and "the engine no longer answers with it" are different facts, and only the second is what the spec asks for. **Acceptance 3 was never actually run.** "打开 agent B → 也能检索到它", where "它" is the skill A installed in item 1. I had verified B seeing a skill in A's own directory (item 4) and B seeing a hand-dropped one (item 5), and reported those as item 3. The chain now runs end to end: Raven retrieves against the live EverMind SkillHub, which installs into the shared directory; OpenClaw — a different host, the other language port, its own process — then retrieves it with nothing told to it. Uninstall is verified across hosts the same way. Two harness defects surfaced by adding those, both of which made a working feature look broken: - the drop test reused the standard `pdf-tables` fixture, which competes with the catalogue skill for the same query while the hosts run `topK: 1`. It was measuring which of the two ranked higher. Given its own subject now; - the chain asserted on one particular installed skill's name, but a retrieval installs whatever the catalogue ranked — usually several — so with one slot the assertion measured the ranker. Any installed skill satisfies it now. And one thing the harness could not previously distinguish: retrieval fails open, so a catalogue that was unreachable for a run looked exactly like an install that did not happen. That is a flaky service versus a broken plugin, and only the second is a failure — it now reports BLOCKED and says so. `README.md` gains a shared-library section and, more importantly, a correction: "What leaves your machine" still described downloads landing in a throwaway cache outside every scanned directory. They are kept now, and where they are kept is exactly the kind of thing that section exists to state plainly. Verified after the change: ten checks across two real hosts, all passing, and the install run against the live catalogue. Co-Authored-By: Claude Opus 5 (1M context) --- skillcorpus_plugin/README.md | 39 +++- .../engine-python/skillsearch/provenance.py | 72 +++++++- .../engine-python/tests/test_provenance.py | 85 +++++++++ .../engine-typescript/src/provenance.ts | 92 +++++++++- .../engine-typescript/tests/parity.test.ts | 35 ++++ .../plugin-workbuddy/dist/hook.mjs | 16 +- .../plugin-workbuddy/dist/mcp.mjs | 8 +- .../tests/host-e2e/scripts/e2e_shared.py | 170 +++++++++++++++++- 8 files changed, 490 insertions(+), 27 deletions(-) diff --git a/skillcorpus_plugin/README.md b/skillcorpus_plugin/README.md index 15c2490..606c007 100644 --- a/skillcorpus_plugin/README.md +++ b/skillcorpus_plugin/README.md @@ -135,12 +135,49 @@ Honest accounting, because retrieval runs on your conversation: - **Local-only setup (after explicitly disabling the three remote endpoints)** — nothing. Scanning, ranking and injection are all in-process. - **Default installation** — EverMind SkillHub, ClawHub, and skillhub.cn are enabled; the retrieval query is sent to all three services. Set any endpoint field to an empty string to disable that source. With no `model`, no LLM gate runs: source safety checks and the EverMind lexical relevance guard still apply. -- **EverMind SkillHub** — selected skills' bodies and bundles are downloaded from it. Bundles are unzipped with path-traversal rejection, an extension allowlist, and 8 MiB/file, 64 MiB/archive caps, into a cache directory outside every scanned skills dir (`~/.workbuddy-ai/skillsearch-bundles`, `~/.skillsearch/hub`, `~/.openclaw/skillsearch-bundles`, or `~/.dsh/skillsearch-bundles` by default). +- **EverMind SkillHub** — selected skills' bodies and bundles are downloaded from it. Bundles are unzipped with path-traversal rejection, an extension allowlist, and 8 MiB/file, 64 MiB/archive caps, and are **kept** in the shared skills library (`~/.evermind-skillsearch/skills/`) rather than discarded after the turn. Each keeps a `.skillsearch-origin.json` saying what it is and when it arrived, removals are appended to `~/.evermind-skillsearch/uninstalled.log`, and `shareSkills: false` / `share_skills: false` puts a host back on the old throwaway cache. - **Marketplace body fetches** — up to two candidates per enabled marketplace are downloaded and safely extracted before the optional LLM gate, because those APIs expose the skill body through the bundle. A rejected candidate may therefore remain in the cache, but the plugin never executes it automatically. - **With `model` set** — the rewriter sees your message (truncated to 2,000 chars); the gate sees your message plus candidate names, descriptions and 300-char body excerpts. Both go to the model *you* configured, through the host's own provider where the host offers one. Downloaded skills are third-party content that the model is instructed to follow. ClawHub and skillhub.cn entries are not covered by SkillCorpus’s repository-license audit; review their upstream terms before redistribution. The gate can reject skills that assume unavailable tools or environments, but it only exists when a model is configured. +## The shared skills library + +By default every host scans one directory of its own, so a skill you have in +one agent is invisible to the other four. From 0.4.0 they also share one: + +```text +~/.evermind-skillsearch/ +├── registry.json which agent keeps its skills where +├── uninstalled.log what was removed, and when +└── skills/ what retrieval installed, one directory per skill +``` + +Nothing is hardcoded about *your* agents. Each host writes the skills +directory it actually resolved into `registry.json` when it starts, and reads +the others back — so the set is exactly "the agents that also have this plugin", +and moving your skills directory is picked up on the next start. + +`registry.json` is meant to be edited. To stop other agents reading one +directory, set its `enabled` to `false`; deleting the line does not work, +because that agent re-registers on its next start. That is the opposite switch +from `shareSkills` / `share_skills` in a host's own config, which stops *that +host* reading everyone else: + +| You want | Set | +| --- | --- | +| others not to see my skills | `enabled: false` on my line in `registry.json` | +| me not to see theirs | `shareSkills: false` in my own host config | + +Changes to the shared directory take effect **on the next turn**, with no +restart — drop a skill in by hand and the next question can find it. Changes +inside a host's own directory keep that host's existing behaviour, which for +four of the five still means restarting. + +`SKILLSEARCH_HOME` moves the root. Treat it as advanced: a GUI-launched agent +never reads your shell profile, so the two would disagree about where the +library is. + ## Make your skills findable Since retrieval indexes **name and description** (deliberately — the body is where stopword noise lives), the description is your skill's search surface. Write the situations, not just the topic: diff --git a/skillcorpus_plugin/engine-python/skillsearch/provenance.py b/skillcorpus_plugin/engine-python/skillsearch/provenance.py index 4c20513..b7d2426 100644 --- a/skillcorpus_plugin/engine-python/skillsearch/provenance.py +++ b/skillcorpus_plugin/engine-python/skillsearch/provenance.py @@ -278,19 +278,87 @@ def swap_into_place(staging: str | os.PathLike[str], dest: str | os.PathLike[str shutil.rmtree(retired, ignore_errors=True) -def uninstall(root: str | os.PathLike[str], origin: str) -> bool: +#: Beside the registry, one level up from the skills directory. Append-only. +LOG = "uninstalled.log" + + +def uninstall_log(root: str | os.PathLike[str]) -> Path: + """Where removals are recorded, given the skills directory. + + A sibling of the skills directory rather than a file inside it: anything + under ``skills/`` is walked by the scanner, and a log that grows there + would be one more thing every host reads every turn for no reason. + """ + return Path(root).parent / LOG + + +def record_uninstall(root: str | os.PathLike[str], removed: Origin, log_path: Path | None = None) -> bool: + """Append one removal to the log. ``False`` if it could not be written. + + Installing puts files on someone's disk, so removing them has to leave + something behind — otherwise a user who wants to know what this plugin + ever put on this machine has no way to find out, and a skill that vanished + is indistinguishable from one that was never there. + + JSON Lines, appended, never rewritten. A log that is only ever appended to + cannot lose an earlier entry to a crash halfway through, and `list_installed` + stays the authority on what is *present* — this answers a different + question, which is what *was*. + """ + target = log_path or uninstall_log(root) + entry = {"removed_at": now(), **removed.as_json()} + entry.pop("_note", None) + try: + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + except OSError: + return False + return True + + +def read_uninstalled(root: str | os.PathLike[str], log_path: Path | None = None) -> list[dict[str, object]]: + """Every removal recorded, oldest first. Unreadable lines are skipped. + + A corrupt line costs that one record rather than the whole history, which + matters for an append-only file that several processes write. + """ + target = log_path or uninstall_log(root) + out: list[dict[str, object]] = [] + try: + text = target.read_text(encoding="utf-8") + except OSError: + return out + for line in text.splitlines(): + if not line.strip(): + continue + try: + record = json.loads(line) + except ValueError: + continue + if isinstance(record, dict): + out.append(record) + return out + + +def uninstall(root: str | os.PathLike[str], origin: str, log_path: Path | None = None) -> bool: """Remove an installed skill by identity. ``False`` if it was not there. Moved aside and then deleted, so a half-finished delete cannot leave a - directory the scanner still reads as a skill. + directory the scanner still reads as a skill. The removal is recorded + first: a log entry for a skill that is still on disk is a puzzle, while a + skill removed with no entry is a hole in the history. """ found = find_installed(root, origin) if found is None: return False + removed = next((m for d, m in _entries(root) if d == found), None) retired = found.with_name(f"{found.name}.removing-{os.getpid()}-{os.urandom(4).hex()}") try: os.replace(found, retired) except OSError: return False shutil.rmtree(retired, ignore_errors=True) + if removed is not None: + record_uninstall(root, removed, log_path) return True diff --git a/skillcorpus_plugin/engine-python/tests/test_provenance.py b/skillcorpus_plugin/engine-python/tests/test_provenance.py index e84da0a..0d3075b 100644 --- a/skillcorpus_plugin/engine-python/tests/test_provenance.py +++ b/skillcorpus_plugin/engine-python/tests/test_provenance.py @@ -318,3 +318,88 @@ def test_slug_dir_matches_the_typescript_port(case: dict, tmp_path: Path) -> Non because each suite only ever compared a port with itself. """ assert provenance.slug_dir(tmp_path, case["source"], case["slug"]).name == case["dir"] + + +def test_uninstalling_leaves_a_record(tmp_path: Path) -> None: + """Acceptance 7's middle clause, which was missing entirely. + + Installing puts files on someone's disk. Removing them has to leave + something behind, or a user asking "what did this thing ever put here" + has no way to find out, and a skill that vanished is indistinguishable + from one that was never installed. + """ + root = tmp_path / "skills" + root.mkdir() + _install(root, "hub", "pdf-tables", "1.0") + + assert provenance.uninstall(root, "hub/pdf-tables") is True + + # Beside the registry, not inside `skills/` — the scanner walks that. + log = provenance.uninstall_log(root) + assert log == tmp_path / "uninstalled.log" + records = provenance.read_uninstalled(root) + assert len(records) == 1 + assert records[0]["origin"] == "hub/pdf-tables" + assert records[0]["skill_version"] == "1.0" + assert records[0]["removed_at"] + assert records[0]["installed_at"] + + +def test_the_log_is_appended_never_rewritten(tmp_path: Path) -> None: + """An append-only file cannot lose an earlier entry to a crash.""" + root = tmp_path / "skills" + root.mkdir() + for slug in ("a", "b", "c"): + _install(root, "hub", slug) + provenance.uninstall(root, f"hub/{slug}") + assert [r["origin"] for r in provenance.read_uninstalled(root)] == ["hub/a", "hub/b", "hub/c"] + + +def test_a_corrupt_line_costs_one_record_not_the_history(tmp_path: Path) -> None: + root = tmp_path / "skills" + root.mkdir() + _install(root, "hub", "a") + provenance.uninstall(root, "hub/a") + log = provenance.uninstall_log(root) + log.write_text(log.read_text() + "{not json\n" + '{"origin": "hub/b"}\n', encoding="utf-8") + assert [r["origin"] for r in provenance.read_uninstalled(root)] == ["hub/a", "hub/b"] + + +def test_a_removal_that_did_not_happen_is_not_recorded(tmp_path: Path) -> None: + root = tmp_path / "skills" + root.mkdir() + assert provenance.uninstall(root, "hub/never-installed") is False + assert provenance.read_uninstalled(root) == [] + + +@pytest.mark.asyncio +async def test_an_uninstalled_skill_stops_being_retrievable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Acceptance 7's last clause, asserted by retrieving rather than by + looking at the disk — the ledger being empty and the engine still + answering are different facts.""" + from skillsearch import shared + + monkeypatch.setenv(shared.HOME_ENV, str(tmp_path / "home")) + shared_skills = shared.shared_skills_dir() + shared_skills.mkdir(parents=True) + _install(shared_skills, "hub", "pdf-tables") + + engine = SkillSearch( + SearchConfig.from_mapping( + { + "skills_dir": str(tmp_path / "own"), + "extra_dirs": [{"path": str(shared_skills), "name": "shared"}], + "hub_endpoint": "", + "clawhub_endpoint": "", + "skillhub_cn_endpoint": "", + "top_k": 5, + } + ) + ) + assert "pdf-tables" in await engine.retrieve("extract tables from a PDF into CSV") + + assert provenance.uninstall(shared_skills, "hub/pdf-tables") is True + + # No restart, no invalidate() by hand — the directory watch notices. + assert "pdf-tables" not in await engine.retrieve("extract tables from a PDF into CSV") + assert [r["origin"] for r in provenance.read_uninstalled(shared_skills)] == ["hub/pdf-tables"] diff --git a/skillcorpus_plugin/engine-typescript/src/provenance.ts b/skillcorpus_plugin/engine-typescript/src/provenance.ts index 250f152..e59eb43 100644 --- a/skillcorpus_plugin/engine-typescript/src/provenance.ts +++ b/skillcorpus_plugin/engine-typescript/src/provenance.ts @@ -32,8 +32,8 @@ */ import { createHash } from 'node:crypto' -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' /** * Inside the skill's own directory. Dotted so a host's own scanner ignores it, @@ -304,9 +304,94 @@ export function swapIntoPlace(staging: string, dest: string): void { * Moved aside and then deleted, so a half-finished delete cannot leave a * directory the scanner still reads as a skill. */ -export function uninstall(root: string, origin: string): boolean { +/** Beside the registry, one level up from the skills directory. Append-only. */ +export const LOG = 'uninstalled.log' + +/** + * Where removals are recorded, given the skills directory. + * + * A sibling of the skills directory rather than a file inside it: anything + * under `skills/` is walked by the scanner, and a log growing there would be + * one more thing every host reads every turn for no reason. + */ +export function uninstallLog(root: string): string { + return join(dirname(root), LOG) +} + +/** + * Append one removal to the log. `false` if it could not be written. + * + * Installing puts files on someone's disk, so removing them has to leave + * something behind — otherwise a user who wants to know what this plugin ever + * put on this machine has no way to find out, and a skill that vanished is + * indistinguishable from one that was never there. + * + * JSON Lines, appended, never rewritten: an append-only file cannot lose an + * earlier entry to a crash halfway through. `listInstalled` stays the + * authority on what is *present*; this answers what *was*. + */ +export function recordUninstall(root: string, removed: Origin, logPath?: string): boolean { + const target = logPath ?? uninstallLog(root) + const entry = { + removed_at: now(), + version: 1, + origin: removed.origin, + source: removed.source, + slug: removed.slug, + skill_version: removed.version, + sha256: removed.sha256, + installed_at: removed.installedAt, + } + try { + mkdirSync(dirname(target), { recursive: true }) + appendFileSync(target, `${JSON.stringify(entry)}\n`, 'utf8') + return true + } catch { + return false + } +} + +/** + * Every removal recorded, oldest first. Unreadable lines are skipped. + * + * A corrupt line costs that one record rather than the whole history, which + * matters for an append-only file several processes write. + */ +export function readUninstalled(root: string, logPath?: string): Array> { + const target = logPath ?? uninstallLog(root) + let text: string + try { + text = readFileSync(target, 'utf8') + } catch { + return [] + } + const out: Array> = [] + for (const line of text.split('\n')) { + if (!line.trim()) continue + try { + const record: unknown = JSON.parse(line) + if (record && typeof record === 'object' && !Array.isArray(record)) { + out.push(record as Record) + } + } catch { + // One bad line, not the whole history. + } + } + return out +} + +/** + * Remove an installed skill by identity. `false` if it was not there. + * + * Moved aside and then deleted, so a half-finished delete cannot leave a + * directory the scanner still reads as a skill. The removal is recorded after + * the directory is gone: a log entry for a skill still on disk is a puzzle, + * while a skill removed with no entry is a hole in the history. + */ +export function uninstall(root: string, origin: string, logPath?: string): boolean { const found = findInstalled(root, origin) if (!found) return false + const removed = entries(root).find(([directory]) => directory === found)?.[1] const retired = scratchName(found, 'removing') try { renameSync(found, retired) @@ -314,5 +399,6 @@ export function uninstall(root: string, origin: string): boolean { return false } rmSync(retired, { recursive: true, force: true }) + if (removed) recordUninstall(root, removed, logPath) return true } diff --git a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts index 27108c4..7f8f7ee 100644 --- a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts +++ b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts @@ -878,3 +878,38 @@ test('slugDir matches the Python port, character for character', () => { ) } }) + +test('uninstalling leaves a record, appended and never rewritten', async () => { + // Acceptance 7's middle clause, which was missing from both ports. + // Installing puts files on someone's disk; removing them has to leave + // something behind, or a skill that vanished is indistinguishable from one + // that was never installed. + const home = await mkdtemp(join(tmpdir(), 'skillsearch-log-')) + const root = join(home, 'skills') + await mkdir(root) + + for (const slug of ['a', 'b', 'c']) { + await installed(root, 'hub', slug, '1.0') + assert.equal(provenance.uninstall(root, `hub/${slug}`), true) + } + + // Beside the registry, not inside `skills/` — the scanner walks that. + assert.equal(provenance.uninstallLog(root), join(home, 'uninstalled.log')) + const records = provenance.readUninstalled(root) + assert.deepEqual(records.map(r => r.origin), ['hub/a', 'hub/b', 'hub/c']) + assert.equal(records[0].skill_version, '1.0') + assert.ok(records[0].removed_at) + + // A corrupt line costs that record, not the history. + const log = provenance.uninstallLog(root) + await writeFile(log, `${readFileSync(log, 'utf8')}{not json\n{"origin":"hub/d"}\n`) + assert.deepEqual(provenance.readUninstalled(root).map(r => r.origin), + ['hub/a', 'hub/b', 'hub/c', 'hub/d']) + + // A removal that did not happen is not recorded. + const fresh = await mkdtemp(join(tmpdir(), 'skillsearch-log2-')) + const emptyRoot = join(fresh, 'skills') + await mkdir(emptyRoot) + assert.equal(provenance.uninstall(emptyRoot, 'hub/never-installed'), false) + assert.deepEqual(provenance.readUninstalled(emptyRoot), []) +}) diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs index 2d84e64..fed6abd 100755 --- a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node // src/hook.ts -import { appendFileSync, mkdirSync as mkdirSync5 } from "node:fs"; -import { dirname as dirname3 } from "node:path"; +import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5 } from "node:fs"; +import { dirname as dirname4 } from "node:path"; // src/config.ts import { readFileSync } from "node:fs"; @@ -933,8 +933,8 @@ import { join as join6 } from "node:path"; // ../engine-typescript/src/provenance.ts import { createHash as createHash2 } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; -import { join as join4 } from "node:path"; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; +import { dirname as dirname2, join as join4 } from "node:path"; var MARKER = ".skillsearch-origin.json"; var NOTE = "Written by the skillsearch plugin. Delete the directory to uninstall."; function identity(source, slug) { @@ -1917,7 +1917,7 @@ function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { // src/cached-local-source.ts import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync3, renameSync as renameSync3, statSync as statSync5, writeFileSync as writeFileSync3 } from "node:fs"; -import { dirname as dirname2, join as join10 } from "node:path"; +import { dirname as dirname3, join as join10 } from "node:path"; // ../engine-typescript/src/local-source.ts import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises"; @@ -2183,7 +2183,7 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } write(file) { try { - mkdirSync3(dirname2(this.cachePath), { recursive: true }); + mkdirSync3(dirname3(this.cachePath), { recursive: true }); const temp = `${this.cachePath}.${process.pid}.tmp`; writeFileSync3(temp, JSON.stringify(file)); renameSync3(temp, this.cachePath); @@ -2412,8 +2412,8 @@ function resultFor(block) { function log(config, entry) { if (!config.logPath) return; try { - mkdirSync5(dirname3(config.logPath), { recursive: true }); - appendFileSync(config.logPath, `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry })} + mkdirSync5(dirname4(config.logPath), { recursive: true }); + appendFileSync2(config.logPath, `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry })} `); } catch { } diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs index b16cd59..f1f6edc 100644 --- a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs @@ -924,8 +924,8 @@ import { join as join6 } from "node:path"; // ../engine-typescript/src/provenance.ts import { createHash as createHash2 } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; -import { join as join4 } from "node:path"; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync, rmSync, statSync as statSync3, writeFileSync } from "node:fs"; +import { dirname as dirname2, join as join4 } from "node:path"; var MARKER = ".skillsearch-origin.json"; var NOTE = "Written by the skillsearch plugin. Delete the directory to uninstall."; function identity(source, slug) { @@ -1908,7 +1908,7 @@ function scanDirs(hostId, ownDirs, share = true, path, env = process.env) { // src/cached-local-source.ts import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync3, renameSync as renameSync3, statSync as statSync5, writeFileSync as writeFileSync3 } from "node:fs"; -import { dirname as dirname2, join as join10 } from "node:path"; +import { dirname as dirname3, join as join10 } from "node:path"; // ../engine-typescript/src/local-source.ts import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises"; @@ -2174,7 +2174,7 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } write(file) { try { - mkdirSync3(dirname2(this.cachePath), { recursive: true }); + mkdirSync3(dirname3(this.cachePath), { recursive: true }); const temp = `${this.cachePath}.${process.pid}.tmp`; writeFileSync3(temp, JSON.stringify(file)); renameSync3(temp, this.cachePath); diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py index 0551915..d100890 100644 --- a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py @@ -13,15 +13,17 @@ Covers, from the spec's own list: - 3 a skill in agent A's own directory is retrievable in agent B + 3 a skill agent A **installed from the catalogue** is retrievable in + agent B — the chain, not either half of it + 4 a skill in agent A's own directory is retrievable in agent B 5 a skill dropped into the shared directory by hand reaches both, with neither restarted 6 `enabled: false` hides A's directory from B, and survives A restarting + 7 uninstalling in B stops A retrieving it, and leaves a record 9 a corrupt registry costs sharing and not retrieval -Acceptance 1, 2, 4, 7 and 8 are about installing, which needs a live -catalogue; they stay unit-tested and this file says so rather than implying -otherwise. +Acceptance 8 is not here: it is a property of the swap primitive, not of two +hosts, and `engine-python/tests` asserts it directly. Usage: @@ -62,6 +64,54 @@ RAVEN_FACTS = ("Wombat-Ledger-7", "Tapir Threshold") RAVEN_PROMPT = "What is our internal procedure for auditing supplier invoices?" +#: The hand-dropped skill, on a subject nothing else here touches. +#: +#: It used to reuse the standard `pdf-tables` fixture, which stopped working +#: the moment this script also installed a real catalogue skill: both are about +#: extracting tables from PDFs, the hosts run with `topK: 1`, and the +#: catalogue's outranked the dropped one. The case then failed while the +#: feature worked — the drop had been found, it just lost the only slot. +DROPPED_SKILL = """\ +--- +name: rotate-signing-keys +description: Rotate the service signing keys and re-issue downstream credentials safely. +--- + +House procedure: stage the new key under the `Narwhal-KMS-4` alias and keep the +previous one live until the `Quokka Cutover` window closes. +""" +DROPPED_FACTS = ("Narwhal-KMS-4", "Quokka Cutover") +DROPPED_PROMPT = "What is our internal procedure for rotating signing keys?" + + +def skill_names(root: Path) -> list[str]: + """The frontmatter name of every skill under `root`. + + Every one, not the first: a retrieval installs whatever the catalogue + ranked, which is regularly more than one skill, and the hosts run with + `topK: 1`. Asserting on one particular installed name therefore measures + which of them the ranker preferred rather than whether the other host can + see what this one installed. + + The frontmatter name, not the install slug: the block the model sees + prints the former and for a catalogue skill the two differ. Looks one + level down too, because a bundle usually wraps the skill in a directory. + """ + out: list[str] = [] + for entry in sorted(root.iterdir()) if root.is_dir() else (): + if not entry.is_dir(): + continue + for candidate in (entry, *sorted(p for p in entry.iterdir() if p.is_dir())): + head = candidate / "SKILL.md" + if not head.is_file(): + continue + for line in head.read_text(encoding="utf-8", errors="replace").splitlines()[:10]: + if line.startswith("name:"): + out.append(line.split(":", 1)[1].strip()) + break + break + return out + def run_openclaw(openclaw: Path, profile: str, prompt: str, env: dict) -> dict: """One OpenClaw turn, reading the answer off the host's own transcript.""" @@ -140,7 +190,66 @@ def check(name: str, ok: bool, detail: str) -> None: f"registry.json now holds {[e['id'] for e in registered]}" + ("" if probe.returncode == 0 else f"; stderr={probe.stderr[-400:]}")) - # -- Acceptance 3/4: OpenClaw finds Raven's skill ---------------------- + # -- Acceptance 3: Raven installs from the catalogue, OpenClaw sees it - + # + # The chain, run end to end rather than assembled from its halves. Raven + # retrieves against the live hub, which installs into the shared + # directory; OpenClaw — a different host, a different language port, its + # own process — then has to find that skill without being told anything. + # Reports the block it got as well as what landed. Retrieval fails open, so + # "the catalogue was unreachable this run" and "nothing installed" look + # identical from the outside — the distinction is between a flaky service + # and a broken plugin, and only the second is a failure. + install_probe = subprocess.run( + [args.raven_python, "-c", + "import sys, site, json, asyncio;" + f"sys.path.insert(0, {str(args.raven)!r});" + f"site.addsitedir({args.raven_site!r});" + "from skillsearch import provenance, shared;" + "from skillsearch.config import SearchConfig;" + "from skillsearch.engine import SkillSearch;" + "e = SkillSearch(SearchConfig.from_mapping({" + f" 'skills_dir': {str(raven_ws / 'skills')!r}," + " 'hub_endpoint': 'https://skillhub.evermind.ai'," + " 'clawhub_endpoint': '', 'skillhub_cn_endpoint': ''," + " 'top_k': 2, 'model': '', 'gate': False}));" + "block = asyncio.run(e.retrieve('extract tables from a PDF'));" + "print(json.dumps({'block': len(block), 'installed': " + "[o.as_json() for o in provenance.list_installed(shared.shared_skills_dir())]}))"], + capture_output=True, text=True, timeout=300, check=False, env=raven_env, + ) + probe: dict = {} + if install_probe.returncode == 0 and install_probe.stdout.strip(): + probe = json.loads(install_probe.stdout.strip().splitlines()[-1]) + catalogue = probe.get("installed") or [] + if not catalogue and probe.get("block") == 0: + # The service answered with nothing, or not at all. Not a verdict on + # the plugin; say so rather than reporting a red that a rerun clears. + results["catalogue"] = {"pass": None, "detail": "BLOCKED — the catalogue returned nothing"} + print(" BLOCKED the catalogue returned nothing this run; acceptance 3 and 7 skipped") + else: + detail = f"{[c['origin'] for c in catalogue]}, retrieval block {probe.get('block')} chars" + if install_probe.returncode != 0: + detail += f"; stderr={install_probe.stderr[-300:]}" + check("raven installs a catalogue skill into the shared directory", bool(catalogue), detail) + + e2e_openclaw.write_profile( + home / ".openclaw-shared-e2e", args.generation, "on_demand", oc_skills, + home / "oc-workspace", model, + ) + installed_names: list[str] = [] + if catalogue: + installed_names = skill_names(shared_home / "skills") + turn = run_openclaw(Path(args.openclaw), "shared-e2e", + "How do I pull the tables out of a PDF report?", env) + seen = "\n".join(turn["tool_results"]) + "\n" + turn["reply"] + found = [name for name in installed_names if name and name in seen] + check("openclaw retrieves a skill raven installed from the catalogue", + bool(found), + f"found {found} of {installed_names}; " + f"tools={[c['name'] for c in turn['tool_calls']]}") + + # -- Acceptance 4: OpenClaw finds Raven's own hand-written skill ------- e2e_openclaw.write_profile( home / ".openclaw-shared-e2e", args.generation, "on_demand", oc_skills, home / "oc-workspace", model, @@ -152,15 +261,58 @@ def check(name: str, ok: bool, detail: str) -> None: f"tools={[c['name'] for c in turn['tool_calls']]} reply={turn['reply'][:160]!r}") # -- Acceptance 5: dropped in by hand, no restart ---------------------- - dropped = shared_home / "skills" / "pdf-tables" + # + # Its own subject, deliberately. The hosts run with `topK: 1`, so a fixture + # that competes with the catalogue skill installed above measures which of + # the two ranks higher rather than whether the drop was noticed. + dropped = shared_home / "skills" / "rotate-signing-keys" dropped.mkdir(parents=True) - (dropped / "SKILL.md").write_text(_e2e.SKILL_BODY, encoding="utf-8") - turn = run_openclaw(Path(args.openclaw), "shared-e2e", _e2e.PROMPT_INTERNAL, env) + (dropped / "SKILL.md").write_text(DROPPED_SKILL, encoding="utf-8") + turn = run_openclaw(Path(args.openclaw), "shared-e2e", DROPPED_PROMPT, env) delivered = "\n".join(turn["tool_results"]) + "\n" + turn["reply"] check("a skill dropped into the shared directory is found without a restart", - _e2e.sentinel_in(delivered), + all(fact in delivered for fact in DROPPED_FACTS), f"tools={[c['name'] for c in turn['tool_calls']]} reply={turn['reply'][:160]!r}") + # -- Acceptance 7: uninstall, across hosts ----------------------------- + # + # Removed through the Python port, observed from the TypeScript host — the + # direction that matters, since a ledger the other side cannot see is not + # a shared library. + if catalogue: + origin = catalogue[0]["origin"] + removal = subprocess.run( + [args.raven_python, "-c", + "import sys, site, json;" + f"sys.path.insert(0, {str(args.raven)!r});" + f"site.addsitedir({args.raven_site!r});" + "from skillsearch import provenance, shared;" + "root = shared.shared_skills_dir();" + f"ok = provenance.uninstall(root, {origin!r});" + "print(json.dumps({'ok': ok, " + "'left': [o.origin for o in provenance.list_installed(root)], " + "'log': [r['origin'] for r in provenance.read_uninstalled(root)]}))"], + capture_output=True, text=True, timeout=120, check=False, env=raven_env, + ) + ok_run = removal.returncode == 0 and removal.stdout.strip() + state = json.loads(removal.stdout.strip().splitlines()[-1]) if ok_run else {} + check("uninstalling removes it and records that it was removed", + state.get("ok") is True and origin not in state.get("left", [origin]) + and origin in state.get("log", []), + f"{state}" + ("" if removal.returncode == 0 else f"; stderr={removal.stderr[-300:]}")) + + # What is gone is what this identity's directory held, which is one of + # the installed names — the others are still installed and may well be + # retrieved instead. So the assertion is about the removed one alone. + removed_names = [n for n in installed_names if n not in skill_names(shared_home / "skills")] + turn = run_openclaw(Path(args.openclaw), "shared-e2e", + "How do I pull the tables out of a PDF report?", env) + seen = "\n".join(turn["tool_results"]) + "\n" + turn["reply"] + still = [n for n in removed_names if n in seen] + check("and the other host stops retrieving it, without a restart", + bool(removed_names) and not still, + f"removed {removed_names}; still visible {still}") + # -- Acceptance 6: enabled:false hides it, and survives a restart ------ document = json.loads(registry.read_text(encoding="utf-8")) for entry in document["hosts"]: From b2f759448d9c95b95a3fcc742104463338a0c9dc Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 06:27:55 +0000 Subject: [PATCH 09/14] test(host-e2e): ask every host separately whether it joins the shared library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I had said the four unmeasured hosts were "the same code run again on another host". That was wrong, and this is the evidence: self-registration is wired at six separate call sites, each with its own config key and its own place in that host's engine builder, and three "fixed on one side only" bugs have already come out of this branch. `e2e_shared_hosts.py` puts one skill in the shared directory, leaves each host's own directory empty, and asks the one question that skill answers. Same setup for all of them, so a difference in the result is a difference in that host's wiring. All five headless hosts register and retrieve: openclaw 1.x, openclaw 2.0, DeepSeek Harness, Raven, Hermes. It found a real defect in the DSH path. `installRootFor(cfg.shareSkills)` did not compile: `shareSkills` is optional on the exported `Config` interface, while the zod default only applies to config the harness itself parsed. No suite in this repository compiles that file against that interface, so it passed here and failed the moment the harness built it. `?? true` at the call site, and the comment says why the default is not enough. Two things about the verdicts, both of which cost a rerun to learn: Registration and retrieval are separate questions and are now reported apart. In on-demand mode retrieval goes through the model choosing to call the tool, and a model that answers from memory instead leaves the wiring untested rather than broken — measured on both OpenClaw generations, one run each, and driving 1.x's engine directly returned the shared skill in full. A host that registers but whose model never reached for the tool is INCONCLUSIVE; a host that does not register is a failure, because then the others cannot see it. And the label a script uses is not the id a plugin registers. The 1.x package predates the 2.0 split and still registers as `openclaw`, so comparing against `openclaw1` reported a correctly registered host as failed. WorkBuddy has no headless path and is not run here. Co-Authored-By: Claude Opus 5 (1M context) --- .../engine-typescript/src/index.ts | 10 +- skillcorpus_plugin/tests/host-e2e/ruff.toml | 2 + .../tests/host-e2e/scripts/_e2e.py | 36 +++ .../tests/host-e2e/scripts/e2e_deepseek.py | 20 +- .../tests/host-e2e/scripts/e2e_hermes.py | 21 +- .../tests/host-e2e/scripts/e2e_raven.py | 32 ++- .../host-e2e/scripts/e2e_shared_hosts.py | 232 ++++++++++++++++++ 7 files changed, 340 insertions(+), 13 deletions(-) create mode 100644 skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared_hosts.py diff --git a/skillcorpus_plugin/engine-typescript/src/index.ts b/skillcorpus_plugin/engine-typescript/src/index.ts index 80e18ab..f1220c5 100644 --- a/skillcorpus_plugin/engine-typescript/src/index.ts +++ b/skillcorpus_plugin/engine-typescript/src/index.ts @@ -369,8 +369,14 @@ function buildEngine(ctx: Context, cfg: Config): SkillSearchEngine { const dirs = cfg.skillsDirs ?? [] // Registers this harness's skills directory so the other four hosts can // scan it, and appends the shared directory plus whatever they registered. - const installRoot = installRootFor(cfg.shareSkills) - const roots = scanDirs('deepseek-harness', dirs, cfg.shareSkills) + // `?? true` because this reaches `buildEngine` through the exported + // `Config` type, where the field is optional — the zod default only applies + // to config the harness parsed. Without it the harness's own type-check + // fails, which is how this was found: the repository suites never compile + // this file against that interface. + const share = cfg.shareSkills ?? true + const installRoot = installRootFor(share) + const roots = scanDirs('deepseek-harness', dirs, share) if (roots.length > 0) { const local = new LocalSkillSource(roots, { indexBody: cfg.indexBody ?? false }) local.weight = cfg.weightLocal ?? 1.0 diff --git a/skillcorpus_plugin/tests/host-e2e/ruff.toml b/skillcorpus_plugin/tests/host-e2e/ruff.toml index b6bb4df..b388caa 100644 --- a/skillcorpus_plugin/tests/host-e2e/ruff.toml +++ b/skillcorpus_plugin/tests/host-e2e/ruff.toml @@ -40,3 +40,5 @@ ignore = [ # Runs two host CLIs and an interpreter, all named by the operator. Running # them is the script. "scripts/e2e_shared.py" = ["S603"] +# Drives every host CLI and interpreter the operator named. That is the script. +"scripts/e2e_shared_hosts.py" = ["S603"] diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/_e2e.py b/skillcorpus_plugin/tests/host-e2e/scripts/_e2e.py index 507ae9c..2eb43ce 100644 --- a/skillcorpus_plugin/tests/host-e2e/scripts/_e2e.py +++ b/skillcorpus_plugin/tests/host-e2e/scripts/_e2e.py @@ -264,3 +264,39 @@ def line(mode: str, ok: bool, facts: dict, elapsed_s: float) -> str: f"in_reply={facts['sentinel_in_reply']!s:5} " f"{'PASS' if ok else 'FAIL'} ({elapsed_s:.0f}s)" ) + + +#: A skill that lives only in the shared library, for the cross-host case. +#: Its own subject so it cannot be confused with the standard fixture, and +#: facts that exist nowhere else so a reply carrying them can only have come +#: from the shared directory. +SHARED_SKILL = """\ +--- +name: rotate-signing-keys +description: Rotate the service signing keys and re-issue downstream credentials safely. +--- + +House procedure: stage the new key under the `Narwhal-KMS-4` alias and keep the +previous one live until the `Quokka Cutover` window closes. +""" +SHARED_FACTS = ("Narwhal-KMS-4", "Quokka Cutover") +SHARED_PROMPT = "What is our internal procedure for rotating signing keys?" + + +def shared_corpus(home: Path) -> tuple[Path, Path]: + """Put the fixture in the shared library, and give the host an empty dir. + + Returns ``(the host's own skills directory, the shared skills directory)``. + + The host's own directory is created and left empty on purpose. A host that + configures *no* skills directory does not join the shared library at all — + that is deliberate, and documented in `shared.py` — so handing it an empty + one is what isolates the question to "does this host read the others", + with nothing of its own to find instead. + """ + own = home / "own-skills" + own.mkdir(parents=True, exist_ok=True) + shared = home / ".evermind-skillsearch" / "skills" / "rotate-signing-keys" + shared.mkdir(parents=True, exist_ok=True) + (shared / "SKILL.md").write_text(SHARED_SKILL, encoding="utf-8") + return own, shared.parent diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_deepseek.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_deepseek.py index 0aed421..178ff41 100644 --- a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_deepseek.py +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_deepseek.py @@ -291,6 +291,11 @@ def main() -> int: help="case P5: point one remote catalogue at a closed port " "and check the local corpus, the turn and the log all " "survive it") + ap.add_argument("--shared-probe", action="store_true", + help="ask whether this host joins the shared library: the " + "fixture goes in the shared directory, this host's own " + "directory is left empty, and the prompt is one only " + "that skill answers") ap.add_argument("--dump", type=Path, default=None) args = ap.parse_args() if not args.host: @@ -303,15 +308,26 @@ def main() -> int: model = _e2e.model_config() server, recorder, base_url = start_proxy(model["base_url"], model["api_key"]) - skills = _e2e.corpus() + skills = (Path(os.environ["SKILLSEARCH_E2E_SHARED_OWN_DIR"]) if args.shared_probe + else _e2e.corpus()) print(f"host={args.host} model={model['model']} corpus={skills} proxy={base_url}") results: dict[str, dict] = {} failures: list[str] = [] try: for mode in args.modes: + prompt = (os.environ["SKILLSEARCH_E2E_SHARED_PROMPT"] if args.shared_probe + else _e2e.CASES[args.case]["prompt"]) out = run(mode, skills, base_url, recorder, dsh_bin, model, - _e2e.CASES[args.case]["prompt"], args.broken_source) + prompt, args.broken_source) + if args.shared_probe: + seen = out["injected_text"] + out["tool_result_text"] + out["reply"] + got = all(f in seen for f in _e2e.SHARED_FACTS) + print(f" SHARED-PROBE {'PASS' if got else 'FAIL'} {mode} " + f"tool_offered={out['tool_offered']} reply={out['reply'][:120]!r}") + if not got: + failures.append(mode) + continue # `default` has to behave as on-demand; that is the assertion. effective = "on_demand" if mode == "default" else mode ok, facts = _e2e.verdict( diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_hermes.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_hermes.py index 7243386..2f5bc2c 100644 --- a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_hermes.py +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_hermes.py @@ -196,13 +196,22 @@ def main() -> int: help="case P5: point one remote catalogue at a closed port " "and check the local corpus, the turn and the log all " "survive it") + ap.add_argument("--shared-probe", action="store_true", + help="ask whether this host joins the shared library: the " + "fixture goes in the shared directory, this host's own " + "directory is left empty, and the prompt is one only " + "that skill answers") ap.add_argument("--dump", type=Path, default=None) args = ap.parse_args() if not args.host: ap.error("--host or SKILLSEARCH_E2E_HERMES_CHECKOUT is required") model = _e2e.model_config() - skills = _e2e.corpus() + # Under `--shared-probe` the fixture is already in the shared library and + # this host gets an empty directory of its own, so anything it finds came + # from there. + skills = (Path(os.environ["SKILLSEARCH_E2E_SHARED_OWN_DIR"]) if args.shared_probe + else _e2e.corpus()) print(f"host={args.host} model={model['model']} corpus={skills}") budget = args.prefetch_budget or "host default (8s)" print(f"rewrite={not args.no_rewrite} prefetch_budget={budget}") @@ -210,7 +219,8 @@ def main() -> int: results, failures = {}, [] for mode in args.modes: out = run(mode, skills, Path(args.host), model, - prompt=_e2e.CASES[args.case]["prompt"], + prompt=(os.environ["SKILLSEARCH_E2E_SHARED_PROMPT"] if args.shared_probe + else _e2e.CASES[args.case]["prompt"]), rewrite=not args.no_rewrite, prefetch_budget_s=args.prefetch_budget, broken_source=args.broken_source) results[mode] = out @@ -221,6 +231,13 @@ def main() -> int: delivered = "\n".join( [out["prefetch_text"]] + [c["returned"] for c in out["tool_calls"]] ) + if args.shared_probe: + got = all(f in delivered + out["reply"] for f in _e2e.SHARED_FACTS) + print(f" SHARED-PROBE {'PASS' if got else 'FAIL'} {mode} " + f"schemas={out['schemas']} reply={out['reply'][:120]!r}") + if not got: + failures.append(mode) + continue ok, facts = _e2e.verdict( case=args.case, mode=mode, diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_raven.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_raven.py index ad4f84c..09e3048 100644 --- a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_raven.py +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_raven.py @@ -221,6 +221,11 @@ def main() -> int: help="case P5: point one remote catalogue at a closed port " "and check the local corpus, the turn and the log all " "survive it") + ap.add_argument("--shared-probe", action="store_true", + help="ask whether this host joins the shared library: the " + "fixture goes in the shared directory, this host's own " + "directory is left empty, and the prompt is one only " + "that skill answers") ap.add_argument("--dump", type=Path, default=None) args = ap.parse_args() if not args.host: @@ -247,14 +252,20 @@ def main() -> int: "reason": "host has no context_segments slot"} continue workspace = Path(tempfile.mkdtemp(prefix=f"raven-ws-{mode}-")) - # Inside the workspace on purpose: a corpus in a stray temp directory - # reads to the agent as a skill that is not installed — it checks, does - # not find the directory, and says so. That is the harness lying about - # the deployment, not retrieval failing. - skills = _e2e.corpus(workspace) + if args.shared_probe: + # The fixture is already in the shared library; this host gets an + # empty directory of its own, so anything it finds came from there. + skills = Path(os.environ["SKILLSEARCH_E2E_SHARED_OWN_DIR"]) + else: + # Inside the workspace on purpose: a corpus in a stray temp + # directory reads to the agent as a skill that is not installed — + # it checks, does not find the directory, and says so. That is the + # harness lying about the deployment, not retrieval failing. + skills = _e2e.corpus(workspace) try: - out = run(mode, skills, workspace, model, - _e2e.CASES[args.case]["prompt"], args.timeout, + prompt = (os.environ["SKILLSEARCH_E2E_SHARED_PROMPT"] if args.shared_probe + else _e2e.CASES[args.case]["prompt"]) + out = run(mode, skills, workspace, model, prompt, args.timeout, broken_source=args.broken_source) except Exception as err: # a host that will not start is the finding print(f" {mode:10} host failed: {type(err).__name__}: {err}") @@ -267,6 +278,13 @@ def main() -> int: delivered = "\n".join( [out["segment_text"]] + [c["returned"] for c in out["tool_calls"]] ) + if args.shared_probe: + got = all(f in delivered + out["reply"] for f in _e2e.SHARED_FACTS) + print(f" SHARED-PROBE {'PASS' if got else 'FAIL'} {mode} " + f"tools={out['tool_names']} reply={out['reply'][:120]!r}") + if not got: + failures.append(mode) + continue ok, facts = _e2e.verdict( case=args.case, mode=mode, diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared_hosts.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared_hosts.py new file mode 100644 index 0000000..e7b43f5 --- /dev/null +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared_hosts.py @@ -0,0 +1,232 @@ +"""Does *each* host actually join the shared library? + +`e2e_shared.py` proves two hosts share with each other. This asks the narrower +question of every host separately, because the answer is per-host code: the +self-registration is wired at six separate call sites, each with its own config +key and its own place in that host's engine builder. Three "fixed on one side +only" bugs have already come out of this branch, and every one of them was +invisible from inside the other side. + +The setup is the same for all of them, so a difference in the result is a +difference in the host's wiring and nothing else: + +- one skill, in the shared directory, on a subject nothing else here touches + and carrying facts that exist nowhere outside this repository; +- the host's own skills directory created and left **empty** — a host that + configures no directory deliberately does not join the shared library, so an + empty one isolates "does this host read the others" with nothing of its own + to find instead; +- one question, whose answer is only in that skill. + +A host passes when its own line appears in `registry.json` **and** the shared +skill reaches the model. + +Those are two questions and the second one is not entirely the plugin's to +answer: in on-demand mode the model decides whether to call `skill_search` at +all, and a model that answers from memory instead leaves the wiring untested +rather than broken. Measured: OpenClaw 1.x failed this exactly once that way, +and driving its engine directly returned the shared skill in full. + +So a host that registers but whose model did not reach for the tool is +reported as INCONCLUSIVE, not FAIL, and the run says which. A host that does +not register is a real failure — it means the others cannot see it, which is +half the feature gone. + +Usage: + + export SKILLSEARCH_E2E_BASE_URL=... SKILLSEARCH_E2E_MODEL=... + python e2e_shared_hosts.py --raven ... --hermes ... --dsh ... \\ + --openclaw1 ... --openclaw2 ... + +Every host is optional; the ones not given are reported as skipped rather than +silently dropped. WorkBuddy has no headless path and is never run here. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _e2e +import e2e_openclaw + +TIMEOUT_S = 600.0 +ENGINE_PY = Path(__file__).resolve().parents[3] / "engine-python" + + +def registered(home: Path) -> list[str]: + """Host ids in the shared registry, read without importing the engine.""" + try: + raw = json.loads((home / ".evermind-skillsearch" / "registry.json").read_text("utf-8")) + except (OSError, ValueError): + return [] + return [str(h.get("id")) for h in raw.get("hosts") or [] if isinstance(h, dict)] + + +def python_host(kind: str, checkout: Path, site: str, python: str, home: Path, + own: Path, model: dict) -> dict: + """Drive Raven or Hermes in a subprocess with the shared home in place. + + A subprocess because `SKILLSEARCH_HOME` and the host's own import side + effects have to be this run's, not this interpreter's. + """ + driver = Path(__file__).resolve().parent / f"e2e_{kind}.py" + env = { + **os.environ, + "HOME": str(home), + "SKILLSEARCH_HOME": str(home / ".evermind-skillsearch"), + "SKILLSEARCH_E2E_SHARED_OWN_DIR": str(own), + "SKILLSEARCH_E2E_SHARED_PROMPT": _e2e.SHARED_PROMPT, + } + extra = ["--plugin-site", site] if kind == "raven" else ["--prefetch-budget", "300"] + proc = subprocess.run( + [python, str(driver), "--host", str(checkout), *extra, "--shared-probe", "on_demand"], + capture_output=True, text=True, timeout=TIMEOUT_S + 120, check=False, env=env, + ) + return {"returncode": proc.returncode, + "stdout": proc.stdout[-4000:], + "stderr": proc.stderr[-2000:]} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--raven", type=Path) + ap.add_argument("--raven-site") + ap.add_argument("--raven-python", default=sys.executable) + ap.add_argument("--hermes", type=Path) + ap.add_argument("--hermes-python", default=sys.executable) + ap.add_argument("--dsh", type=Path) + ap.add_argument("--openclaw1", type=Path) + ap.add_argument("--openclaw2", type=Path) + ap.add_argument("--dump", type=Path, default=None) + args = ap.parse_args() + + model = _e2e.model_config() + results: dict = {} + failures: list[str] = [] + skipped: list[str] = [] + + inconclusive: list[str] = [] + + def check(host: str, registered_ok: bool, retrieved: bool, called: bool, detail: str) -> None: + """Two questions, reported apart. + + Registration is the plugin's alone, so its failure is this host's + failure. Retrieval in on-demand mode goes through the model's choice + to call the tool, so "did not retrieve because it never asked" is not + evidence against the wiring. + """ + if registered_ok and retrieved: + verdict, bucket = "PASS", None + elif not registered_ok: + verdict, bucket = "FAIL", failures + elif not called: + verdict, bucket = "INCONCLUSIVE", inconclusive + else: + verdict, bucket = "FAIL", failures + results[host] = {"verdict": verdict, "registered": registered_ok, + "retrieved": retrieved, "called_tool": called, "detail": detail} + print(f" {verdict:13} {host}") + print(f" {detail}") + if bucket is not None: + bucket.append(host) + + # -- the two OpenClaw generations ------------------------------------- + # `label` is what this script calls the host; `host_id` is what the plugin + # registers itself as. They differ for 1.x — the package predates the 2.0 + # split and still registers as `openclaw` — and comparing the wrong one + # reported a host that had registered correctly as a failure. + for label, host_id, executable, generation in (("openclaw1", "openclaw", args.openclaw1, 1), + ("openclaw2", "openclaw2", args.openclaw2, 2)): + if not executable: + skipped.append(label) + continue + home = Path(tempfile.mkdtemp(prefix=f"shared-{label}-")) + own, _ = _e2e.shared_corpus(home) + profile = f"shared-{label}" + e2e_openclaw.write_profile(home / f".openclaw-{profile}", generation, "on_demand", + own, home / "workspace", model) + env = {**os.environ, "HOME": str(home), + "SKILLSEARCH_HOME": str(home / ".evermind-skillsearch")} + session = str(uuid.uuid4()) + proc = subprocess.run( + [str(executable), "--profile", profile, "agent", "--local", "--thinking", "off", + "--session-id", session, "--json", "-m", _e2e.SHARED_PROMPT], + capture_output=True, text=True, timeout=TIMEOUT_S, check=False, env=env, + ) + turn = e2e_openclaw.read_turn( + e2e_openclaw.transcript(home / f".openclaw-{profile}", session)) + seen = "\n".join(turn["tool_results"]) + "\n" + turn["reply"] + ids = registered(home) + names = [c["name"] for c in turn["tool_calls"]] + check(label, + host_id in ids, + all(fact in seen for fact in _e2e.SHARED_FACTS), + "skill_search" in names, + f"registry={ids} tools={names} reply={turn['reply'][:140]!r}" + + ("" if proc.returncode == 0 else f" stderr={proc.stderr[-300:]}")) + + # -- the DeepSeek Harness --------------------------------------------- + if args.dsh: + home = Path(tempfile.mkdtemp(prefix="shared-dsh-")) + own, _ = _e2e.shared_corpus(home) + driver = Path(__file__).resolve().parent / "e2e_deepseek.py" + env = {**os.environ, "HOME": str(home), + "SKILLSEARCH_HOME": str(home / ".evermind-skillsearch"), + "SKILLSEARCH_E2E_SHARED_OWN_DIR": str(own), + "SKILLSEARCH_E2E_SHARED_PROMPT": _e2e.SHARED_PROMPT} + proc = subprocess.run( + [sys.executable, str(driver), "--host", str(args.dsh), "--shared-probe", "on_demand"], + capture_output=True, text=True, timeout=TIMEOUT_S + 120, check=False, env=env, + ) + ids = registered(home) + check("deepseek-harness", + "deepseek-harness" in ids, + "SHARED-PROBE PASS" in proc.stdout, + "skill_search" in proc.stdout, + f"registry={ids} " + (proc.stdout[-300:] or proc.stderr[-300:])) + else: + skipped.append("deepseek-harness") + + # -- Raven and Hermes -------------------------------------------------- + for label, checkout, site, python in ( + ("raven", args.raven, args.raven_site, args.raven_python), + ("hermes", args.hermes, "", args.hermes_python), + ): + if not checkout: + skipped.append(label) + continue + home = Path(tempfile.mkdtemp(prefix=f"shared-{label}-")) + own, _ = _e2e.shared_corpus(home) + out = python_host(label, Path(checkout), site or "", python, home, own, model) + ids = registered(home) + check(label, + label in ids, + "SHARED-PROBE PASS" in out["stdout"], + "skill_search" in out["stdout"], + f"registry={ids} " + (out["stdout"][-300:] or out["stderr"][-300:])) + + if skipped: + print(f"\n skipped (not given): {', '.join(skipped)}") + print(" workbuddy: no headless path; verified by hand — see reports/") + + if args.dump: + args.dump.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8") + if inconclusive: + print(f"\n inconclusive (registered, but the model never called the tool): " + f"{', '.join(inconclusive)}") + print(" rerun those, or drive their engine directly — the wiring is not what " + "this measured") + print("\nall passed" if not failures else f"\nfailed: {failures}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From ca324890b08d5082f57c87eeb35d91b84051b32d Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 06:39:55 +0000 Subject: [PATCH 10/14] test(host-e2e): acceptance 8 on a real install, and a verdict that cannot be inconclusive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two remaining gaps that were mine to close. **Acceptance 8 now runs against the live catalogue.** I had argued it was a property of the swap primitive and therefore adequately unit-tested. That was half right and the wrong half: the unit test drives `swap_into_place` with a staging directory that does not exist, which covers the primitive and nothing in front of it. The path a user hits is decide-to-update, download, extract, swap — and only the last step was covered. So: install for real from EverMind SkillHub, then attempt an update to a version the marker does not have, from an endpoint nothing is listening on. Measured — the download fails, the directory is unchanged, the previous body is byte-identical at 2546 characters, and no `.incoming-` or `.retiring-` directory is left behind. That last one matters on its own: a surviving staging directory is picked up by the scanner as a skill. **The OpenClaw verdict no longer depends on the model's mood.** In on-demand mode retrieval goes through the model choosing to call `skill_search`, and one that answers from memory instead leaves the wiring untested rather than broken — which is how a correctly wired host came out INCONCLUSIVE, on different generations on different runs. Each generation now also gets an engine probe: the plugin's own `buildEngine`, given the config the host profile carries, asked to retrieve. No model in the loop, so it answers the question the wiring actually owns. The model-facing turn stays, reported beside it as the end-to-end observation. Both are recorded, and the difference between them is informative rather than noise: across three runs `engine_probe` was true every time while `model_found` flipped on both generations. Five hosts, all passing: openclaw 1.x, openclaw 2.0, DeepSeek Harness, Raven, Hermes. WorkBuddy has no headless path and still needs a hand. Co-Authored-By: Claude Opus 5 (1M context) --- skillcorpus_plugin/tests/host-e2e/ruff.toml | 2 +- .../tests/host-e2e/scripts/e2e_install.py | 73 ++++++++++++++++++- .../host-e2e/scripts/e2e_shared_hosts.py | 61 ++++++++++++++-- 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/skillcorpus_plugin/tests/host-e2e/ruff.toml b/skillcorpus_plugin/tests/host-e2e/ruff.toml index b388caa..27f0dc5 100644 --- a/skillcorpus_plugin/tests/host-e2e/ruff.toml +++ b/skillcorpus_plugin/tests/host-e2e/ruff.toml @@ -41,4 +41,4 @@ ignore = [ # them is the script. "scripts/e2e_shared.py" = ["S603"] # Drives every host CLI and interpreter the operator named. That is the script. -"scripts/e2e_shared_hosts.py" = ["S603"] +"scripts/e2e_shared_hosts.py" = ["S603", "S607"] # also resolves npx from PATH diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py index 5cca3a0..2bae176 100644 --- a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py @@ -15,10 +15,13 @@ 2 the next turn finds it locally, **exactly once**, with no restart 7 uninstalling removes it, and it stops being retrievable -Acceptance 8 — a failed update leaves the old version working — is not here. -It is a property of the swap primitive, not of the catalogue, and forcing a -real download to fail halfway would be theatre; `engine-python/tests` and -`engine-typescript/tests` assert it directly on `swap_into_place`. + 8 a failed update leaves the previous version installed and working + +Acceptance 8 is here after all. The unit tests drive `swap_into_place` with a +staging directory that does not exist, which proves the primitive; this drives +a real update of a really-installed skill whose download really fails, which +is the path a user hits. The two are not the same test — everything between +"decide to update" and "swap" is only covered by the second. Usage: @@ -38,6 +41,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _e2e #: The default in every host's config, and a service that answers without a #: key. Overridable so this can be pointed at a staging deployment. @@ -175,6 +179,67 @@ async def run() -> dict: name != "" and turns["third"].count(heading) == 1, f"{turns['third'].count(heading)}x in an engine with no cached scan") + # -- Acceptance 8: an update that fails leaves the old one working ----- + # + # A real installed skill, a real update attempt, a download that really + # fails. The unit tests cover the swap primitive; nothing covered the + # decision path in front of it, which is where a half-written directory + # would come from. + before_dir = provenance.find_installed(shared_skills, origin) + before_body = "" + if before_dir is not None: + for candidate in (before_dir, *sorted(p for p in before_dir.iterdir() if p.is_dir())): + if (candidate / "SKILL.md").is_file(): + before_body = (candidate / "SKILL.md").read_text(encoding="utf-8") + break + + failed_as_expected = False + if before_dir is not None: + # Same skill, a version the marker does not have, and an endpoint that + # nothing is listening on — so the update is attempted and the download + # is what fails. + dead = f"http://127.0.0.1:{_e2e.dead_port()}" + + async def attempt() -> None: + from skillsearch.hub_client import SkillHubClient + + client = SkillHubClient(dead, cache_dir=home / "cache", install_root=shared_skills) + try: + await client.install(installed[0].slug, + prefetched_meta={"slug": installed[0].slug, + "version": "999.0", + "skill_md": ""}) + finally: + await client.aclose() + + try: + asyncio.run(attempt()) + except Exception: # the failure is the point + failed_as_expected = True + + after_dir = provenance.find_installed(shared_skills, origin) + after_body = "" + if after_dir is not None: + for candidate in (after_dir, *sorted(p for p in after_dir.iterdir() if p.is_dir())): + if (candidate / "SKILL.md").is_file(): + after_body = (candidate / "SKILL.md").read_text(encoding="utf-8") + break + + check("a failed update leaves the previous version installed and working", + failed_as_expected and after_dir == before_dir and after_body == before_body + and bool(before_body), + f"download failed as expected: {failed_as_expected}; " + f"directory unchanged: {after_dir == before_dir}; " + f"body unchanged: {after_body == before_body} ({len(before_body)} chars)") + + # No half-written directory left behind, which is the other way this can + # go wrong: a staging directory that survives is picked up by the scanner. + leftovers = sorted(p.name for p in shared_skills.iterdir() + if p.is_dir() and (".incoming-" in p.name or ".retiring-" in p.name)) + check("and no half-written directory is left behind", + not leftovers, + f"leftovers: {leftovers}" if leftovers else "none") + # -- Acceptance 7 ------------------------------------------------------ removed = [o.origin for o in installed if provenance.uninstall(shared_skills, o.origin)] check("uninstalling reports that it removed what was there", diff --git a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared_hosts.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared_hosts.py index e7b43f5..375940d 100644 --- a/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared_hosts.py +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared_hosts.py @@ -27,10 +27,16 @@ rather than broken. Measured: OpenClaw 1.x failed this exactly once that way, and driving its engine directly returned the shared skill in full. -So a host that registers but whose model did not reach for the tool is -reported as INCONCLUSIVE, not FAIL, and the run says which. A host that does -not register is a real failure — it means the others cannot see it, which is -half the feature gone. +So the model-facing turn is not the only evidence. Each OpenClaw generation +also gets an **engine probe**: the plugin's own `buildEngine`, given the same +config the host profile carries, asked to retrieve. That answers "is the shared +library wired into this host's engine" with no model in the loop, so it cannot +come out INCONCLUSIVE. + +The two together say what a single one cannot. A host passes on the engine +probe and registration; the model-facing turn is reported beside it as the +end-to-end observation, and a model that answered from memory instead is noted +rather than counted against the wiring. Usage: @@ -95,6 +101,46 @@ def python_host(kind: str, checkout: Path, site: str, python: str, home: Path, "stderr": proc.stderr[-2000:]} +def openclaw_engine_probe(generation: int, own: Path, home: Path) -> bool: + """Retrieve through the plugin's own engine, with no model in the loop. + + The model-facing turn answers "did this agent use the shared library", + which is the real question but is not deterministic: in on-demand mode the + model decides whether to call `skill_search`, and one that answers from + memory instead leaves the wiring untested. This answers the narrower + question the wiring actually owns — "can this host's engine see the shared + directory" — and it is the same code path the tool would have used. + + Written to a file rather than passed with `-c` because the plugin sources + are ESM with top-level imports and `tsx -e` compiles to CJS, which refuses + the top-level await this needs. + """ + package = "plugin-openclaw2" if generation == 2 else "plugin-openclaw" + root = Path(__file__).resolve().parents[3] / package + script = home / "engine-probe.ts" + script.write_text( + f"import {{ buildEngine }} from {str(root / 'src' / 'register.ts')!r}\n" + f"import {{ loadConfig }} from {str(root / 'src' / 'config.ts')!r}\n" + "async function main() {\n" + f" const cfg = loadConfig({{ skillsDirs: [{str(own)!r}], hubEndpoint: '',\n" + " clawhubEndpoint: '', skillhubCnEndpoint: '', topK: 3 }, process.env)\n" + " const engine = buildEngine(cfg, undefined, undefined)\n" + f" const block = await engine.retrieve({_e2e.SHARED_PROMPT!r}, {{}})\n" + " console.log(block)\n" + "}\n" + "void main()\n", + encoding="utf-8", + ) + env = {**os.environ, "HOME": str(home), + "SKILLSEARCH_HOME": str(home / ".evermind-skillsearch")} + try: + proc = subprocess.run(["npx", "--yes", "tsx", str(script)], capture_output=True, + text=True, timeout=180, check=False, env=env, cwd=str(root)) + except (OSError, subprocess.SubprocessError): + return False + return all(fact in proc.stdout for fact in _e2e.SHARED_FACTS) + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--raven", type=Path) @@ -166,11 +212,14 @@ def check(host: str, registered_ok: bool, retrieved: bool, called: bool, detail: seen = "\n".join(turn["tool_results"]) + "\n" + turn["reply"] ids = registered(home) names = [c["name"] for c in turn["tool_calls"]] + by_model = all(fact in seen for fact in _e2e.SHARED_FACTS) + by_engine = openclaw_engine_probe(generation, own, home) check(label, host_id in ids, - all(fact in seen for fact in _e2e.SHARED_FACTS), + by_engine, "skill_search" in names, - f"registry={ids} tools={names} reply={turn['reply'][:140]!r}" + f"registry={ids} engine_probe={by_engine} model_found={by_model} " + f"tools={names} reply={turn['reply'][:120]!r}" + ("" if proc.returncode == 0 else f" stderr={proc.stderr[-300:]}")) # -- the DeepSeek Harness --------------------------------------------- From 933e658a9067f08b265b1afa7ec7cd32fd3b32e9 Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 06:53:16 +0000 Subject: [PATCH 11/14] docs(host-e2e): commit the shared-library cases and what they returned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scripts were checked in as they were written; the cases and the results were not. So the acceptance for this feature lived in a chat log — which is exactly the failure the earlier E2E documentation request was about, and I reproduced it. `cases.md` gains **S1–S9**, numbered the way the shared-skills spec numbers them so a result can be read against it line by line. They are a separate family from P1–P6 and the file says why: P1–P6 ask whether one host retrieves correctly, S1–S9 ask whether hosts can see each other, which needs two of them running and leaves its evidence in files rather than in one transcript. It also records the three-fixture rule and the mistake behind it. The hosts run with `topK: 1`, so two fixtures on one subject makes a case measure which ranked higher instead of what it was written for — which is how the hand-dropped case failed once while the feature worked. `reports/shared-skills.md` is what actually happened at ca32489: all nine acceptance items, five hosts one at a time, two hosts sharing, and the install run against the real EverMind SkillHub. With the numbers, not adjectives — S8's 2546 unchanged bytes, the exact skill ids installed, which token proved which claim. It also lists the six defects this found, five of them in code every package suite reported green, and says plainly what was **not** run: the whole library on WorkBuddy, S5 and S9 on all five hosts rather than the ones named, and concurrent registration. `README.md` gains the three scripts and the three things needed to read their output: registration and retrieval are separate verdicts, a silent catalogue is BLOCKED rather than FAIL, and the install script talks to the live service on purpose. INCONCLUSIVE joins the verdict table, since it is new here and means something specific — the plugin did its part and the model did not exercise it. Co-Authored-By: Claude Opus 5 (1M context) --- skillcorpus_plugin/tests/host-e2e/README.md | 43 ++++- skillcorpus_plugin/tests/host-e2e/cases.md | 61 +++++++ .../tests/host-e2e/reports/shared-skills.md | 163 ++++++++++++++++++ 3 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 skillcorpus_plugin/tests/host-e2e/reports/shared-skills.md diff --git a/skillcorpus_plugin/tests/host-e2e/README.md b/skillcorpus_plugin/tests/host-e2e/README.md index da89baa..67e121a 100644 --- a/skillcorpus_plugin/tests/host-e2e/README.md +++ b/skillcorpus_plugin/tests/host-e2e/README.md @@ -9,8 +9,11 @@ gap. This directory is that second layer: fixed prompts, a fixed corpus, and a fixed way of deciding PASS, so a result from one release is comparable with the next. -- [`cases.md`](cases.md) — the cases. Six hosts, two modes, six scenarios. -- [`reports/`](reports) — what actually happened, one file per release. +- [`cases.md`](cases.md) — the cases. **P1–P6** are retrieval on one host, six + hosts and two modes; **S1–S9** are the shared skills library, which is about + hosts seeing each other and therefore needs two of them running. +- [`reports/`](reports) — what actually happened. `0.3.0.md` is a release; + `shared-skills.md` is a feature's acceptance. - [`scripts/`](scripts) — the five hosts that can be driven headlessly. ## Three layers, and what each one is worth @@ -155,6 +158,39 @@ python scripts/e2e_openclaw.py --generation 2 --openclaw /path/to/2.0/openclaw Add `--case p2` or `--case p3` to run the other scenarios; the default is P1. +## The shared library, S1–S9 + +Three more scripts, because these cases are about hosts seeing *each other* and +one host cannot answer that: + +```bash +# does each host join the library at all — five hosts, one at a time +python scripts/e2e_shared_hosts.py --openclaw1 … --openclaw2 … --dsh … \ + --raven … --raven-site … --hermes … + +# two hosts sharing: one Python port, one TypeScript port, one shared home +python scripts/e2e_shared.py --openclaw /path/to/2.0/openclaw --raven … --raven-site … + +# installing, against the real EverMind SkillHub +python scripts/e2e_install.py +``` + +Three things to know before reading their output: + +- **Registration and retrieval are separate verdicts.** In on-demand mode + retrieval passes through the model choosing to call `skill_search`, so a + model that answers from memory leaves the wiring untested rather than + broken — reported as INCONCLUSIVE. Not registering *is* a failure: the other + hosts then cannot see this one. Both OpenClaw generations also get an engine + probe with no model in the loop, so their wiring verdict is deterministic. +- **A catalogue that answered with nothing reports BLOCKED**, not FAIL. + Retrieval fails open, so an unreachable service and an empty result are + indistinguishable from outside, and a red that a rerun clears teaches nobody + anything. +- **`e2e_install.py` talks to the live catalogue on purpose.** A hand-built + fixture agrees with itself; the bug it found — a bundle that wraps the skill + in a directory — only exists because a real service sends that shape. + Each prints one line per mode and exits non-zero on any failure; `--dump FILE` writes the full record, including what went over the wire. @@ -206,7 +242,8 @@ executed by hand. Do not add a script that cannot run. | --- | --- | | PASS | Every condition for that mode held, observed directly | | FAIL | The plugin did not do what the case requires | -| BLOCKED | The host cannot run this case — a missing slot, an unavailable build, a gate nobody can grant. Say what would unblock it | +| BLOCKED | The host cannot run this case — a missing slot, an unavailable build, a gate nobody can grant, a catalogue that answered with nothing. Say what would unblock it | +| INCONCLUSIVE | The plugin did its part and the *model* did not exercise it — in on-demand mode it decides whether to call the tool. Not evidence about the wiring either way; rerun, or drive the engine directly | BLOCKED is not a soft FAIL and must never be written as PASS. Raven `auto` on stock Raven is BLOCKED; recording it as "Raven supports auto" is the specific diff --git a/skillcorpus_plugin/tests/host-e2e/cases.md b/skillcorpus_plugin/tests/host-e2e/cases.md index 54df246..4083aa5 100644 --- a/skillcorpus_plugin/tests/host-e2e/cases.md +++ b/skillcorpus_plugin/tests/host-e2e/cases.md @@ -248,3 +248,64 @@ empty" is the assertion; "process exits" is the bug. The older WorkBuddy verification notes checked the hook log, which is an `auto`-mode check. It is not a completion standard for the default. + +--- + +# S1–S9 — the shared skills library + +A second family of cases, from `skillsearch-shared-skills-spec.md`. They are +numbered as that document numbers them, so a result here can be read against +it line by line. + +These differ from P1–P6 in what they are about. P1–P6 ask whether *one* host +retrieves correctly; these ask whether the hosts can see **each other**, which +means at least two of them have to be running and the evidence lives in files +on disk rather than in one turn's transcript. + +## The corpus + +Three fixtures, and the reason there are three rather than one is a mistake +worth not repeating. They must not compete: the hosts run with `topK: 1`, so +two skills on the same subject means the case measures which one ranked higher +rather than what it set out to measure. + +| Fixture | Lives in | Facts | Used by | +| --- | --- | --- | --- | +| `invoice-audit` | host A's own directory | `Wombat-Ledger-7`, `Tapir Threshold` | S4 | +| `rotate-signing-keys` | the shared directory | `Narwhal-KMS-4`, `Quokka Cutover` | S5, and the per-host probe | +| whatever the catalogue returns for "extract tables from a PDF" | installed by retrieval | its own frontmatter name | S1, S2, S3, S7, S8 | + +## The cases + +| # | The claim | How it is verified | Script | +| --- | --- | --- | --- | +| S1 | a retrieved skill appears under `/skills/` | install for real from EverMind SkillHub, then read the ledger | `e2e_install.py` | +| S2 | the next turn finds it locally, **exactly once**, no restart | retrieve twice on one engine, count the heading; then again on a freshly built engine | `e2e_install.py` | +| S3 | agent B retrieves what agent A installed | Raven installs from the catalogue; OpenClaw — other host, other language port, own process — is then asked | `e2e_shared.py` | +| S4 | a skill in A's own directory is retrievable in B | Raven registers its directory; OpenClaw is asked the question only that skill answers | `e2e_shared.py` | +| S5 | a skill dropped in by hand reaches the hosts next turn, no restart | write into the shared directory mid-run, ask again | `e2e_shared.py`, `e2e_shared_hosts.py` | +| S6 | `enabled: false` hides A from B, **and survives A restarting** | edit the registry, ask B, re-register A, read the flag back | `e2e_shared.py` | +| S7 | uninstall → directory gone, **a record kept**, not retrievable | remove through the Python port, observe from the TypeScript host, read `uninstalled.log` | `e2e_shared.py`, `e2e_install.py` | +| S8 | a failed update leaves the previous version working | install for real, then update to an absent version from a dead endpoint | `e2e_install.py` | +| S9 | a corrupt registry costs sharing, not retrieval | write `{ this is not json` and ask again | `e2e_shared.py` | + +`e2e_shared_hosts.py` runs the narrower question — *does this host join at +all* — separately against each of the five headless hosts, because the +registration is wired at six different call sites and a fix applied to one is +not a fix applied to the others. Three bugs on this branch were exactly that. + +## Two things about judging these + +**Registration and retrieval are separate verdicts.** A host that registers but +whose model never called `skill_search` has not failed — in on-demand mode the +model decides, and one that answers from memory leaves the wiring untested +rather than broken. That is reported as INCONCLUSIVE. A host that does not +register *is* a failure: the others then cannot see it, which is half the +feature. Both OpenClaw generations also get an engine probe — the plugin's own +`buildEngine`, no model in the loop — so the wiring verdict cannot come out +inconclusive at all. + +**A catalogue that answered with nothing is not a failed install.** Retrieval +fails open, so an unreachable service and an empty result look identical from +outside. The install cases report BLOCKED in that situation rather than a red +that a rerun clears. diff --git a/skillcorpus_plugin/tests/host-e2e/reports/shared-skills.md b/skillcorpus_plugin/tests/host-e2e/reports/shared-skills.md new file mode 100644 index 0000000..4dbbce9 --- /dev/null +++ b/skillcorpus_plugin/tests/host-e2e/reports/shared-skills.md @@ -0,0 +1,163 @@ +# Shared skills library — host E2E results + +```text +Feature: cross-agent shared skills library +Spec: skillsearch-shared-skills-spec.md, acceptance 1–9 +SkillCorpus commit: ca32489 (branch feat/shared-skills) +Test date: 2026-09-09 +Tester: tianyi.sun@evermind.ai +Operating system: Ubuntu 22.04.4 (Linux 5.4.250) +Node / Python: Node v22.23.1, Python 3.12.1 +Catalogue: EverMind SkillHub, the real service, no key needed +Model: Qwen3.6-27B on an internal OpenAI-compatible gateway, + reasoning disabled; endpoint redacted, supplied through + SKILLSEARCH_E2E_BASE_URL +``` + +Reported apart from `0.3.0.md` because this is a feature's acceptance rather +than a release's, and it is numbered the way its own spec numbers things. The +cases are in [`../cases.md`](../cases.md) under **S1–S9**. + +## Summary + +| # | Acceptance | Result | +| --- | --- | --- | +| S1 | a retrieved skill appears under `/skills/` | PASS | +| S2 | next turn finds it locally, exactly once, no restart | PASS | +| S3 | agent B retrieves what agent A installed | PASS | +| S4 | a skill in A's own directory is retrievable in B | PASS | +| S5 | a hand-dropped skill reaches the hosts next turn | PASS | +| S6 | `enabled: false` hides A from B, and survives A restarting | PASS | +| S7 | uninstall → gone, recorded, not retrievable | PASS | +| S8 | a failed update leaves the previous version working | PASS | +| S9 | a corrupt registry costs sharing, not retrieval | PASS | + +All nine observed on real hosts against the real catalogue. The spec words +S5 and S9 as "五家"; they were run on the hosts named below rather than on all +five, and the per-host table underneath is what covers every host separately. + +WorkBuddy is not in any of this: it has no headless path, and the shared +library has **not** been verified there. That is the one open item. + +## Per host: does this host join the library at all? + +`scripts/e2e_shared_hosts.py`. One skill in the shared directory, each host's +own directory created and left empty, one question only that skill answers. +Same setup for all of them, so a difference in the result is a difference in +that host's wiring — which matters, because the registration is wired at six +separate call sites. + +| Host | Registered as | Engine retrieved it | Model called the tool | Verdict | +| --- | --- | --- | --- | --- | +| OpenClaw 1.x (2026.7.1) | `openclaw` | Yes | Yes | PASS | +| OpenClaw 2.0 (2026.8.1) | `openclaw2` | Yes | Yes | PASS | +| DeepSeek Harness (`47f9438`) | `deepseek-harness` | Yes | — *(no tool array on the wire for this probe)* | PASS | +| Raven (`1cb604a` + local patch) | `raven` | Yes | Yes | PASS | +| Hermes (`77ed972`) | `hermes` | Yes | Yes | PASS | +| WorkBuddy 5.3.13 | — | — | — | **not run** | + +**Registration and retrieval are separate verdicts, deliberately.** In +on-demand mode retrieval passes through the model choosing to call +`skill_search`, and a model that answers from memory instead leaves the wiring +untested rather than broken. Across three runs of this table the OpenClaw +engine probe was true every time while the model's choice flipped on *both* +generations — once each. A host that registers but whose model did not reach +for the tool is INCONCLUSIVE; a host that does not register is a failure, +because then the others cannot see it. + +## Two hosts, sharing with each other + +`scripts/e2e_shared.py` — one Python port (Raven) and one TypeScript port +(OpenClaw 2.0), one `SKILLSEARCH_HOME`, separate processes. Two rather than +five because two is what the two *ports* are: a disagreement between them is +the failure a single-host run cannot see. + +| Check | Covers | Result | +| --- | --- | --- | +| raven registers its own skills directory | S4 setup | PASS | +| raven installs a catalogue skill into the shared directory | S1 | PASS | +| openclaw retrieves a skill raven installed from the catalogue | **S3** | PASS | +| openclaw retrieves a skill that lives in raven's directory | S4 | PASS | +| a skill dropped into the shared directory is found without a restart | S5 | PASS | +| uninstalling removes it and records that it was removed | S7 | PASS | +| and the other host stops retrieving it, without a restart | S7 | PASS | +| disabling raven's entry hides its skills from openclaw | S6 | PASS | +| and raven restarting does not switch it back on | S6 | PASS | +| a corrupt registry leaves retrieval working | S9 | PASS | + +Evidence worth naming: OpenClaw's reply carried `Wombat-Ledger-7`, a token +present in nothing but the `SKILL.md` under **Raven's** directory, which Raven +registered and OpenClaw read out of `registry.json`. + +## Installing, against the live catalogue + +`scripts/e2e_install.py`. Against the real hub rather than a fixture, because +the failure this guards is a real catalogue's shape — and one such bug was +found this way, below. + +| Check | Covers | Result | +| --- | --- | --- | +| a retrieved skill is kept under the shared skills directory | S1 | PASS | +| and it carries a readable provenance marker | S1 | PASS | +| the next turn finds it exactly once, not twice | **S2** | PASS | +| a newly built engine sees it too, and still once | S2 | PASS | +| a failed update leaves the previous version installed and working | **S8** | PASS | +| and no half-written directory is left behind | S8 | PASS | +| uninstalling reports that it removed what was there | S7 | PASS | +| and nothing is left in the ledger or on disk | S7 | PASS | + +Installed in the run above: `hub/mzlzyCA_html-markdown_extract-tables-from-pdf` +and `hub/openclaw_skills_table-extractor`. S8's numbers: the download failed as +expected, the directory was unchanged, and the previous body was byte-identical +at 2546 characters. + +## What running this found + +Six defects, all in code that the package suites reported green. + +| Found by | Defect | +| --- | --- | +| the live catalogue | `list_installed` looked only at the top level, so a bundle that wraps the skill in a directory was invisible. Dedup worked while every management call was blind — `listInstalled` reported an empty library holding two skills | +| the same, one port later | the identical bug, unfixed, in the TypeScript port | +| the differential | the two ports computed **different install directories** for a non-ASCII slug, so one skill occupied two of them; every all-CJK slug collapsed to the same name and overwrote itself | +| the differential | a whitespace-only `SKILLSEARCH_SKILLS_DIRS` read as an override on one side and as unset on the other, silently emptying the host's skills directory and with it the shared library | +| the DSH host build | `installRootFor(cfg.shareSkills)` did not compile: the field is optional on the exported `Config`, and the zod default only applies to config the harness parsed. No suite here compiles that file against that interface | +| the spec, re-read verbatim | acceptance 7 asks for a record to be kept on uninstall. There was none — the directory was deleted and nothing was written | + +Three of the six are the same shape: a fix applied to one port and not the +other. Every package suite compares a port with itself, which is why +[`../../parity/`](../../parity) exists. + +## Not executed + +| What | Why | +| --- | --- | +| the whole library on WorkBuddy | No headless path — it is a desktop application installed from a marketplace. Nothing here has verified that its `UserPromptSubmit` hook and its MCP server both join the shared library | +| S5 and S9 on all five hosts | The spec says "五家". They ran on the hosts named in each table above; the per-host table covers registration and retrieval for every host separately, which is the part that is per-host code | +| concurrent writes from several hosts at once | The registry write is atomic and the tests assert that, but no test runs two hosts registering simultaneously | + +## Reproducing + +```bash +export SKILLSEARCH_E2E_BASE_URL=https://your-openai-compatible-endpoint/v1 +export SKILLSEARCH_E2E_MODEL=your-model-id + +cd skillcorpus_plugin/tests/host-e2e + +# every host, one at a time: does it join at all +python scripts/e2e_shared_hosts.py \ + --openclaw1 /path/to/1.x/openclaw --openclaw2 /path/to/2.0/openclaw \ + --dsh /path/to/deepseek-harness \ + --raven /path/to/raven --raven-site /path/to/site-packages \ + --hermes /path/to/hermes-agent + +# two hosts, sharing with each other +python scripts/e2e_shared.py --openclaw /path/to/2.0/openclaw \ + --raven /path/to/raven --raven-site /path/to/site-packages + +# installing, against the live catalogue +python scripts/e2e_install.py +``` + +Every script writes its full record with `--dump FILE`, including what went +over the wire. From 044aaecd2524d288b719b5f51dfe518f152c757d Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 06:57:28 +0000 Subject: [PATCH 12/14] docs: the shared library, for the people installing and uninstalling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature was documented for whoever reads `tests/host-e2e/`. It was not documented anywhere the installing agent or the user looks, and it puts a new directory in their home. **WorkBuddy gets its acceptance as steps.** It is the one host with no headless path, so S1–S9 there are a checklist rather than a script — same corpus, same questions, driven through the UI. Including the one that only matters on this host: note the registry's mtime, run several turns, check it is unchanged. The `UserPromptSubmit` hook is a fresh process every turn, so "register at startup" means "every turn" here, on the turn's hot path inside an 8-second budget, and the short circuit is what makes that free. **Both install playbooks gain the library**, because an agent following them should tell the user what is appearing in their home directory and where their skills are now visible from. Both switches are named together, since they are opposites and get conflated: `enabled` in the registry is "others cannot see me", `shareSkills` in a host's config is "I cannot see others". And deleting a registry line is explicitly called out as not lasting. **Uninstall is now correct in four places.** `~/.evermind-skillsearch/` is shared, so removing it while another agent still has the plugin takes that agent's library too. The narrow cleanup is this host's line in `registry.json`. The previous wording would have had an agent delete the lot. **Both READMEs gain the section**, and the Chinese one gains a correction the English got earlier: "what leaves your machine" still said downloads land in a throwaway cache outside every scanned directory. They are kept now, and where they are kept is exactly what that section exists to state. Co-Authored-By: Claude Opus 5 (1M context) --- skillcorpus_plugin/INSTALL.agent.md | 41 +++++++++++ skillcorpus_plugin/README.md | 2 +- skillcorpus_plugin/README.zh.md | 28 ++++++- .../plugin-workbuddy/INSTALL.agent.md | 59 ++++++++++++++- skillcorpus_plugin/tests/host-e2e/cases.md | 73 +++++++++++++++++++ 5 files changed, 198 insertions(+), 5 deletions(-) diff --git a/skillcorpus_plugin/INSTALL.agent.md b/skillcorpus_plugin/INSTALL.agent.md index 1c0bc4d..9536fed 100644 --- a/skillcorpus_plugin/INSTALL.agent.md +++ b/skillcorpus_plugin/INSTALL.agent.md @@ -208,6 +208,42 @@ python -c "import skillsearch, skillsearch_raven; print('import ok')" plainly during installation. The user can set any endpoint to an empty string to disable that source, or clear all three for local-only operation. +## The shared skills library + +From 0.4.0 every host also reads a directory shared with the user's other +agents, and registers its own skills directory so those agents can read it +back. Nothing needs configuring, but **say it out loud during installation** — +it is a new directory in the user's home and a new place their skills become +visible from: + +```text +~/.evermind-skillsearch/ +├── registry.json which agent keeps its skills where +├── uninstalled.log what retrieval installed and later removed +└── skills/ what retrieval installed +``` + +Two switches exist and they are opposites, so establish which one the user +means before touching either: + +| The user wants | Set | +| --- | --- | +| other agents not to see this one's skills | `enabled: false` on its line in `registry.json` | +| this agent not to see the others' | `shareSkills` / `share_skills` false in this host's own config | + +Deleting a line from `registry.json` does nothing lasting: that agent +re-registers on its next start. That is why the first switch is a flag. + +Two consequences worth stating plainly, because they change what the user's +disk holds: + +- **Retrieved skills are kept**, not discarded after the turn. Each carries a + `.skillsearch-origin.json` saying where it came from, and removals append to + `uninstalled.log`. +- **Changes to the shared directory take effect next turn**, with no restart. + Changes inside a host's own directory keep that host's existing behaviour, + which for four of the five still means restarting. + ## Verification — definition of done Do all of these; the install is done only when every box is ticked. @@ -273,5 +309,10 @@ When the user asks to remove skillsearch: `pip uninstall skillsearch skillsearch-raven`. 2. Offer to delete the bundle cache (`~/.skillsearch/hub`, `~/.openclaw/skillsearch-bundles`, or `~/.dsh/skillsearch-bundles`). + **`~/.evermind-skillsearch/` is different — leave it unless this was the + last agent.** It is shared, so deleting it while another agent still has the + plugin takes that agent's library too. Removing this host's line from + `registry.json` is the right narrow cleanup; tell the user what the + directory holds so they can decide about the rest. 3. Restore or delete the `.bak-skillsearch` backups per the user's call. 4. Show the diffs, same rule as installing. diff --git a/skillcorpus_plugin/README.md b/skillcorpus_plugin/README.md index 606c007..cccad15 100644 --- a/skillcorpus_plugin/README.md +++ b/skillcorpus_plugin/README.md @@ -204,7 +204,7 @@ A skill with no description at all can only be found by its name. (`index_body: ## Uninstall -Reverse of install, nothing hidden: remove the plugin directory / pip packages, delete the config keys you added, and optionally the bundle cache directory listed above. Each plugin README has the exact paths, and the agent playbook has an [uninstall section](INSTALL.agent.md#uninstall) — "remove skillsearch" works too. +Reverse of install, nothing hidden: remove the plugin directory / pip packages, delete the config keys you added, and optionally the bundle cache directory listed above. **`~/.evermind-skillsearch/` is the exception — leave it unless this is your last agent**: it is shared, so deleting it while another agent still has the plugin takes that agent's library with it. The narrow cleanup is removing this host's line from `registry.json`. Each plugin README has the exact paths, and the agent playbook has an [uninstall section](INSTALL.agent.md#uninstall) — "remove skillsearch" works too. ## How it works diff --git a/skillcorpus_plugin/README.zh.md b/skillcorpus_plugin/README.zh.md index 72949d4..8e584ab 100644 --- a/skillcorpus_plugin/README.zh.md +++ b/skillcorpus_plugin/README.zh.md @@ -112,12 +112,36 @@ EOF - **显式清空三个远程 endpoint 后的纯本地模式**——什么都不出去。扫描、排序、注入全在进程内。 - **默认安装**——EverMind SkillHub、ClawHub 与 skillhub.cn 默认开启,检索查询会发送给三个服务;将任一 endpoint 设为空字符串可单独关闭。未配置 `model` 时不会运行 LLM gate,但仍执行来源安全检查和 EverMind 关键词相关性过滤。 -- **EverMind SkillHub**——选中技能的正文和 bundle 会从它下载。zip 解包有路径穿越拒绝、扩展名白名单、单文件 8 MiB / 整包 64 MiB 上限,缓存目录在所有被扫描技能目录之外(默认 `~/.workbuddy-ai/skillsearch-bundles`、`~/.skillsearch/hub`、`~/.openclaw/skillsearch-bundles` 或 `~/.dsh/skillsearch-bundles`)。 +- **EverMind SkillHub**——选中技能的正文和 bundle 会从它下载。zip 解包有路径穿越拒绝、扩展名白名单、单文件 8 MiB / 整包 64 MiB 上限;而且**装下来会留着**,落在共享技能库 `~/.evermind-skillsearch/skills/` 里,不再是用完即弃。每个技能带一份 `.skillsearch-origin.json` 记来源和装入时间,卸载会追加到 `~/.evermind-skillsearch/uninstalled.log`。把宿主配置里的 `shareSkills` / `share_skills` 设为 false,可退回旧的一次性缓存行为。 - **Marketplace 正文获取**——每个启用的 marketplace 最多会有两个候选在可选 LLM gate 之前下载并安全解包,因为这两个 API 通过 bundle 提供技能正文。被 gate 拒绝的候选可能仍留在缓存里,但插件不会自动执行它。 - **配了 `model`**——改写器看到你的消息(截断到 2,000 字符);gate 看到你的消息加候选技能的名字、描述和 300 字符正文摘录。两者都发给**你自己配置的**模型,宿主有 provider 通道的走宿主通道。 下载的技能是第三方内容,模型会被指示遵循它。ClawHub 与 skillhub.cn 条目不在 SkillCorpus 的仓库许可证审计范围内,重新分发前应检查其上游条款。gate 能剔除依赖不可用工具或环境的技能,但只有配置了模型时才真正存在。 +## 共享技能库 + +默认情况下每个宿主只扫自己那一个目录,所以你在一个 agent 里有的技能,另外四个看不见。0.4.0 起它们还共用一个: + +```text +~/.evermind-skillsearch/ +├── registry.json 哪个 agent 的技能放在哪 +├── uninstalled.log 卸载过什么、什么时候 +└── skills/ 检索装进来的技能,一个技能一个目录 +``` + +**你的 agent 路径没有一处是写死的。** 每个宿主启动时把自己实际解析出来的技能目录写进 `registry.json`,再把整张表读回去——所以它看到的正好是"同样装了这个插件的那些 agent",而你挪了技能目录,下次启动就自动跟上。 + +`registry.json` 是给人改的。要让别的 agent 不扫某个目录,把那条的 `enabled` 设成 `false`;**直接删掉那行无效**,那个 agent 下次启动会重新登记。它和宿主自己配置里的 `shareSkills` / `share_skills` 是**反方向**的两个开关: + +| 你想要 | 改哪儿 | +| --- | --- | +| 别人别看我的技能 | `registry.json` 里我那条的 `enabled: false` | +| 我不想看别人的技能 | 我这个宿主自己配置里的 `shareSkills` / `share_skills` | + +共享目录的变更**下一轮生效,不用重启**——手动拖一个技能进去,下一个问题就能搜到。宿主自己目录里的变更仍按各家原来的规矩,五家里有四家还是要重启。 + +`SKILLSEARCH_HOME` 能改根目录,但当成高级用法:GUI 启动的 agent 读不到你的 shell profile,两边会对"库在哪"产生分歧。 + ## 让你的技能可被搜到 检索索引的是**名字和描述**(有意为之——正文是停用词噪声的来源),所以 description 就是技能的搜索面。写触发场景,不要只写主题: @@ -144,7 +168,7 @@ description: PDF helper. ## 卸载 -安装的逆操作,没有暗桩:删插件目录 / pip 卸载、删你加的配置键、可选删上面列的 bundle 缓存目录。各插件 README 有精确路径,agent 剧本里也有[卸载节](INSTALL.agent.md#uninstall)——对 agent 说"卸载 skillsearch"同样管用。 +安装的逆操作,没有暗桩:删插件目录 / pip 卸载、删你加的配置键、可选删上面列的 bundle 缓存目录。**`~/.evermind-skillsearch/` 例外——除非这是最后一个 agent,否则别删**:它是共享的,还有别的 agent 装着插件时删掉它,会把那些 agent 的技能库一起带走;正确的窄清理是把这个宿主那一行从 `registry.json` 里去掉。各插件 README 有精确路径,agent 剧本里也有[卸载节](INSTALL.agent.md#uninstall)——对 agent 说"卸载 skillsearch"同样管用。 ## 工作原理 diff --git a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md index 99c2e20..4d1cdc7 100644 --- a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md +++ b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md @@ -81,6 +81,37 @@ from their current path. plainly during installation. The user can set any endpoint to an empty string to disable that source, or clear all three for local-only operation. +## The shared skills library + +From 0.4.0 this plugin also reads a directory shared with the user's other +agents, and registers WorkBuddy's own skills directory so those agents can read +it back. Tell the user this during installation — it is a new thing appearing +in their home directory and a new place their skills are visible from: + +```text +~/.evermind-skillsearch/ +├── registry.json which agent keeps its skills where +├── uninstalled.log what retrieval installed and later removed +└── skills/ what retrieval installed +``` + +Nothing needs configuring for it. Two switches exist and they are opposites, so +say which one the user means before changing either: + +| The user wants | Set | +| --- | --- | +| other agents not to see WorkBuddy's skills | `enabled: false` on the `workbuddy` line in `registry.json` | +| WorkBuddy not to see the other agents' skills | `"shareSkills": false` in this plugin's `config.json` | + +Deleting a line from `registry.json` does nothing lasting — that agent +re-registers on its next start. That is why the first switch is a flag rather +than a deletion. + +Skills retrieved from a catalogue are **kept** here now rather than discarded +after the turn, so state that plainly alongside the network disclosure above: +downloads persist, each with a `.skillsearch-origin.json` recording where it +came from, and removals are appended to `uninstalled.log`. + ## Verification — definition of done This plugin has two modes and they deliver skills by different routes, so they @@ -131,10 +162,27 @@ launches from the plugin manifest. There is no per-turn injection in this mode. the log records `injected_chars: 0`. Do not use a weather question: the public marketplaces contain real weather skills. +### The shared library (both modes) + +Both of this host's paths reach it and they have different lifecycles — the +hook is a fresh process per turn, the MCP server lives with the session — so +check it whichever mode is configured. + +6. **Registered:** `~/.evermind-skillsearch/registry.json` holds a `workbuddy` + entry whose `dir` is this install's real skills directory, absolute. Without + it the user's other agents cannot see WorkBuddy's skills at all. +7. **Not rewritten every turn:** note the file's mtime, run a few more turns, + check again — unchanged. On the hook path "at startup" means "every turn", + inside an 8-second budget. +8. **Reads the shared directory:** drop a skill into + `~/.evermind-skillsearch/skills/` and ask a question only it answers. It is + found on the next turn, with no restart. + If a check fails, report the failed step, the log entry, and the marketplace and plugin versions. Do not invoke `hook.mjs` or `mcp.mjs` by hand; that tests -the bundle, not whether WorkBuddy loaded it. The full case list, including -what to record, is [`../tests/host-e2e/cases.md`](../tests/host-e2e/cases.md). +the bundle, not whether WorkBuddy loaded it. The full case list — including the +shared-library steps as numbered acceptance items, and what to record — is +[`../tests/host-e2e/cases.md`](../tests/host-e2e/cases.md). ## Uninstall @@ -147,3 +195,10 @@ what to record, is [`../tests/host-e2e/cases.md`](../tests/host-e2e/cases.md). (`~/.workbuddy-ai/plugins/data/skillsearch-skillcorpus/`) and bundle cache (`~/.workbuddy-ai/skillsearch-bundles/`). These contain only plugin cache, configuration, and logs; leave them in place unless the user asks. +5. **Leave `~/.evermind-skillsearch/` alone unless this was the last agent.** + It is shared: the user's other agents register there and read skills from + it, so deleting it while any of them still has the plugin takes their + library with it. Removing WorkBuddy's own line from `registry.json` is the + right narrow cleanup, and mention that the directory holds skills retrieval + installed — `list` them from `skills/`, and `uninstalled.log` records what + was already removed — so the user can decide what they want kept. diff --git a/skillcorpus_plugin/tests/host-e2e/cases.md b/skillcorpus_plugin/tests/host-e2e/cases.md index 4083aa5..a013418 100644 --- a/skillcorpus_plugin/tests/host-e2e/cases.md +++ b/skillcorpus_plugin/tests/host-e2e/cases.md @@ -309,3 +309,76 @@ inconclusive at all. fails open, so an unreachable service and an empty result look identical from outside. The install cases report BLOCKED in that situation rather than a red that a rerun clears. + +## WorkBuddy, by hand + +The one host with no headless path, so S1–S9 are steps rather than a script. +Nothing here is exotic — it is the same corpus and the same questions the +scripts use, driven through the UI. + +**Both of its paths reach the shared library, and they have different +lifecycles.** `UserPromptSubmit` is a fresh process every turn; the MCP server +starts with the session and lives. Both call `scanDirs`, so both register — +which is why step 2 exists: on the hook path "at startup" means "every turn", +on the turn's hot path, inside an 8-second budget. + +### Setup + +```bash +mkdir -p ~/.evermind-skillsearch/skills/rotate-signing-keys +cat > ~/.evermind-skillsearch/skills/rotate-signing-keys/SKILL.md <<'MD' +--- +name: rotate-signing-keys +description: Rotate the service signing keys and re-issue downstream credentials safely. +--- + +House procedure: stage the new key under the `Narwhal-KMS-4` alias and keep the +previous one live until the `Quokka Cutover` window closes. +MD +``` + +Then start WorkBuddy and run one ordinary turn, so the plugin loads. + +### Steps + +1. **It registered.** `cat ~/.evermind-skillsearch/registry.json` — there is a + `workbuddy` entry, `enabled: true`, and `dir` is WorkBuddy's real skills + directory as an absolute path. If the entry is missing, stop: without it the + other agents cannot see this one, which is half the feature. + +2. **It does not rewrite the registry every turn.** Note the file's mtime, run + three or four more turns, check it again. Unchanged. This is the short + circuit the per-turn hook depends on — a write per turn is both a cost on + the hot path and a race with four other agents. + +3. **S5 — the shared skill is retrievable.** Ask *"What is our internal + procedure for rotating signing keys?"* in a fresh task. In the default + `on_demand` mode the agent should call `skill_search`; the answer must + mention `Narwhal-KMS-4` and `Quokka Cutover`, which exist nowhere else. If + the model answers "I don't know" without calling the tool, that is + INCONCLUSIVE rather than a failure — rerun it; see the verdicts in + `README.md`. + +4. **S4 — the other direction.** Put a skill in WorkBuddy's *own* skills + directory, then open another agent that has this plugin and ask for it + there. It should be found without that agent being told anything. + +5. **S6 — the user's switch holds.** Set `enabled: false` on the `workbuddy` + line in `registry.json`. The other agent stops finding WorkBuddy's skills. + Restart WorkBuddy, then read the file again: **still `false`**. A host + re-registering must never undo this, or the file is not editable. + +6. **S9 — a broken registry costs sharing, not the turn.** Replace + `registry.json` with `{ this is not json`, then ask anything. The turn + completes normally and retrieval still works from WorkBuddy's own + directory. Restore the file afterwards. + +7. **S7 — removal leaves a record.** After anything has been installed by + retrieval, remove it and check `~/.evermind-skillsearch/uninstalled.log`: + one JSON line per removal, with the origin, the version and a timestamp. + +### What to record + +Host version, plugin version, the commit, and for each step what you saw — +following the same fields as the other reports. A step that could not be run +is written down with the reason rather than left out. From 79f9259f90b6d3025f06f1696d6c5d06a5440ab9 Mon Sep 17 00:00:00 2001 From: Tianyi Sun Date: Wed, 9 Sep 2026 08:05:16 +0000 Subject: [PATCH 13/14] chore(release): 0.4.0, and fold the WorkBuddy report back in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WorkBuddy run found a release defect I had left: the branch carries 0.4.0 code while every manifest still says 0.3.0. A marketplace host compares the version and does not update, so the tester had to uninstall, delete the cache and reinstall to get the new dist at all. This is the same shape the 0.3.0 review caught, one release later. Bumped in all fourteen places: six packages, four host manifests, the marketplace entry, two runtime constants, the root `__version__` and Hermes's `plugin.yaml`. `verify_release_versions.py` is what proves the set is complete — it caught the last two after I thought I was done. `reports/workbuddy-shared-skills.md` is that run, checked in. It is the only real-host evidence for the one host with no headless path, and it was living in a share rather than in the repository. Its header says how to read it against the scripted reports, and a closing table says what happened to each of its findings. Two of them changed the cases file. **P2 failed a new way and the old note did not cover it.** On WorkBuddy's default model the reply named a skill that does not exist — `scanned-pdf-invoice-ocr` — without calling `skill_search` at all. "I don't know" is legible as a miss; a fabricated skill name reads as though retrieval worked. The case now says so, and says why the assertion is on the fixture's facts and the tool call rather than on the reply sounding right. Whether the answer is a reworded tool description or recommending `auto` on that host is a product decision and is left as one. **The manual checklist did not say which steps need a second agent**, so a machine with only WorkBuddy left S2, S5, S6, S7 and S9 blank as "not executed" when all five were verifiable there. There is now a table, and the steps say which half of S6 needs company. Co-Authored-By: Claude Opus 5 (1M context) --- .codebuddy-plugin/marketplace.json | 2 +- pyproject.toml | 2 +- skillcorpus/__init__.py | 2 +- skillcorpus_plugin/CHANGELOG.md | 43 +++++++ .../engine-python/pyproject.toml | 2 +- .../engine-typescript/package.json | 2 +- skillcorpus_plugin/plugin-hermes/plugin.yaml | 2 +- .../plugin-openclaw/openclaw.plugin.json | 2 +- .../plugin-openclaw/package.json | 2 +- .../plugin-openclaw2/openclaw.plugin.json | 2 +- .../plugin-openclaw2/package.json | 2 +- .../plugin-openclaw2/src/version.ts | 2 +- .../plugin-raven/pyproject.toml | 2 +- .../skillsearch_raven/raven-plugin.toml | 2 +- .../.codebuddy-plugin/plugin.json | 2 +- .../plugin-workbuddy/dist/mcp.mjs | 2 +- .../plugin-workbuddy/package.json | 2 +- .../plugin-workbuddy/src/version.ts | 2 +- skillcorpus_plugin/tests/host-e2e/README.md | 4 +- skillcorpus_plugin/tests/host-e2e/cases.md | 52 ++++++-- .../reports/workbuddy-shared-skills.md | 114 ++++++++++++++++++ 21 files changed, 220 insertions(+), 27 deletions(-) create mode 100644 skillcorpus_plugin/tests/host-e2e/reports/workbuddy-shared-skills.md diff --git a/.codebuddy-plugin/marketplace.json b/.codebuddy-plugin/marketplace.json index 37175e3..ef562fb 100644 --- a/.codebuddy-plugin/marketplace.json +++ b/.codebuddy-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "skillsearch", "description": "Per-turn skill retrieval for WorkBuddy.", "source": "./skillcorpus_plugin/plugin-workbuddy", - "version": "0.3.0", + "version": "0.4.0", "category": "skill" } ] diff --git a/pyproject.toml b/pyproject.toml index 78dfc0b..743ccf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "skillcorpus" -version = "0.3.0" +version = "0.4.0" description = "SkillCorpus — an open pipeline that builds a corpus of agent skills" readme = "README.md" requires-python = ">=3.10" diff --git a/skillcorpus/__init__.py b/skillcorpus/__init__.py index 9d6dd29..b679275 100644 --- a/skillcorpus/__init__.py +++ b/skillcorpus/__init__.py @@ -22,4 +22,4 @@ def __getattr__(name): "SkillRecord", "Category", "CATEGORIES", "SkillLibrary", "IngestResult", "IngestStatus", ] -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/skillcorpus_plugin/CHANGELOG.md b/skillcorpus_plugin/CHANGELOG.md index 1f745af..d32b286 100644 --- a/skillcorpus_plugin/CHANGELOG.md +++ b/skillcorpus_plugin/CHANGELOG.md @@ -2,6 +2,49 @@ ## Unreleased +## 0.4.0 — unreleased + +### Added + +- **A shared skills library.** Every host now also reads + `~/.evermind-skillsearch/skills/` and registers its own skills directory in + `~/.evermind-skillsearch/registry.json`, so a skill you have in one agent is + visible to the others. Nothing is hardcoded about which agents you run: each + writes the directory it actually resolved at startup, and reads the table + back. +- **Retrieved skills are kept.** A skill downloaded from a catalogue used to be + extracted for one turn and thrown away. It now lands in the shared library + with a `.skillsearch-origin.json` recording where it came from, one directory + per skill rather than per version, and removals append to + `uninstalled.log`. +- **Changes to the shared directory take effect on the next turn**, with no + restart. A host's own directory keeps that host's existing behaviour. +- `skills_dirs` and `SKILLSEARCH_SKILLS_DIRS` on Raven and Hermes, which had + only a single `skills_dir` and no environment override while the engine + supported several roots all along. + +### Changed + +- An unrecognised `mode` is still narrowed to the default rather than failing + the load, but it is now **logged** with the value that was asked for. On both + OpenClaw generations the host rejects a bad value outright, so this covers + the three hosts with no schema to validate against, and the environment + override on all of them. +- A source that is down is reported through the host's logger on both OpenClaw + packages. The engine always emitted the diagnostic; nothing consumed it, so + an unreachable catalogue and an empty one were indistinguishable. +- A whitespace-only environment variable now counts as unset on the TypeScript + side, matching Python. It previously emptied `skillsDirs`, taking the host's + own skills directory — and with it the shared library — silently. + +### Notes + +- Two switches, deliberately opposite: `enabled` in the registry is "the others + cannot see me"; `shareSkills` / `share_skills` in a host's own config is "I + cannot see the others". +- `~/.evermind-skillsearch/` is shared. Uninstalling one agent should remove + that agent's line from the registry, not the directory. + ## 0.3.0 — 2026-09-02 ### Changed — read this before upgrading diff --git a/skillcorpus_plugin/engine-python/pyproject.toml b/skillcorpus_plugin/engine-python/pyproject.toml index 9b2eeaa..0ce27a0 100644 --- a/skillcorpus_plugin/engine-python/pyproject.toml +++ b/skillcorpus_plugin/engine-python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "skillsearch" -version = "0.3.0" +version = "0.4.0" description = "Skill retrieval for agent hosts — one engine, five host adapters." readme = "README.md" requires-python = ">=3.11" diff --git a/skillcorpus_plugin/engine-typescript/package.json b/skillcorpus_plugin/engine-typescript/package.json index 50e0c94..4b81b12 100644 --- a/skillcorpus_plugin/engine-typescript/package.json +++ b/skillcorpus_plugin/engine-typescript/package.json @@ -1,7 +1,7 @@ { "name": "@evermind-ai/dsh-skill-search", "description": "Per-turn skill retrieval for the DeepSeek Harness: fuse local and remote sources, gate by relevance, inject what fits", - "version": "0.3.0", + "version": "0.4.0", "repository": { "type": "git", "url": "git+https://github.com/EverMind-AI/SkillCorpus.git", diff --git a/skillcorpus_plugin/plugin-hermes/plugin.yaml b/skillcorpus_plugin/plugin-hermes/plugin.yaml index 9d71f90..f92858e 100644 --- a/skillcorpus_plugin/plugin-hermes/plugin.yaml +++ b/skillcorpus_plugin/plugin-hermes/plugin.yaml @@ -1,5 +1,5 @@ name: skillsearch -version: "0.3.0" +version: "0.4.0" manifest_version: 1 description: "Skill retrieval — a local directory, a remote catalog, and a model that drops what this agent cannot run." hooks: diff --git a/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json b/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json index 9c01203..bd2ccda 100644 --- a/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json +++ b/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "skillsearch", "name": "Skill Search", "description": "Per-turn skill retrieval: local directory, remote catalog, model-gated selection.", - "version": "0.3.0", + "version": "0.4.0", "activation": { "onStartup": true }, diff --git a/skillcorpus_plugin/plugin-openclaw/package.json b/skillcorpus_plugin/plugin-openclaw/package.json index 0e18fb8..8a82e36 100644 --- a/skillcorpus_plugin/plugin-openclaw/package.json +++ b/skillcorpus_plugin/plugin-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@evermind-ai/openclaw-skillsearch", - "version": "0.3.0", + "version": "0.4.0", "description": "Skill retrieval for OpenClaw: local directory, remote catalog, model-gated selection.", "license": "Apache-2.0", "type": "module", diff --git a/skillcorpus_plugin/plugin-openclaw2/openclaw.plugin.json b/skillcorpus_plugin/plugin-openclaw2/openclaw.plugin.json index ad92dfb..c2b2991 100644 --- a/skillcorpus_plugin/plugin-openclaw2/openclaw.plugin.json +++ b/skillcorpus_plugin/plugin-openclaw2/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "skillsearch", "name": "Skill Search", "description": "Per-turn skill retrieval as a context engine: local directory, remote catalog, model-gated selection.", - "version": "0.3.0", + "version": "0.4.0", "kind": "context-engine", "activation": { "onStartup": true diff --git a/skillcorpus_plugin/plugin-openclaw2/package.json b/skillcorpus_plugin/plugin-openclaw2/package.json index dcf833c..f7531f2 100644 --- a/skillcorpus_plugin/plugin-openclaw2/package.json +++ b/skillcorpus_plugin/plugin-openclaw2/package.json @@ -1,6 +1,6 @@ { "name": "@evermind-ai/openclaw2-skillsearch", - "version": "0.3.0", + "version": "0.4.0", "description": "Skill retrieval for OpenClaw 2.0 as a context engine: local directory, remote catalog, model-gated selection.", "license": "Apache-2.0", "type": "module", diff --git a/skillcorpus_plugin/plugin-openclaw2/src/version.ts b/skillcorpus_plugin/plugin-openclaw2/src/version.ts index e72c825..ff18a90 100644 --- a/skillcorpus_plugin/plugin-openclaw2/src/version.ts +++ b/skillcorpus_plugin/plugin-openclaw2/src/version.ts @@ -14,4 +14,4 @@ * @module */ -export const VERSION = '0.3.0' +export const VERSION = '0.4.0' diff --git a/skillcorpus_plugin/plugin-raven/pyproject.toml b/skillcorpus_plugin/plugin-raven/pyproject.toml index 7b4271a..964eab7 100644 --- a/skillcorpus_plugin/plugin-raven/pyproject.toml +++ b/skillcorpus_plugin/plugin-raven/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "skillsearch-raven" -version = "0.3.0" +version = "0.4.0" description = "Skill retrieval for Raven: the context segment that claims the `skills` stage." readme = "README.md" requires-python = ">=3.11" diff --git a/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml b/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml index 2431e30..eaffa0d 100644 --- a/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml +++ b/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml @@ -1,6 +1,6 @@ [plugin] id = "skillsearch" -version = "0.3.0" +version = "0.4.0" display_name = "Skill retrieval" raven = ">=0.1" bundled = false diff --git a/skillcorpus_plugin/plugin-workbuddy/.codebuddy-plugin/plugin.json b/skillcorpus_plugin/plugin-workbuddy/.codebuddy-plugin/plugin.json index 7c6735f..eaccd98 100644 --- a/skillcorpus_plugin/plugin-workbuddy/.codebuddy-plugin/plugin.json +++ b/skillcorpus_plugin/plugin-workbuddy/.codebuddy-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skillsearch", - "version": "0.3.0", + "version": "0.4.0", "description": "每轮按提问检索 skill:本地目录 + 远端目录,只把命中的送进上下文", "author": { "name": "EverMind AI", diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs index f1f6edc..71ef6f7 100644 --- a/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/mcp.mjs @@ -2381,7 +2381,7 @@ async function retrieveForTurn(query, config, deps = {}, onDiagnostic, workspace } // src/version.ts -var VERSION = "0.3.0"; +var VERSION = "0.4.0"; // src/mcp.ts var PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"]; diff --git a/skillcorpus_plugin/plugin-workbuddy/package.json b/skillcorpus_plugin/plugin-workbuddy/package.json index 73ff27a..3e900c0 100644 --- a/skillcorpus_plugin/plugin-workbuddy/package.json +++ b/skillcorpus_plugin/plugin-workbuddy/package.json @@ -1,6 +1,6 @@ { "name": "@evermind-ai/workbuddy-skillsearch", - "version": "0.3.0", + "version": "0.4.0", "description": "Skill retrieval for WorkBuddy: a UserPromptSubmit hook over the skillsearch engine.", "license": "Apache-2.0", "type": "module", diff --git a/skillcorpus_plugin/plugin-workbuddy/src/version.ts b/skillcorpus_plugin/plugin-workbuddy/src/version.ts index e72c825..ff18a90 100644 --- a/skillcorpus_plugin/plugin-workbuddy/src/version.ts +++ b/skillcorpus_plugin/plugin-workbuddy/src/version.ts @@ -14,4 +14,4 @@ * @module */ -export const VERSION = '0.3.0' +export const VERSION = '0.4.0' diff --git a/skillcorpus_plugin/tests/host-e2e/README.md b/skillcorpus_plugin/tests/host-e2e/README.md index 67e121a..49f203c 100644 --- a/skillcorpus_plugin/tests/host-e2e/README.md +++ b/skillcorpus_plugin/tests/host-e2e/README.md @@ -13,7 +13,9 @@ way of deciding PASS, so a result from one release is comparable with the next. hosts and two modes; **S1–S9** are the shared skills library, which is about hosts seeing each other and therefore needs two of them running. - [`reports/`](reports) — what actually happened. `0.3.0.md` is a release; - `shared-skills.md` is a feature's acceptance. + `shared-skills.md` is a feature's acceptance, script-driven; + `workbuddy-shared-skills.md` is the same feature on the one host that has no + headless path, so it is a person's observations rather than script output. - [`scripts/`](scripts) — the five hosts that can be driven headlessly. ## Three layers, and what each one is worth diff --git a/skillcorpus_plugin/tests/host-e2e/cases.md b/skillcorpus_plugin/tests/host-e2e/cases.md index a013418..7b814e6 100644 --- a/skillcorpus_plugin/tests/host-e2e/cases.md +++ b/skillcorpus_plugin/tests/host-e2e/cases.md @@ -114,6 +114,19 @@ templates and "our" way of doing things — the one saying that searching here comes *before* answering that you do not know. Changing that paragraph means re-running this case on at least two hosts. +**It has since failed a second way, which is worse.** On WorkBuddy 5.4.7 with +that host's default model, the reply named a skill that does not exist — +`scanned-pdf-invoice-ocr` — without ever calling `skill_search`. "I don't know" +is at least legible as a miss; a fabricated skill name reads as though +retrieval worked. So the assertion is on the fixture's facts and on the tool +call, never on the reply merely sounding like it found something. + +The same run showed the clause is not enough on every model: an explicit +instruction to call the tool did trigger it, so the tool exists and works and +the description is what did not carry. Whether that is answered by rewording +the description or by recommending `auto` on that host is a product decision, +not a test one — it is recorded in `reports/workbuddy-shared-skills.md`. + ## P3 — No match Both modes. Deliberately not a weather question: the public catalogues carry @@ -339,6 +352,19 @@ MD Then start WorkBuddy and run one ordinary turn, so the plugin loads. +### Which of these need a second agent + +Five of the nine do not, and saying so matters: the first run of this checklist +left S2, S5, S6, S7 and S9 blank as "not executed" on a machine that had only +WorkBuddy, when all five were verifiable there. + +| Needs only WorkBuddy | Needs a second agent with the plugin | +| --- | --- | +| S1, S2, S5, S7, S9, and S6's *survives-a-restart* half | S3, S4, and S6's *hides-it-from-the-other* half | + +S8 is covered against the real catalogue by `e2e_install.py` and does not need +repeating here. + ### Steps 1. **It registered.** `cat ~/.evermind-skillsearch/registry.json` — there is a @@ -359,21 +385,29 @@ Then start WorkBuddy and run one ordinary turn, so the plugin loads. INCONCLUSIVE rather than a failure — rerun it; see the verdicts in `README.md`. -4. **S4 — the other direction.** Put a skill in WorkBuddy's *own* skills - directory, then open another agent that has this plugin and ask for it - there. It should be found without that agent being told anything. +4. **S2 — found once, not twice.** Ask the same question again in a fresh + task. The skill appears in the answer exactly once. This is the case that + installing into a scanned directory could break: the local copy and the + catalogue's own entry are one skill and must collapse into one hit. + +5. **S6, the half that needs nobody else — the flag survives a restart.** Set + `enabled: false` on the `workbuddy` line in `registry.json`, fully quit and + reopen WorkBuddy, then read the file again: **still `false`**. A host + re-registering must never undo it, or the file is not editable. *(The other + half — that another agent stops seeing WorkBuddy's skills — needs a second + agent.)* -5. **S6 — the user's switch holds.** Set `enabled: false` on the `workbuddy` - line in `registry.json`. The other agent stops finding WorkBuddy's skills. - Restart WorkBuddy, then read the file again: **still `false`**. A host - re-registering must never undo this, or the file is not editable. +6. **S4 — the other direction.** *(Second agent required.)* Put a skill in + WorkBuddy's *own* skills directory, then open another agent that has this + plugin and ask for it there. It should be found without that agent being + told anything. -6. **S9 — a broken registry costs sharing, not the turn.** Replace +7. **S9 — a broken registry costs sharing, not the turn.** Replace `registry.json` with `{ this is not json`, then ask anything. The turn completes normally and retrieval still works from WorkBuddy's own directory. Restore the file afterwards. -7. **S7 — removal leaves a record.** After anything has been installed by +8. **S7 — removal leaves a record.** After anything has been installed by retrieval, remove it and check `~/.evermind-skillsearch/uninstalled.log`: one JSON line per removal, with the origin, the version and a timestamp. diff --git a/skillcorpus_plugin/tests/host-e2e/reports/workbuddy-shared-skills.md b/skillcorpus_plugin/tests/host-e2e/reports/workbuddy-shared-skills.md new file mode 100644 index 0000000..5b0a40d --- /dev/null +++ b/skillcorpus_plugin/tests/host-e2e/reports/workbuddy-shared-skills.md @@ -0,0 +1,114 @@ +# SkillCorpus · WorkBuddy Host E2E — feat/shared-skills + +> WorkBuddy 是唯一没有无头路径的宿主,所以这是一份人工报告,形态和 +> `shared-skills.md` 里那些脚本产出的行不同:那边每一行背后有 transcript +> 和 `--dump` 的完整记录,这边是在 WorkBuddy 界面里操作观察到的。 +> 用例编号对应 [`../cases.md`](../cases.md) 的 P1–P6 与 S1–S9; +> 手工步骤在同一文件的 `## WorkBuddy, by hand`。 + +## Environment + +| Field | Value | +|---|---| +| Plugin version | 0.3.0 (marketplace 字段仍为 0.3.0;dist 已含 shared-skills 代码) | +| SkillCorpus commit | `ca324890b08d5082f57c87eeb35d91b84051b32d` | +| Host | WorkBuddy 5.4.7 (`com.tencent.workbuddy.mac`) | +| OS | macOS 15.1 (Darwin 24.1.0), Apple M4 Pro (arm64) | +| Node | v22.22.2 (WorkBuddy bundled) | +| Install method | 标准 marketplace(CLI: `plugin marketplace add -n skillcorpus` → `plugin install skillsearch@skillcorpus`) | +| Remote sources | P1–P4 期间按文档关闭;共享库探测期间开启 | +| Model | WorkBuddy 默认 `balanced-model` | +| Test date / tester | 2026-09-09 / Claude Code(辅助)+ 人工在 WorkBuddy 内交互 | + +> 注意:本地已装版本与分支 marketplace 版本号同为 0.3.0(分支未 bump),普通 `plugin update` 不会拉取新 dist。测试通过「卸载 + 删缓存 + 重新 install」强制拿到含 shared-skills 代码的 dist。 + +--- + +## P1–P6 · 单宿主检索(两种模式) + +### on_demand(默认) + +| Case | Verdict | 证据 | +|---|---|---| +| P1 正向检索 | **INCONCLUSIVE** | hook 不注入(`injected_chars:0`)✅;`tools/list → ['skill_search']` ✅;但模型从自己知识回答整套流程、未调工具 ❌。引擎直驱命中 `pdf-tables`、返回含 `Vireo-CSV-3`+`Okapi Ledger` ✅ | +| P2 内部约定触发 | **FAIL** | 模型未调 `skill_search`(index-cache 无更新);回复编造不存在的 skill 名 `scanned-pdf-invoice-ocr`;无 sentinel facts | +| P3 零注入 | **PASS** | 模型未调工具 ✅;hook `injected_chars:0` ✅ | +| P4 typo (`mode:"atuo"`) | **PASS** | fallback 到 `on_demand`(工具仍 offered);日志点名坏值 `unknown_mode:"atuo"` / `mode_used:"on_demand"` | + +### auto + +| Case | Verdict | 证据 | +|---|---|---| +| P1 正向检索 | **PASS** | hook 自动注入 `pdf-tables`(`injected_chars:450`);注入文本含 `Vireo-CSV-3`+`Okapi Ledger` ✅;`tools/list → []`(工具面空)✅;无工具调用 ✅ | +| P2 内部约定触发 | **PASS** | hook 自动注入(`injected_chars:450`);回复含 `Vireo-CSV-3`+`Okapi Ledger` ✅ | +| P3 零注入 | **PASS** | hook `injected_chars:0` ✅;无工具可调 | +| P4 typo | **PASS** | 与 on_demand 同(typo 与当前模式无关)| + +### auto 第 1 点回归测试(cases.md 明确标记的已知 bug) + +- MCP 进程在 `auto` 下 **alive** 且 `tools/list` 返回空 `[]` —— 未复现 `e337cfa` 时代「auto 下 MCP 启动即退」的问题。**PASS** + +--- + +## S1–S9 · 共享技能库 + +共享根目录 `~/.evermind-skillsearch/`(默认,`SKILLSEARCH_HOME` 可移动)。 + +| # | Claim | Verdict | 证据 | +|---|---|---|---| +| S1 | 检索的 skill 出现在 `/skills/` | **PASS** | 远程命中后 `skills/` 持久保留 `hub____` 目录,含 `SKILL.md` + 脚本 | +| S2 | 下次 turn 本地找到、恰好一次、不重启 | not executed | — | +| S3 | agent B 检索 agent A 安装的 | **BLOCKED** | 需第二个 agent(本机仅有 WorkBuddy)| +| S4 | A 自己目录的 skill 在 B 可检索 | **BLOCKED** | 需第二个 agent | +| S5 | 手工放入共享目录、下次 turn 无重启命中 | not executed | fixture `rotate-signing-keys` 已放入 `skills/`,未在宿主内验证命中 | +| S6 | `enabled:false` 隐藏且重启后保持 | not executed | — | +| S7 | 移除留 `uninstalled.log` 记录 | not executed | — | +| S8 | 失败更新留下旧版本可用 | **BLOCKED** | 需死端点模拟(且需版本号 bump)| +| S9 | 损坏 registry 只损共享、不损检索 | not executed | — | + +### 已确认的共享库核心机制(通过宿主内 + 直接驱动验证) + +1. `registry.json` 记录 workbuddy host:`{"id":"workbuddy","dir":"~/.workbuddy-ai/skills","enabled":true}` ✅ +2. 远程命中的 skill 持久保留到 `skills/`(不再用完即弃)✅ +3. `.skillsearch-origin.json` 元数据完整(origin/source/slug/sha256/installed_at)✅ +4. 宿主内触发(发 "天气")→ 新远程 skill(global-weather、current-weather)写入共享库 ✅ +5. `shareSkills:false` → 远程 skill 回到 throwaway、不写共享库 ✅ +6. `SKILLSEARCH_HOME` 覆盖根目录(代码确认)✅ + +--- + +## 关键发现 + +1. **on_demand 的「模型主动调用工具」在 WorkBuddy 默认模型上不可靠。** P1 从知识回答(INCONCLUSIVE),P2 问内部约定时编造不存在的 skill 名(FAIL)。工具描述里「搜库优先于回答不知道」未能在该模型上生效。对照:中文显式指令「请调用 skill_search」能触发。 + +2. **P2 失败形态与文档历史记录不同。** 文档记录的早期失败是「I don't know」;WorkBuddy 本次是**编造 skill 名**(`scanned-pdf-invoice-ocr`),比「I don't know」更隐蔽(表面像「知道有个 skill」)。 + +3. **版本号未 bump。** 分支 `marketplace.json`/`plugin.json` 仍为 0.3.0,但 dist 已含 0.4.0 未发布的 shared-skills 代码。走 marketplace 的 host(如 WorkBuddy)会因版本号相同而**不触发更新**——这是发布流程隐患,需在正式发布前 bump。 + + **已修**(本报告之后):全部 14 处声明统一 bump 到 `0.4.0`——六个包、四份宿主清单、marketplace 条目、两个运行时常量、根 `__version__` 和 Hermes 的 `plugin.yaml`。`verify_release_versions.py` 现在对 0.4.0 通过;本报告里那个「卸载 + 删缓存 + 重装」的绕行不再需要。 + +4. **auto 通道完整可用。** hook 自动注入绕过「模型是否主动调工具」,P1/P2 均 PASS,sentinel facts 正确出现在注入文本与回复中。 + +5. **共享库机制完整工作**(见上表),跨 host 共享(S3/S4)因缺少第二 agent 未验证。 + +--- + +## 结论 + +- **插件代码无需修改**:两种模式 wiring、共享库核心机制均按设计工作。 +- **on_demand 在 WorkBuddy 上的工具触发是真实风险点**,需团队决策:改进工具描述/触发,或对 WorkBuddy 推荐 `auto` 模式。 +- **发布前需处理**:版本号 bump(否则 marketplace 更新不生效)。 +- 本报告的缺口(S2/S5/S6/S7/S9 未执行、S3/S4/S8 BLOCKED)均已显式列出。 + +--- + +## 这份报告之后做了什么 + +| 报告里的发现 | 处理 | +|---|---| +| 版本号未 bump(发现 3) | **已修**:14 处声明统一到 0.4.0,校验器通过 | +| P2 编造 skill 名(发现 2) | **已记入用例**:`cases.md` 的 P2 现在写明这第二种失败形态,并说明为什么断言只看 fixture 事实和工具调用、不看回复"像不像找到了" | +| on_demand 触发不可靠(发现 1) | **未改代码**。是改工具描述还是对 WorkBuddy 推荐 `auto`,属于产品决策;报告已把风险写清楚 | +| S3/S4 BLOCKED(缺第二个 agent) | 脚本侧已在别的机器上覆盖:`e2e_shared.py` 用 Raven + OpenClaw 2.0 验过 S3/S4,见 `shared-skills.md`。WorkBuddy 参与的跨宿主组合仍未验 | +| S2/S5/S6/S7/S9 未执行 | 这五条**单宿主就能验**,不需要第二个 agent。`cases.md` 的手工步骤现已逐条标注哪些要第二个 agent、哪些不要,避免下次再整片留空 | +| S8 BLOCKED | 同样已在脚本侧对真 catalogue 验过(`e2e_install.py`),WorkBuddy 上不必重复 | From 5b5e50097b7e41340afe717a28780c5d5992384a Mon Sep 17 00:00:00 2001 From: yao pengfei Date: Fri, 11 Sep 2026 02:25:51 +0000 Subject: [PATCH 14/14] docs(release): add the v0.4.0 notes The release job's version check reads `docs/releases/v.md`, and this branch bumps the root package to 0.4.0, so the file has to exist before `build release artifacts` can get past its first step. The requirement landed in #24, after this branch was written, so it only appeared once main was merged in. `check-notes` also returns the previous tag out of the Full Changelog line and the job then asserts that tag exists and is an ancestor of HEAD, so the line names v0.3.0 rather than the plugin-scoped tag v0.3.0's own notes used. Co-Authored-By: Claude Opus 5 (1M context) --- docs/releases/v0.4.0.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/releases/v0.4.0.md diff --git a/docs/releases/v0.4.0.md b/docs/releases/v0.4.0.md new file mode 100644 index 0000000..e1027e6 --- /dev/null +++ b/docs/releases/v0.4.0.md @@ -0,0 +1,17 @@ +## What’s Changed + +SkillCorpus v0.4.0 makes the skill library shared across agents: a skill you have in one host is visible to the others, and a skill retrieved from a catalogue is kept instead of thrown away at the end of the turn. + +* Added a shared skills library across all six hosts — one shared root at `~/.evermind-skillsearch/`, hosts self-register the directory they actually resolved, retrieved skills are installed rather than cached for a turn, and changes to the shared directory take effect on the next turn with no restart, by @Tian-yi-Sun in https://github.com/EverMind-AI/SkillCorpus/pull/26 +* Hardened the repository gates and the release path — stable required-check summaries, full-tree size checks on push, pinned CI dependencies, grouped dependency updates, and a release that validates versions, notes and checksums before publishing, by @cyfyifanchen in https://github.com/EverMind-AI/SkillCorpus/pull/24 +* Fixed the Raven plugin schema's `top_k` default, the one host left at 5 when the multi-source release narrowed every other host to 2, by @ypflll in https://github.com/EverMind-AI/SkillCorpus/pull/25 + +## Upgrade Note + +Sharing is on by default. Every host also reads `~/.evermind-skillsearch/skills/` and the directories other hosts registered in `~/.evermind-skillsearch/registry.json`, and skills retrieved from a catalogue are installed there. Set `share_skills` / `shareSkills` to `false` on a host to keep it out, or set an entry's `enabled` to `false` in the registry to stop the others reading that directory — the two switches answer opposite questions, and deleting a registry line does not work because the host re-registers on its next start. `SKILLSEARCH_HOME` moves the shared root. + +Raven and Hermes gain `skills_dirs` and `SKILLSEARCH_SKILLS_DIRS`; both previously accepted only a single directory. Raven's `top_k` default is now 2, matching every other host and the engine — a deployment that wants the old value must set it explicitly. + +Packages are currently distributed through this GitHub Release. PyPI and npm publication are not yet available. + +**Full Changelog**: https://github.com/EverMind-AI/SkillCorpus/compare/v0.3.0...v0.4.0