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/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 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/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/__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/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 15c2490..cccad15 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: @@ -167,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/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-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 6f0eb89..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, ) @@ -214,9 +216,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 +242,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 +285,41 @@ 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. + + 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 +327,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/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..b7d2426 --- /dev/null +++ b/skillcorpus_plugin/engine-python/skillsearch/provenance.py @@ -0,0 +1,364 @@ +"""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() + + +#: 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``. + + 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 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 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]]: + """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[tuple[Path, 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((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 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 + + +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) + + +#: 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. 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/skillsearch/shared.py b/skillcorpus_plugin/engine-python/skillsearch/shared.py new file mode 100644 index 0000000..ff91d88 --- /dev/null +++ b/skillcorpus_plugin/engine-python/skillsearch/shared.py @@ -0,0 +1,369 @@ +"""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}) + + 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 + # 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/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/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 new file mode 100644 index 0000000..3385db6 --- /dev/null +++ b/skillcorpus_plugin/engine-python/tests/conftest.py @@ -0,0 +1,23 @@ +"""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_provenance.py b/skillcorpus_plugin/engine-python/tests/test_provenance.py new file mode 100644 index 0000000..0d3075b --- /dev/null +++ b/skillcorpus_plugin/engine-python/tests/test_provenance.py @@ -0,0 +1,405 @@ +"""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"] + + +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) == [] + + +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"] + + +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-python/tests/test_shared.py b/skillcorpus_plugin/engine-python/tests/test_shared.py new file mode 100644 index 0000000..aab6982 --- /dev/null +++ b/skillcorpus_plugin/engine-python/tests/test_shared.py @@ -0,0 +1,346 @@ +"""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) == [] + + +# --------------------------------------------------------------------------- +# 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 import shared + from skillsearch.config import SearchConfig + from skillsearch.engine import SkillSearch + + 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/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/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/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 776ec4f..f1220c5 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,6 +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, sharedSkillsDir } from './shared.js' import { MarketplaceClient, MarketplaceSkillSource } from './marketplace-source.js' import { QueryRewriter } from './rewriter.js' @@ -57,6 +59,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 +160,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'), @@ -336,15 +343,42 @@ 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 ?? [] - 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. + // `?? 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 sources.push(local) } @@ -352,6 +386,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 @@ -370,6 +405,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, }) @@ -390,6 +426,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/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..e59eb43 --- /dev/null +++ b/skillcorpus_plugin/engine-typescript/src/provenance.ts @@ -0,0 +1,404 @@ +/** + * 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 { 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, + * 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 { + // 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}`) +} + +/** + * 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 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. + * + * 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[] { + return entries(root) + .map(([, marker]) => marker) + .sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0)) +} + +/** + * 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 [directory, marker] of entries(root)) { + if (marker.origin === origin) return directory + } + 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. + */ +/** 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) + } catch { + return false + } + rmSync(retired, { recursive: true, force: true }) + if (removed) recordUninstall(root, removed, logPath) + return true +} diff --git a/skillcorpus_plugin/engine-typescript/src/shared.ts b/skillcorpus_plugin/engine-typescript/src/shared.ts new file mode 100644 index 0000000..5ca0c9c --- /dev/null +++ b/skillcorpus_plugin/engine-typescript/src/shared.ts @@ -0,0 +1,362 @@ +/** + * 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 + // 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) + } catch { + // Sharing is never worth a failed turn. + } + return out +} 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/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/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 56d73bb..7f8f7ee 100644 --- a/skillcorpus_plugin/engine-typescript/tests/parity.test.ts +++ b/skillcorpus_plugin/engine-typescript/tests/parity.test.ts @@ -12,7 +12,8 @@ */ import assert from 'node:assert/strict' -import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +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' import test from 'node:test' @@ -23,6 +24,9 @@ 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 { 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' @@ -540,3 +544,372 @@ 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', {}), []) +}) + +// --------------------------------------------------------------------------- +// 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) +}) + +// --------------------------------------------------------------------------- +// 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/) +}) + +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}`, + ) + } +}) + +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-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/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-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..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 }, @@ -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/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-openclaw/src/config.ts b/skillcorpus_plugin/plugin-openclaw/src/config.ts index b7b64bf..af5d151 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', } @@ -212,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 { @@ -239,6 +260,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..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,6 +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, 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' @@ -41,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, @@ -49,11 +71,13 @@ 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 installRoot = installRootFor(config.shareSkills) + const roots = scanDirs('openclaw', dirs, config.shareSkills) + if (roots.length > 0) { + sources.push(new LocalSkillSource(roots, { indexBody: config.indexBody })) } let client: SkillHubClient | undefined @@ -63,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)) } @@ -75,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)) @@ -89,6 +115,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-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..1f063b0 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 { @@ -332,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-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..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 @@ -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/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/config.ts b/skillcorpus_plugin/plugin-openclaw2/src/config.ts index b7b64bf..af5d151 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', } @@ -212,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 { @@ -239,6 +260,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..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,6 +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, 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' @@ -79,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, @@ -87,11 +109,13 @@ 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 installRoot = installRootFor(config.shareSkills) + const roots = scanDirs('openclaw2', dirs, config.shareSkills) + if (roots.length > 0) { + sources.push(new LocalSkillSource(roots, { indexBody: config.indexBody })) } let client: SkillHubClient | undefined @@ -101,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)) } @@ -113,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)) @@ -127,6 +153,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/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-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..4fa0590 100644 --- a/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts +++ b/skillcorpus_plugin/plugin-openclaw2/test/register.test.ts @@ -15,8 +15,17 @@ 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' -import { DEFAULTS } from '../src/config.ts' + +// 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 { VERSION } from '../src/version.ts' import type { @@ -373,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-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/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/__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 a881795..efdee4d 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 @@ -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/.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/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/plugin-workbuddy/dist/hook.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs index 22c8fc0..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 mkdirSync2 } 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"; @@ -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) { @@ -149,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, @@ -182,25 +184,32 @@ 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 { mkdirSync as mkdirSync4 } from "node:fs"; +import { homedir as homedir3 } from "node:os"; +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) { @@ -540,6 +549,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; @@ -558,6 +629,7 @@ var SkillSearchEngine = class { refs; placeholders; runtime; + watch; constructor(parts, options = {}) { this.sources = parts.sources; this.rewriter = parts.rewriter; @@ -565,6 +637,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; @@ -600,7 +673,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); @@ -835,13 +928,115 @@ function errorMessage(error) { } // ../engine-typescript/src/hub-source.ts -import { existsSync as existsSync2 } from "node:fs"; -import { join as join4 } 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 { 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) { + 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 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 = { + _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 join3, 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"; @@ -979,7 +1174,7 @@ async function extractBundle(archive, destination) { } const data = entry.read(); total += data.length; - await mkdir(join3(target, ".."), { recursive: true }); + await mkdir(join5(target, ".."), { recursive: true }); await writeFile(target, data); } try { @@ -1005,7 +1200,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 join5(destination, only.name); return destination; } @@ -1139,6 +1334,7 @@ var SkillHubClient = class { timeoutMs; downloadTimeoutMs; cacheDir; + installRoot; source; constructor(endpoint, options = {}) { this.base = endpoint.replace(/\/+$/, ""); @@ -1146,6 +1342,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"; } /** @@ -1161,19 +1358,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 = join4(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. @@ -1318,17 +1553,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 join5 } 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; } @@ -1339,8 +1576,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 = join5(this.cacheDir, key); + const destination = join7(this.cacheDir, key); if (!existsSync3(destination)) { let archive; try { @@ -1356,7 +1594,7 @@ var MarketplaceClient = class { } try { const dir = await bundleRoot(destination); - const skillMd = await readFile(join5(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(() => { @@ -1364,6 +1602,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); @@ -1484,13 +1762,166 @@ function errorMessage2(error) { return error instanceof Error ? error.message : String(error); } +// ../engine-typescript/src/shared.ts +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 join8, resolve as resolve2 } from "node:path"; +var HOME_ENV = "SKILLSEARCH_HOME"; +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 join8(home, path.slice(2)); + return path; +} +function sharedRoot(env = process.env) { + const override = (env[HOME_ENV] ?? "").trim(); + if (override) return expandHome(override); + return join8(homedir2(), ".evermind-skillsearch"); +} +function sharedSkillsDir(env = process.env) { + return join8(sharedRoot(env), "skills"); +} +function registryPath(env = process.env) { + return join8(sharedRoot(env), "registry.json"); +} +function isDirectory2(path) { + try { + return statSync4(path).isDirectory(); + } catch { + return false; + } +} +function readRegistry(path, env = process.env) { + const target = path ?? registryPath(env); + let raw; + try { + raw = JSON.parse(readFileSync3(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: 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("\\"))); + mkdirSync2(parent, { recursive: true }); + staging = mkdtempSync2(join8(parent, ".registry-")); + const scratch = join8(staging, "registry.json"); + writeFileSync2(scratch, `${JSON.stringify(payload, null, 2)} +`, "utf8"); + renameSync2(scratch, path); + return true; + } catch { + return false; + } finally { + if (staging) { + try { + rmSync3(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; + if (ownDirs.length === 0) 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 mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync3, renameSync as renameSync3, statSync as statSync5, writeFileSync as writeFileSync3 } from "node:fs"; +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"; -import { basename, join as join6 } from "node:path"; +import { basename, join as join9 } from "node:path"; // ../engine-typescript/src/bm25.ts var TOKEN_RE = /[a-z0-9]{2,}|[一-鿿]+/g; @@ -1578,9 +2009,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; @@ -1615,7 +2046,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 ?? "" } })); } @@ -1643,7 +2078,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; @@ -1679,9 +2114,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 }); - } else if (entry.name === SKILL_FILE) { - yield join6(dir, entry.name); + if (!SKIP_DIRS2.has(entry.name)) stack.push({ dir: join9(dir, entry.name), depth: depth + 1 }); + } else if (entry.name === SKILL_FILE2) { + yield join9(dir, entry.name); } } } @@ -1704,8 +2139,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; @@ -1722,22 +2157,22 @@ 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() { try { - const parsed = JSON.parse(readFileSync2(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; @@ -1748,29 +2183,29 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } write(file) { try { - mkdirSync(dirname2(this.cachePath), { recursive: true }); + mkdirSync3(dirname3(this.cachePath), { recursive: true }); const temp = `${this.cachePath}.${process.pid}.tmp`; - writeFileSync(temp, JSON.stringify(file)); - renameSync(temp, this.cachePath); + writeFileSync3(temp, JSON.stringify(file)); + renameSync3(temp, this.cachePath); } catch { } } }; -function collect(dir, depth, out) { +function collect2(dir, depth, out) { if (depth < 0) return; let entries; try { - entries = readdirSync(dir, { withFileTypes: true }); + entries = readdirSync3(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - if (SKIP_DIRS2.has(entry.name)) continue; - const path = join7(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 = join10(dir, entry.name); + if (entry.isDirectory()) collect2(path, depth - 1, out); + else if (entry.name === SKILL_FILE3) { try { - out.push(`${path}:${statSync2(path).mtimeMs}`); + out.push(`${path}:${statSync5(path).mtimeMs}`); } catch { } } @@ -1813,18 +2248,30 @@ 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 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) => expandHome(dir)).filter(Boolean); - if (dirs.length > 0) { + 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( - 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); @@ -1832,11 +2279,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: expandHome(config.bundleCacheDir) || join8(homedir2(), ".workbuddy-ai", "skillsearch-bundles") + cacheDir: expandHome2(config.bundleCacheDir) || join11(homedir3(), ".workbuddy-ai", "skillsearch-bundles") }); const hub = new HubSkillSource(client); hub.weight = config.hubWeight; @@ -1849,7 +2297,8 @@ function buildEngine(config, onDiagnostic, workspaceDir) { ]) { if (!endpoint) continue; const marketplace = new MarketplaceClient(kind, endpoint, { - cacheDir: expandHome(config.bundleCacheDir) || join8(homedir2(), ".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)), @@ -1866,6 +2315,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 }) } : {}, @@ -1905,8 +2358,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: join11(homedir3(), ".workbuddy-ai"), resolvePlaceholders: config.resolvePlaceholders } ); @@ -1959,8 +2412,8 @@ function resultFor(block) { function log(config, entry) { if (!config.logPath) return; try { - mkdirSync2(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 3285b5e..71ef6f7 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) { @@ -140,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, @@ -173,25 +175,32 @@ 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 { mkdirSync as mkdirSync4 } from "node:fs"; +import { homedir as homedir3 } from "node:os"; +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) { @@ -531,6 +540,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; @@ -549,6 +620,7 @@ var SkillSearchEngine = class { refs; placeholders; runtime; + watch; constructor(parts, options = {}) { this.sources = parts.sources; this.rewriter = parts.rewriter; @@ -556,6 +628,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; @@ -591,7 +664,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); @@ -826,13 +919,115 @@ function errorMessage(error) { } // ../engine-typescript/src/hub-source.ts -import { existsSync as existsSync2 } from "node:fs"; -import { join as join4 } 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 { 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) { + 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 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 = { + _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 join3, 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"; @@ -970,7 +1165,7 @@ async function extractBundle(archive, destination) { } const data = entry.read(); total += data.length; - await mkdir(join3(target, ".."), { recursive: true }); + await mkdir(join5(target, ".."), { recursive: true }); await writeFile(target, data); } try { @@ -996,7 +1191,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 join5(destination, only.name); return destination; } @@ -1130,6 +1325,7 @@ var SkillHubClient = class { timeoutMs; downloadTimeoutMs; cacheDir; + installRoot; source; constructor(endpoint, options = {}) { this.base = endpoint.replace(/\/+$/, ""); @@ -1137,6 +1333,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"; } /** @@ -1152,19 +1349,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 = join4(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. @@ -1309,17 +1544,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 join5 } 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; } @@ -1330,8 +1567,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 = join5(this.cacheDir, key); + const destination = join7(this.cacheDir, key); if (!existsSync3(destination)) { let archive; try { @@ -1347,7 +1585,7 @@ var MarketplaceClient = class { } try { const dir = await bundleRoot(destination); - const skillMd = await readFile(join5(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(() => { @@ -1355,6 +1593,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); @@ -1475,13 +1753,166 @@ function errorMessage2(error) { return error instanceof Error ? error.message : String(error); } +// ../engine-typescript/src/shared.ts +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 join8, resolve as resolve2 } from "node:path"; +var HOME_ENV = "SKILLSEARCH_HOME"; +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 join8(home, path.slice(2)); + return path; +} +function sharedRoot(env = process.env) { + const override = (env[HOME_ENV] ?? "").trim(); + if (override) return expandHome(override); + return join8(homedir2(), ".evermind-skillsearch"); +} +function sharedSkillsDir(env = process.env) { + return join8(sharedRoot(env), "skills"); +} +function registryPath(env = process.env) { + return join8(sharedRoot(env), "registry.json"); +} +function isDirectory2(path) { + try { + return statSync4(path).isDirectory(); + } catch { + return false; + } +} +function readRegistry(path, env = process.env) { + const target = path ?? registryPath(env); + let raw; + try { + raw = JSON.parse(readFileSync3(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: 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("\\"))); + mkdirSync2(parent, { recursive: true }); + staging = mkdtempSync2(join8(parent, ".registry-")); + const scratch = join8(staging, "registry.json"); + writeFileSync2(scratch, `${JSON.stringify(payload, null, 2)} +`, "utf8"); + renameSync2(scratch, path); + return true; + } catch { + return false; + } finally { + if (staging) { + try { + rmSync3(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; + if (ownDirs.length === 0) 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 mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync3, renameSync as renameSync3, statSync as statSync5, writeFileSync as writeFileSync3 } from "node:fs"; +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"; -import { basename, join as join6 } from "node:path"; +import { basename, join as join9 } from "node:path"; // ../engine-typescript/src/bm25.ts var TOKEN_RE = /[a-z0-9]{2,}|[一-鿿]+/g; @@ -1569,9 +2000,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; @@ -1606,7 +2037,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 ?? "" } })); } @@ -1634,7 +2069,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; @@ -1670,9 +2105,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 }); - } else if (entry.name === SKILL_FILE) { - yield join6(dir, entry.name); + if (!SKIP_DIRS2.has(entry.name)) stack.push({ dir: join9(dir, entry.name), depth: depth + 1 }); + } else if (entry.name === SKILL_FILE2) { + yield join9(dir, entry.name); } } } @@ -1695,8 +2130,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; @@ -1713,22 +2148,22 @@ 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() { try { - const parsed = JSON.parse(readFileSync2(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; @@ -1739,29 +2174,29 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } write(file) { try { - mkdirSync(dirname2(this.cachePath), { recursive: true }); + mkdirSync3(dirname3(this.cachePath), { recursive: true }); const temp = `${this.cachePath}.${process.pid}.tmp`; - writeFileSync(temp, JSON.stringify(file)); - renameSync(temp, this.cachePath); + writeFileSync3(temp, JSON.stringify(file)); + renameSync3(temp, this.cachePath); } catch { } } }; -function collect(dir, depth, out) { +function collect2(dir, depth, out) { if (depth < 0) return; let entries; try { - entries = readdirSync(dir, { withFileTypes: true }); + entries = readdirSync3(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - if (SKIP_DIRS2.has(entry.name)) continue; - const path = join7(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 = join10(dir, entry.name); + if (entry.isDirectory()) collect2(path, depth - 1, out); + else if (entry.name === SKILL_FILE3) { try { - out.push(`${path}:${statSync2(path).mtimeMs}`); + out.push(`${path}:${statSync5(path).mtimeMs}`); } catch { } } @@ -1804,18 +2239,30 @@ 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 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) => expandHome(dir)).filter(Boolean); - if (dirs.length > 0) { + 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( - 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); @@ -1823,11 +2270,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: expandHome(config.bundleCacheDir) || join8(homedir2(), ".workbuddy-ai", "skillsearch-bundles") + cacheDir: expandHome2(config.bundleCacheDir) || join11(homedir3(), ".workbuddy-ai", "skillsearch-bundles") }); const hub = new HubSkillSource(client); hub.weight = config.hubWeight; @@ -1840,7 +2288,8 @@ function buildEngine(config, onDiagnostic, workspaceDir) { ]) { if (!endpoint) continue; const marketplace = new MarketplaceClient(kind, endpoint, { - cacheDir: expandHome(config.bundleCacheDir) || join8(homedir2(), ".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)), @@ -1857,6 +2306,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 }) } : {}, @@ -1896,8 +2349,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: join11(homedir3(), ".workbuddy-ai"), resolvePlaceholders: config.resolvePlaceholders } ); @@ -1928,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/config.ts b/skillcorpus_plugin/plugin-workbuddy/src/config.ts index 1a0e08c..b6bdb31 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', } @@ -303,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 { @@ -337,6 +358,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..ec267f9 100644 --- a/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts +++ b/skillcorpus_plugin/plugin-workbuddy/src/retrieve.ts @@ -8,12 +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, 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' @@ -34,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, @@ -42,9 +64,20 @@ 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 installRoot = installRootFor(config.shareSkills) + 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 @@ -56,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 @@ -75,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, @@ -95,6 +130,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)) 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/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..d3b6cdd 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', () => { @@ -158,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/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' diff --git a/skillcorpus_plugin/tests/host-e2e/README.md b/skillcorpus_plugin/tests/host-e2e/README.md index da89baa..49f203c 100644 --- a/skillcorpus_plugin/tests/host-e2e/README.md +++ b/skillcorpus_plugin/tests/host-e2e/README.md @@ -9,8 +9,13 @@ 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, 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 @@ -155,6 +160,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 +244,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..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 @@ -248,3 +261,158 @@ 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. + +## 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. + +### 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 + `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. **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.)* + +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. + +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. + +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. + +### 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. 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. 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 上不必重复 | diff --git a/skillcorpus_plugin/tests/host-e2e/ruff.toml b/skillcorpus_plugin/tests/host-e2e/ruff.toml index d977033..27f0dc5 100644 --- a/skillcorpus_plugin/tests/host-e2e/ruff.toml +++ b/skillcorpus_plugin/tests/host-e2e/ruff.toml @@ -37,3 +37,8 @@ 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"] +# Drives every host CLI and interpreter the operator named. That is the script. +"scripts/e2e_shared_hosts.py" = ["S603", "S607"] # also resolves npx from PATH 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_install.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py new file mode 100644 index 0000000..2bae176 --- /dev/null +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_install.py @@ -0,0 +1,261 @@ +"""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 + + 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: + + 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)) +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. +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 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", + 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()) 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.py b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py new file mode 100644 index 0000000..d100890 --- /dev/null +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared.py @@ -0,0 +1,358 @@ +"""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 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 8 is not here: it is a property of the swap primitive, not of two +hosts, and `engine-python/tests` asserts it directly. + +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?" + +#: 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.""" + 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: 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, + ) + 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 ---------------------- + # + # 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(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", + 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"]: + 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()) 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..375940d --- /dev/null +++ b/skillcorpus_plugin/tests/host-e2e/scripts/e2e_shared_hosts.py @@ -0,0 +1,281 @@ +"""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 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: + + 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 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) + 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"]] + 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, + by_engine, + "skill_search" in names, + 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 --------------------------------------------- + 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()) 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"