From 17ccb7d764dc6eaf4fd7c55d8091a2dd1f89d2ec Mon Sep 17 00:00:00 2001 From: yao pengfei Date: Wed, 26 Aug 2026 07:50:21 +0000 Subject: [PATCH 1/8] fix(plugin): enable EverMind source by default --- skillcorpus_plugin/INSTALL.agent.md | 12 ++++++------ skillcorpus_plugin/README.md | 6 +++--- skillcorpus_plugin/README.zh.md | 6 +++--- skillcorpus_plugin/engine-typescript/README.md | 2 +- .../engine-typescript/README.zh.md | 2 +- .../engine-typescript/src/index.ts | 2 +- skillcorpus_plugin/plugin-hermes/README.md | 2 +- skillcorpus_plugin/plugin-hermes/__init__.py | 2 +- .../plugin-hermes/engine_adapter.py | 3 ++- .../plugin-hermes/tests/test_provider.py | 1 + skillcorpus_plugin/plugin-openclaw/README.md | 2 +- .../plugin-openclaw/src/config.ts | 4 ++-- .../plugin-openclaw/test/model.test.ts | 1 + .../plugin-openclaw/test/register.test.ts | 17 +++++++++++++++-- skillcorpus_plugin/plugin-raven/README.md | 2 +- .../plugin-raven/skillsearch_raven/__init__.py | 1 + .../skillsearch_raven/raven-plugin.toml | 4 +++- .../plugin-raven/tests/test_segment.py | 15 ++++++++++++++- .../plugin-workbuddy/INSTALL.agent.md | 14 ++++++-------- skillcorpus_plugin/plugin-workbuddy/README.md | 4 ++-- .../plugin-workbuddy/src/config.ts | 4 ++-- .../plugin-workbuddy/test/config.test.ts | 12 +++++++++++- 22 files changed, 79 insertions(+), 39 deletions(-) diff --git a/skillcorpus_plugin/INSTALL.agent.md b/skillcorpus_plugin/INSTALL.agent.md index 6022b1b..83a134c 100644 --- a/skillcorpus_plugin/INSTALL.agent.md +++ b/skillcorpus_plugin/INSTALL.agent.md @@ -140,16 +140,16 @@ report this state and move to verification only for the import check: python -c "import skillsearch, skillsearch_raven; print('import ok')" ``` -## Optional capabilities — ask, don't assume +## Network and optional model configuration - **A model for the rewriter and gate**: better selection, two small model calls per retrieving turn. Ask the user which model/route to use; leave empty if they don't care. -- **The remote catalog** (`hub_endpoint` / `hubEndpoint`, e.g. - `https://skillhub.evermind.ai`): gives the agent 96k community skills, but - **sends the retrieval query to that service on every retrieving turn and - downloads third-party skill content to disk**. State this plainly and let - the user opt in; never enable it silently. +- **Remote sources are enabled by default**: EverMind SkillHub + (`https://skillhub.evermind.ai`), ClawHub, and skillhub.cn each receive the + retrieval query and may download candidate skill content to disk. State this + 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. ## Verification — definition of done diff --git a/skillcorpus_plugin/README.md b/skillcorpus_plugin/README.md index b14c5cd..c6dc4a8 100644 --- a/skillcorpus_plugin/README.md +++ b/skillcorpus_plugin/README.md @@ -106,7 +106,7 @@ Full per-host tables live in each plugin's README; these seven decide behaviour | Setting | Default | What it decides | | --- | --- | --- | | `skills_dir` / `skillsDirs` | the host's own skills directory | Where local skills are scanned. Missing directory = the source simply doesn't exist. | -| `hub_endpoint` / `hubEndpoint` | *(empty)* | EverMind-compatible catalog. Empty disables only this source. | +| `hub_endpoint` / `hubEndpoint` | `https://skillhub.evermind.ai` | EverMind SkillHub; empty disables only this source. | | `clawhub_endpoint` / `clawhubEndpoint` | `https://clawhub.ai` | ClawHub search; empty disables it. | | `skillhub_cn_endpoint` / `skillhubCnEndpoint` | `https://api.skillhub.cn` | skillhub.cn search; empty disables it. | | `model` (+ host-specific route) | *(empty)* | Enables the query rewriter and the gate. Empty = retrieval runs unfiltered, ranked by keywords. | @@ -133,8 +133,8 @@ Nothing is added to durable history — the injection is rebuilt per turn and di 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** — ClawHub and skillhub.cn are enabled; the retrieval query is sent to both services. Set their endpoint fields to an empty string to disable either one. With no `model`, no LLM gate runs: only the marketplaces’ own trust flags and the lexical relevance guard apply. -- **With `hub_endpoint` set** — the retrieval query (your message, or its model-cleaned rewrite) is sent to that catalog on every retrieving turn; 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). +- **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). - **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. diff --git a/skillcorpus_plugin/README.zh.md b/skillcorpus_plugin/README.zh.md index 9bb5945..37bdc18 100644 --- a/skillcorpus_plugin/README.zh.md +++ b/skillcorpus_plugin/README.zh.md @@ -82,7 +82,7 @@ EOF | 配置 | 默认 | 决定什么 | | --- | --- | --- | | `skills_dir` / `skillsDirs` | 宿主自己的技能目录 | 本地技能扫哪里。目录不存在 = 这个源就不存在。 | -| `hub_endpoint` / `hubEndpoint` | *(空)* | EverMind 兼容目录;空值只关闭这个来源。 | +| `hub_endpoint` / `hubEndpoint` | `https://skillhub.evermind.ai` | EverMind SkillHub;空值只关闭这个来源。 | | `clawhub_endpoint` / `clawhubEndpoint` | `https://clawhub.ai` | ClawHub 检索;空值关闭。 | | `skillhub_cn_endpoint` / `skillhubCnEndpoint` | `https://api.skillhub.cn` | skillhub.cn 检索;空值关闭。 | | `model`(+ 宿主自己的路由字段) | *(空)* | 启用查询改写器和 gate。空 = 检索裸跑,按关键词排序注入。 | @@ -109,8 +109,8 @@ EOF 如实交代,因为检索跑在你的对话上: - **显式清空三个远程 endpoint 后的纯本地模式**——什么都不出去。扫描、排序、注入全在进程内。 -- **默认安装**——ClawHub 与 skillhub.cn 默认开启,检索查询会发送给这两个服务;将对应 endpoint 设为空字符串可分别关闭。未配置 `model` 时不会运行 LLM gate,只依赖 marketplace 自带的信任标记和关键词相关性过滤。 -- **配了 `hub_endpoint`**——每个检索轮次,检索查询(你的消息,或模型清洗后的改写)会发给那个目录服务;选中技能的正文和 bundle 会从它下载。zip 解包有路径穿越拒绝、扩展名白名单、单文件 8 MiB / 整包 64 MiB 上限,缓存目录在所有被扫描技能目录之外(默认 `~/.workbuddy-ai/skillsearch-bundles`、`~/.skillsearch/hub`、`~/.openclaw/skillsearch-bundles` 或 `~/.dsh/skillsearch-bundles`)。 +- **默认安装**——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`)。 - **Marketplace 正文获取**——每个启用的 marketplace 最多会有两个候选在可选 LLM gate 之前下载并安全解包,因为这两个 API 通过 bundle 提供技能正文。被 gate 拒绝的候选可能仍留在缓存里,但插件不会自动执行它。 - **配了 `model`**——改写器看到你的消息(截断到 2,000 字符);gate 看到你的消息加候选技能的名字、描述和 300 字符正文摘录。两者都发给**你自己配置的**模型,宿主有 provider 通道的走宿主通道。 diff --git a/skillcorpus_plugin/engine-typescript/README.md b/skillcorpus_plugin/engine-typescript/README.md index 1c2b874..f797ace 100644 --- a/skillcorpus_plugin/engine-typescript/README.md +++ b/skillcorpus_plugin/engine-typescript/README.md @@ -35,7 +35,7 @@ Retrieval never throws. A failed source, an unparseable gate reply, or a slow ca | Key | Default | Meaning | | --- | --- | --- | | `skillsDirs` | `['.dsh/skills']` | Directories scanned for `SKILL.md`, up to 5 levels deep. Relative paths resolve against cwd. | -| `hubEndpoint` | `''` | EverMind-compatible catalog; empty disables this source only. | +| `hubEndpoint` | `https://skillhub.evermind.ai` | EverMind SkillHub; empty disables this source only. | | `clawhubEndpoint` | `https://clawhub.ai` | ClawHub API; empty disables it. | | `skillhubCnEndpoint` | `https://api.skillhub.cn` | skillhub.cn API; empty disables it. | | `hubApiKey` | `''` | Bearer token for the catalog. | diff --git a/skillcorpus_plugin/engine-typescript/README.zh.md b/skillcorpus_plugin/engine-typescript/README.zh.md index 1c1f0a8..73bdaae 100644 --- a/skillcorpus_plugin/engine-typescript/README.zh.md +++ b/skillcorpus_plugin/engine-typescript/README.zh.md @@ -35,7 +35,7 @@ Gate 不是优化项。融合按位次排序,因此每个来源的最佳命中 | 键 | 默认值 | 含义 | | --- | --- | --- | | `skillsDirs` | `['.dsh/skills']` | 扫描 `SKILL.md` 的目录,最深 5 层。相对路径相对 cwd 解析。 | -| `hubEndpoint` | `''` | 远程目录服务基址——例如 `https://skillhub.evermind.ai`,或任何实现同一 API 的自建服务。为空则禁用远程来源。 | +| `hubEndpoint` | `https://skillhub.evermind.ai` | EverMind SkillHub,或任何实现同一 API 的自建服务;空值关闭。 | | `hubApiKey` | `''` | 目录服务的 Bearer token。 | | `hubTimeoutMs` | `5000` | 目录服务的单请求超时。 | | `hubMinSafety` | `0.7` | 剔除安全分低于该值的目录条目;仅当目录在搜索结果里携带逐技能安全分时才生效。 | diff --git a/skillcorpus_plugin/engine-typescript/src/index.ts b/skillcorpus_plugin/engine-typescript/src/index.ts index 8844465..e534cd0 100644 --- a/skillcorpus_plugin/engine-typescript/src/index.ts +++ b/skillcorpus_plugin/engine-typescript/src/index.ts @@ -143,7 +143,7 @@ export interface Config { export const Config: z = z.object({ skillsDirs: z.array(z.string()).default(['.dsh/skills']), - hubEndpoint: z.string().default(''), + hubEndpoint: z.string().default('https://skillhub.evermind.ai'), hubApiKey: z.string().default(''), clawhubEndpoint: z.string().default('https://clawhub.ai'), skillhubCnEndpoint: z.string().default('https://api.skillhub.cn'), diff --git a/skillcorpus_plugin/plugin-hermes/README.md b/skillcorpus_plugin/plugin-hermes/README.md index 19f331a..a5d4ccb 100644 --- a/skillcorpus_plugin/plugin-hermes/README.md +++ b/skillcorpus_plugin/plugin-hermes/README.md @@ -28,7 +28,7 @@ be active. | Key | Default | Purpose | |---|---|---| | `skills_dir` | `~/.hermes/skills` | Directory scanned for `SKILL.md` files | -| `hub_endpoint` | - | EverMind-compatible catalog; empty disables this source only | +| `hub_endpoint` | `https://skillhub.evermind.ai` | EverMind SkillHub; empty disables this source only | | `clawhub_endpoint` | `https://clawhub.ai` | ClawHub API; empty disables it | | `skillhub_cn_endpoint` | `https://api.skillhub.cn` | skillhub.cn API; empty disables it | | `hub_api_key` | — | Bearer token for that catalog | diff --git a/skillcorpus_plugin/plugin-hermes/__init__.py b/skillcorpus_plugin/plugin-hermes/__init__.py index a9422cd..636eae9 100644 --- a/skillcorpus_plugin/plugin-hermes/__init__.py +++ b/skillcorpus_plugin/plugin-hermes/__init__.py @@ -41,7 +41,7 @@ class MemoryProvider: # type: ignore[no-redef] DEFAULTS: dict[str, Any] = { "skills_dir": "~/.hermes/skills", - "hub_endpoint": "", + "hub_endpoint": "https://skillhub.evermind.ai", "clawhub_endpoint": "https://clawhub.ai", "skillhub_cn_endpoint": "https://api.skillhub.cn", "model": "", diff --git a/skillcorpus_plugin/plugin-hermes/engine_adapter.py b/skillcorpus_plugin/plugin-hermes/engine_adapter.py index efd592e..f89a88b 100644 --- a/skillcorpus_plugin/plugin-hermes/engine_adapter.py +++ b/skillcorpus_plugin/plugin-hermes/engine_adapter.py @@ -148,9 +148,10 @@ def load_config(hermes_home: str) -> SearchConfig: raw = json.loads(path.read_text(encoding="utf-8")) except Exception as e: log.warning("skillsearch: cannot read %s (%s); using defaults", path, e) - raw = {"clawhub_endpoint": "", "skillhub_cn_endpoint": ""} + raw = {"hub_endpoint": "", "clawhub_endpoint": "", "skillhub_cn_endpoint": ""} raw.setdefault("workspace", hermes_home) raw.setdefault("skills_dir", str(Path(hermes_home) / "skills")) + raw.setdefault("hub_endpoint", "https://skillhub.evermind.ai") raw.setdefault("clawhub_endpoint", "https://clawhub.ai") raw.setdefault("skillhub_cn_endpoint", "https://api.skillhub.cn") # PathGuard placeholders' per-agent facts. Hermes's own config/state root diff --git a/skillcorpus_plugin/plugin-hermes/tests/test_provider.py b/skillcorpus_plugin/plugin-hermes/tests/test_provider.py index 50e4bff..6e48d64 100644 --- a/skillcorpus_plugin/plugin-hermes/tests/test_provider.py +++ b/skillcorpus_plugin/plugin-hermes/tests/test_provider.py @@ -211,6 +211,7 @@ def test_a_missing_config_enables_public_marketplaces_by_default(tmp_path: Path) from engine_adapter import load_config config = load_config(str(tmp_path)) + assert config.hub_endpoint == "https://skillhub.evermind.ai" assert config.clawhub_endpoint == "https://clawhub.ai" assert config.skillhub_cn_endpoint == "https://api.skillhub.cn" diff --git a/skillcorpus_plugin/plugin-openclaw/README.md b/skillcorpus_plugin/plugin-openclaw/README.md index 233402a..d7f8c2d 100644 --- a/skillcorpus_plugin/plugin-openclaw/README.md +++ b/skillcorpus_plugin/plugin-openclaw/README.md @@ -54,7 +54,7 @@ which wins so a credential never has to be copied into a file. | Key | Env | Default | Purpose | |---|---|---|---| | `skillsDirs` | `SKILLSEARCH_SKILLS_DIRS` | `["~/.openclaw/skills"]` | Directories scanned for `SKILL.md` | -| `hubEndpoint` | `SKILLSEARCH_HUB_ENDPOINT` | - | EverMind-compatible catalog; empty disables this source only | +| `hubEndpoint` | `SKILLSEARCH_HUB_ENDPOINT` | `https://skillhub.evermind.ai` | EverMind SkillHub; empty disables this source only | | `clawhubEndpoint` | `SKILLSEARCH_CLAWHUB_ENDPOINT` | `https://clawhub.ai` | ClawHub API; empty disables it | | `skillhubCnEndpoint` | `SKILLSEARCH_SKILLHUB_CN_ENDPOINT` | `https://api.skillhub.cn` | skillhub.cn API; empty disables it | | `hubApiKey` | `SKILLSEARCH_HUB_API_KEY` | — | Bearer token for that catalog | diff --git a/skillcorpus_plugin/plugin-openclaw/src/config.ts b/skillcorpus_plugin/plugin-openclaw/src/config.ts index d56ce8a..32a5014 100644 --- a/skillcorpus_plugin/plugin-openclaw/src/config.ts +++ b/skillcorpus_plugin/plugin-openclaw/src/config.ts @@ -61,7 +61,7 @@ export interface SkillSearchConfig { export const DEFAULTS: SkillSearchConfig = { skillsDirs: ['~/.openclaw/skills'], - hubEndpoint: '', + hubEndpoint: 'https://skillhub.evermind.ai', hubApiKey: '', clawhubEndpoint: 'https://clawhub.ai', skillhubCnEndpoint: 'https://api.skillhub.cn', @@ -170,7 +170,7 @@ export function loadConfig( return { skillsDirs: asList(pick('skillsDirs')) ?? DEFAULTS.skillsDirs, - hubEndpoint: asText(pick('hubEndpoint')) ?? DEFAULTS.hubEndpoint, + hubEndpoint: asEndpoint(pick('hubEndpoint'), DEFAULTS.hubEndpoint), hubApiKey: asText(pick('hubApiKey')) ?? DEFAULTS.hubApiKey, clawhubEndpoint: asEndpoint(pick('clawhubEndpoint'), DEFAULTS.clawhubEndpoint), skillhubCnEndpoint: asEndpoint(pick('skillhubCnEndpoint'), DEFAULTS.skillhubCnEndpoint), diff --git a/skillcorpus_plugin/plugin-openclaw/test/model.test.ts b/skillcorpus_plugin/plugin-openclaw/test/model.test.ts index f93066f..b13f0bb 100644 --- a/skillcorpus_plugin/plugin-openclaw/test/model.test.ts +++ b/skillcorpus_plugin/plugin-openclaw/test/model.test.ts @@ -100,6 +100,7 @@ function fakeApi(pluginConfig: Record): { id: 'skillsearch', name: 'Skill Search', pluginConfig: { + hubEndpoint: '', clawhubEndpoint: '', skillhubCnEndpoint: '', ...pluginConfig, diff --git a/skillcorpus_plugin/plugin-openclaw/test/register.test.ts b/skillcorpus_plugin/plugin-openclaw/test/register.test.ts index 6d8a3c9..ebf6c08 100644 --- a/skillcorpus_plugin/plugin-openclaw/test/register.test.ts +++ b/skillcorpus_plugin/plugin-openclaw/test/register.test.ts @@ -40,7 +40,7 @@ function fakeApi(pluginConfig?: Record): { const api: OpenClawPluginApi = { id: 'skillsearch', name: 'Skill Search', - pluginConfig: { clawhubEndpoint: '', skillhubCnEndpoint: '', ...(pluginConfig ?? {}) }, + pluginConfig: { hubEndpoint: '', clawhubEndpoint: '', skillhubCnEndpoint: '', ...(pluginConfig ?? {}) }, logger: { info: () => {}, warn: (message: string) => { warnings.push(message) }, @@ -216,6 +216,19 @@ test('the environment wins over the host config document', () => { test('an unset configuration resolves to the documented defaults', () => { const config = loadConfig(undefined, {} as NodeJS.ProcessEnv) assert.deepEqual(config, DEFAULTS) + assert.equal(config.hubEndpoint, 'https://skillhub.evermind.ai') + assert.equal(config.clawhubEndpoint, 'https://clawhub.ai') + assert.equal(config.skillhubCnEndpoint, 'https://api.skillhub.cn') +}) + +test('an explicitly empty endpoint disables each default remote source', () => { + const config = loadConfig( + { hubEndpoint: '', clawhubEndpoint: '', skillhubCnEndpoint: '' }, + {} as NodeJS.ProcessEnv, + ) + assert.equal(config.hubEndpoint, '') + assert.equal(config.clawhubEndpoint, '') + assert.equal(config.skillhubCnEndpoint, '') }) test('a comma-separated list is accepted wherever an array is', () => { @@ -242,5 +255,5 @@ test('the last user message is found past assistant and tool turns', () => { }) test('an engine with no sources reports itself disabled', async () => { - assert.equal(buildEngine(loadConfig({ skillsDirs: [], clawhubEndpoint: '', skillhubCnEndpoint: '' }, {} as NodeJS.ProcessEnv)).enabled, false) + assert.equal(buildEngine(loadConfig({ skillsDirs: [], hubEndpoint: '', clawhubEndpoint: '', skillhubCnEndpoint: '' }, {} as NodeJS.ProcessEnv)).enabled, false) }) diff --git a/skillcorpus_plugin/plugin-raven/README.md b/skillcorpus_plugin/plugin-raven/README.md index 41fd7c2..c4830e0 100644 --- a/skillcorpus_plugin/plugin-raven/README.md +++ b/skillcorpus_plugin/plugin-raven/README.md @@ -42,7 +42,7 @@ which the host validates against; the ones that matter most: | Key | Default | Purpose | |---|---|---| | `skills_dir` | `skills` | Directory scanned for `SKILL.md`, relative to the workspace | -| `hub_endpoint` | - | EverMind-compatible catalog; empty disables this source only | +| `hub_endpoint` | `https://skillhub.evermind.ai` | EverMind SkillHub; empty disables this source only | | `clawhub_endpoint` | `https://clawhub.ai` | ClawHub API; empty disables it | | `skillhub_cn_endpoint` | `https://api.skillhub.cn` | skillhub.cn API; empty disables it | | `model` | — | Model for the rewriter and the gate | diff --git a/skillcorpus_plugin/plugin-raven/skillsearch_raven/__init__.py b/skillcorpus_plugin/plugin-raven/skillsearch_raven/__init__.py index 0944b03..2f07da8 100644 --- a/skillcorpus_plugin/plugin-raven/skillsearch_raven/__init__.py +++ b/skillcorpus_plugin/plugin-raven/skillsearch_raven/__init__.py @@ -117,6 +117,7 @@ def make_segment(ctx: Any) -> Any | None: workspace = str(getattr(services, "workspace", ".") or ".") cfg_map.setdefault("workspace", workspace) cfg_map.setdefault("agent_id", getattr(services, "agent_id", "") or "") + 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") # PathGuard placeholders' per-agent facts. Raven has no persistent diff --git a/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml b/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml index 6043fdd..2b37377 100644 --- a/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml +++ b/skillcorpus_plugin/plugin-raven/skillsearch_raven/raven-plugin.toml @@ -20,9 +20,11 @@ replaces = "skills" skills_dir = { type = "string", default = "skills" } builtin_dir = { type = "string", default = "" } scan_depth = { type = "integer", default = 5 } -hub_endpoint = { type = "string", default = "" } +hub_endpoint = { type = "string", default = "https://skillhub.evermind.ai" } hub_api_key = { type = "string", default = "" } hub_timeout_s = { type = "number", default = 2.0 } +clawhub_endpoint = { type = "string", default = "https://clawhub.ai" } +skillhub_cn_endpoint = { type = "string", default = "https://api.skillhub.cn" } hub_min_safety = { type = "number", default = 0.7 } agent_id = { type = "string", default = "" } diff --git a/skillcorpus_plugin/plugin-raven/tests/test_segment.py b/skillcorpus_plugin/plugin-raven/tests/test_segment.py index f8be574..ee0fb42 100644 --- a/skillcorpus_plugin/plugin-raven/tests/test_segment.py +++ b/skillcorpus_plugin/plugin-raven/tests/test_segment.py @@ -99,6 +99,13 @@ def test_the_manifest_id_matches_the_entry_point() -> None: assert points.get(MANIFEST["plugin"]["id"]) == "skillsearch_raven" +def test_the_manifest_enables_all_remote_sources_by_default() -> None: + schema = MANIFEST["plugin"]["config_schema"] + assert schema["hub_endpoint"]["default"] == "https://skillhub.evermind.ai" + assert schema["clawhub_endpoint"]["default"] == "https://clawhub.ai" + assert schema["skillhub_cn_endpoint"]["default"] == "https://api.skillhub.cn" + + # ── behaviour ──────────────────────────────────────────────────────────── # `build()` wraps its result in the host's `Segment` type, so these need a @@ -132,7 +139,13 @@ def test_the_factory_declines_its_slot_when_nothing_is_configured(tmp_path: Path assert ( make_segment( PluginContext( - tmp_path, {"skills_dir": str(tmp_path / "absent"), "clawhub_endpoint": "", "skillhub_cn_endpoint": ""} + tmp_path, + { + "skills_dir": str(tmp_path / "absent"), + "hub_endpoint": "", + "clawhub_endpoint": "", + "skillhub_cn_endpoint": "", + } ) ) is None diff --git a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md index e8664c0..e9b0fd9 100644 --- a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md +++ b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md @@ -94,7 +94,7 @@ call it. Read these values, never invent them. entries. 6. Tell the user to quit and reopen WorkBuddy. -## Optional capabilities — ask, don't assume +## Network and optional model configuration Configuration lives in `~/.workbuddy-ai/plugins/data/skillsearch-/config.json` (the @@ -108,13 +108,11 @@ from their current path. calls per retrieving turn — spent inside the silence between the user pressing enter and the reply starting, which this host does not indicate. Ask which route to use; leave empty if they don't care. -- **The remote catalog** (`hubEndpoint`, e.g. - `https://skillhub.evermind.ai`): community skills, but **sends the - retrieval query to that service on every retrieving turn and downloads - third-party skill content to disk**, and unvetted catalog skills may - reference infrastructure this machine lacks. State this plainly and let - the user opt in; never enable it silently. If enabled, raise `timeoutMs` - to 4000 and recommend the gate. +- **Remote sources are enabled by default**: EverMind SkillHub + (`https://skillhub.evermind.ai`), ClawHub, and skillhub.cn each receive the + retrieval query and may download candidate skill content to disk. State this + 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. ## Verification — definition of done diff --git a/skillcorpus_plugin/plugin-workbuddy/README.md b/skillcorpus_plugin/plugin-workbuddy/README.md index b5983df..ad7f97d 100644 --- a/skillcorpus_plugin/plugin-workbuddy/README.md +++ b/skillcorpus_plugin/plugin-workbuddy/README.md @@ -113,8 +113,8 @@ within one, and the fused list degenerates into whole-source blocks), and `localWeight 1.0 / hubWeight 0.85` seats the local directory first — tried the other way round on 2026-08-18, and the catalog's top two for a poster task both depended on infrastructure this machine lacked while the local -skill that runs here sat unread in seat three. ClawHub (`clawhubEndpoint`) and -skillhub.cn (`skillhubCnEndpoint`) are enabled by default at their public API +skill that runs here sat unread in seat three. EverMind SkillHub (`hubEndpoint`, `https://skillhub.evermind.ai`), ClawHub +(`clawhubEndpoint`), and skillhub.cn (`skillhubCnEndpoint`) are enabled by default at their public API URLs; set either endpoint to an empty string to disable that source. ## Install — paste this to WorkBuddy diff --git a/skillcorpus_plugin/plugin-workbuddy/src/config.ts b/skillcorpus_plugin/plugin-workbuddy/src/config.ts index eee9eb2..d4cf5fd 100644 --- a/skillcorpus_plugin/plugin-workbuddy/src/config.ts +++ b/skillcorpus_plugin/plugin-workbuddy/src/config.ts @@ -122,7 +122,7 @@ export const DEFAULTS: SkillSearchConfig = { // Both roots WorkBuddy actually keeps skills in: what the user installed, // and what plugins brought with them. skillsDirs: ['~/.workbuddy-ai/skills', '~/.workbuddy-ai/plugins/cache'], - hubEndpoint: '', + hubEndpoint: 'https://skillhub.evermind.ai', hubApiKey: '', clawhubEndpoint: 'https://clawhub.ai', skillhubCnEndpoint: 'https://api.skillhub.cn', @@ -261,7 +261,7 @@ export function loadConfig( return { skillsDirs: asList(pick('skillsDirs')) ?? DEFAULTS.skillsDirs, - hubEndpoint: asText(pick('hubEndpoint')) ?? DEFAULTS.hubEndpoint, + hubEndpoint: asEndpoint(pick('hubEndpoint'), DEFAULTS.hubEndpoint), hubApiKey: asText(pick('hubApiKey')) ?? DEFAULTS.hubApiKey, clawhubEndpoint: asEndpoint(pick('clawhubEndpoint'), DEFAULTS.clawhubEndpoint), skillhubCnEndpoint: asEndpoint(pick('skillhubCnEndpoint'), DEFAULTS.skillhubCnEndpoint), diff --git a/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts b/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts index 0f12588..1777487 100644 --- a/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts +++ b/skillcorpus_plugin/plugin-workbuddy/test/config.test.ts @@ -16,10 +16,20 @@ test('the defaults search the two directories WorkBuddy keeps skills in', () => '~/.workbuddy-ai/skills', '~/.workbuddy-ai/plugins/cache', ]) - assert.equal(config.hubEndpoint, '') + assert.equal(config.hubEndpoint, 'https://skillhub.evermind.ai') assert.equal(config.gate, undefined) }) +test('an explicitly empty endpoint disables each default remote source', () => { + const config = loadConfig( + { hubEndpoint: '', clawhubEndpoint: '', skillhubCnEndpoint: '' }, + {}, + ) + assert.equal(config.hubEndpoint, '') + assert.equal(config.clawhubEndpoint, '') + assert.equal(config.skillhubCnEndpoint, '') +}) + test('the default deadline allows the measured public hubs but stays below the host timeout', () => { assert.equal(DEFAULTS.timeoutMs, MAX_TIMEOUT_MS) assert.equal(DEFAULTS.rewrite, false) From 8d10175caff610a39a0a3924ad83f93c753082bd Mon Sep 17 00:00:00 2001 From: yao pengfei Date: Wed, 26 Aug 2026 08:34:22 +0000 Subject: [PATCH 2/8] fix(plugin): complete host discovery manifests --- .codebuddy-plugin/marketplace.json | 17 ++++++++++++++++ .github/workflows/plugin-ci.yml | 2 ++ .../plugin-openclaw/openclaw.plugin.json | 2 +- .../plugin-openclaw/test/register.test.ts | 10 ++++++++++ .../scripts/verify_release_versions.py | 20 +++++++++++++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .codebuddy-plugin/marketplace.json diff --git a/.codebuddy-plugin/marketplace.json b/.codebuddy-plugin/marketplace.json new file mode 100644 index 0000000..7b17111 --- /dev/null +++ b/.codebuddy-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "skillcorpus", + "description": "SkillCorpus plugins marketplace.", + "owner": { + "name": "EverMind AI", + "url": "https://evermind.ai" + }, + "plugins": [ + { + "name": "skillsearch", + "description": "Per-turn skill retrieval for WorkBuddy.", + "source": "./skillcorpus_plugin/plugin-workbuddy", + "version": "0.2.0", + "category": "skill" + } + ] +} diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index f526ab1..40d78a8 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -4,11 +4,13 @@ on: pull_request: paths: - "skillcorpus_plugin/**" + - ".codebuddy-plugin/**" - ".github/workflows/plugin-ci.yml" push: branches: [main] paths: - "skillcorpus_plugin/**" + - ".codebuddy-plugin/**" - ".github/workflows/plugin-ci.yml" permissions: diff --git a/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json b/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json index f16ac8b..a088974 100644 --- a/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json +++ b/skillcorpus_plugin/plugin-openclaw/openclaw.plugin.json @@ -22,7 +22,7 @@ }, "hubEndpoint": { "type": "string", - "default": "", + "default": "https://skillhub.evermind.ai", "description": "Remote catalog base URL; empty disables the remote source" }, "hubApiKey": { diff --git a/skillcorpus_plugin/plugin-openclaw/test/register.test.ts b/skillcorpus_plugin/plugin-openclaw/test/register.test.ts index ebf6c08..5541889 100644 --- a/skillcorpus_plugin/plugin-openclaw/test/register.test.ts +++ b/skillcorpus_plugin/plugin-openclaw/test/register.test.ts @@ -221,6 +221,16 @@ test('an unset configuration resolves to the documented defaults', () => { assert.equal(config.skillhubCnEndpoint, 'https://api.skillhub.cn') }) +test('the host manifest and runtime agree on every remote-source default', async () => { + const manifest = JSON.parse( + await readFile(new URL('../openclaw.plugin.json', import.meta.url), 'utf8'), + ) + const properties = manifest.configSchema.properties + assert.equal(properties.hubEndpoint.default, DEFAULTS.hubEndpoint) + assert.equal(properties.clawhubEndpoint.default, DEFAULTS.clawhubEndpoint) + assert.equal(properties.skillhubCnEndpoint.default, DEFAULTS.skillhubCnEndpoint) +}) + test('an explicitly empty endpoint disables each default remote source', () => { const config = loadConfig( { hubEndpoint: '', clawhubEndpoint: '', skillhubCnEndpoint: '' }, diff --git a/skillcorpus_plugin/scripts/verify_release_versions.py b/skillcorpus_plugin/scripts/verify_release_versions.py index 638d949..74f169c 100644 --- a/skillcorpus_plugin/scripts/verify_release_versions.py +++ b/skillcorpus_plugin/scripts/verify_release_versions.py @@ -7,7 +7,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = ROOT.parent EXPECTED = "0.2.0" +MARKETPLACE = json.loads( + (REPO_ROOT / ".codebuddy-plugin/marketplace.json").read_text(encoding="utf-8") +) def text_version(path: str, pattern: str) -> str: @@ -24,6 +28,22 @@ def text_version(path: str, pattern: str) -> str: "openclaw": json.loads((ROOT / "plugin-openclaw/package.json").read_text())["version"], "workbuddy": json.loads((ROOT / "plugin-workbuddy/package.json").read_text())["version"], } +if MARKETPLACE.get("name") != "skillcorpus": + raise SystemExit("WorkBuddy marketplace name must be skillcorpus") +entries = [entry for entry in MARKETPLACE.get("plugins", []) if entry.get("name") == "skillsearch"] +if len(entries) != 1: + raise SystemExit("WorkBuddy marketplace must contain exactly one skillsearch plugin") +entry = entries[0] +source = (REPO_ROOT / entry["source"]).resolve() +expected_source = (ROOT / "plugin-workbuddy").resolve() +if source != expected_source: + raise SystemExit(f"WorkBuddy marketplace source must resolve to {expected_source}, got {source}") +plugin_manifest = json.loads((source / ".codebuddy-plugin/plugin.json").read_text(encoding="utf-8")) +if plugin_manifest.get("name") != entry["name"]: + raise SystemExit("WorkBuddy marketplace and plugin manifest names disagree") +versions["workbuddy-marketplace"] = entry.get("version") +versions["workbuddy-manifest"] = plugin_manifest.get("version") + wrong = {name: version for name, version in versions.items() if version != EXPECTED} if wrong: raise SystemExit(f"release versions must all be {EXPECTED}: {wrong}") From 4f8cf30be57ee632859efe6e60e6834db6fdc026 Mon Sep 17 00:00:00 2001 From: yao pengfei Date: Wed, 26 Aug 2026 09:05:32 +0000 Subject: [PATCH 3/8] docs(plugin): align installation and source defaults --- skillcorpus_plugin/INSTALL.agent.md | 5 ++-- skillcorpus_plugin/README.md | 4 +-- skillcorpus_plugin/README.zh.md | 4 +-- skillcorpus_plugin/engine-python/README.md | 9 +++---- .../engine-typescript/README.md | 2 +- .../engine-typescript/README.zh.md | 2 +- skillcorpus_plugin/plugin-openclaw/README.md | 2 +- .../plugin-workbuddy/INSTALL.agent.md | 11 ++++---- skillcorpus_plugin/plugin-workbuddy/README.md | 26 ++++--------------- 9 files changed, 24 insertions(+), 41 deletions(-) diff --git a/skillcorpus_plugin/INSTALL.agent.md b/skillcorpus_plugin/INSTALL.agent.md index 83a134c..1f0978d 100644 --- a/skillcorpus_plugin/INSTALL.agent.md +++ b/skillcorpus_plugin/INSTALL.agent.md @@ -32,8 +32,9 @@ several pass, ask the user. | DeepSeek Harness | the workspace you are in has a `cordis.yml` and a `packages/` tree | | Raven | `~/.raven/` exists, or `raven` CLI is present | -Also note where this repository is checked out (clone it if the user gave -you only the URL): every path below is relative to the repository root. +Note where this repository is checked out (clone it if the user gave you only +the URL), then change into its `skillcorpus_plugin/` directory. Every relative +path and command below starts there. ## WorkBuddy diff --git a/skillcorpus_plugin/README.md b/skillcorpus_plugin/README.md index c6dc4a8..845be33 100644 --- a/skillcorpus_plugin/README.md +++ b/skillcorpus_plugin/README.md @@ -2,7 +2,7 @@ English | [简体中文](README.zh.md) -**The official agent-host plugins for [SkillCorpus](https://github.com/EverMind-AI/SkillCorpus): your agent, automatically briefed with the right skills — every turn.** SkillCorpus Plugins watches what the user just asked, retrieves the matching `SKILL.md` skills from a local directory and an optional remote catalog, and puts their bodies in front of the model before it answers. No tool call, no skill name the model has to already know. +**The official agent-host plugins for [SkillCorpus](https://github.com/EverMind-AI/SkillCorpus): your agent, automatically briefed with the right skills — every turn.** SkillCorpus Plugins watches what the user just asked, retrieves matching `SKILL.md` skills from local directories and three remote sources enabled by default, and puts their bodies in front of the model before it answers. No tool call, no skill name the model has to already know. A real turn, on WorkBuddy: ask *“帮我生成一个二维码,内容是 https://evermind.ai,存到桌面”*. No QR skill exists on the machine — but the catalog has one, so before the model answers, its context gains: @@ -19,7 +19,7 @@ this directory — use the absolute form for read_file / exec. The skill's bundled script is already extracted next to it; the model runs it and the QR code lands on the desktop. Without retrieval, the model improvises — `pip install qrcode` and hope. -Works with a directory of your own skills, with [SkillHub](https://evermind.ai/skillhub) — the hosted endpoint over [SkillCorpus](https://github.com/EverMind-AI/SkillCorpus)'s 114,190 vetted, permissively-licensed skills, where that QR skill came from — or both fused into one ranking. +Works with your own skill directories and with EverMind SkillHub, ClawHub, and skillhub.cn, fused into one ranking. The QR skill above came from [SkillHub](https://evermind.ai/skillhub), the hosted endpoint over [SkillCorpus](https://github.com/EverMind-AI/SkillCorpus). ## Install — paste this to your agent diff --git a/skillcorpus_plugin/README.zh.md b/skillcorpus_plugin/README.zh.md index 37bdc18..b9d2259 100644 --- a/skillcorpus_plugin/README.zh.md +++ b/skillcorpus_plugin/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 简体中文 -**[SkillCorpus](https://github.com/EverMind-AI/SkillCorpus) 的官方宿主插件集:让你的 agent 每一轮都自动带上对的技能。** SkillCorpus Plugins 盯着用户刚说的话,从本地目录和可选的远程技能库里检索匹配的 `SKILL.md` 技能,在模型作答之前把技能正文放到它面前——不需要工具调用,模型也不需要事先知道任何技能的名字。 +**[SkillCorpus](https://github.com/EverMind-AI/SkillCorpus) 的官方宿主插件集:让你的 agent 每一轮都自动带上对的技能。** SkillCorpus Plugins 盯着用户刚说的话,从本地目录以及默认开启的三个远程来源中检索匹配的 `SKILL.md` 技能,在模型作答之前把技能正文放到它面前——不需要工具调用,模型也不需要事先知道任何技能的名字。 一个真实轮次,发生在 WorkBuddy 上:问 *"帮我生成一个二维码,内容是 https://evermind.ai,存到桌面"*。这台机器上没有任何二维码技能——但语料库里有,于是模型作答前,它的上下文多出: @@ -19,7 +19,7 @@ this directory — use the absolute form for read_file / exec. 技能自带的脚本已经解包在旁边;模型直接运行它,二维码落到桌面。没有检索的话,模型只能即兴发挥——`pip install qrcode`,然后碰运气。 -技能来源可以是你自己的技能目录、[SkillHub](https://evermind.ai/skillhub)([SkillCorpus](https://github.com/EverMind-AI/SkillCorpus) 的托管端点,114,190 条经审核、许可宽松的社区技能——上面那条二维码技能就来自这里),或两者融合进同一个排序。 +技能来源可以是你自己的技能目录,也可以是默认开启的 EverMind SkillHub、ClawHub 和 skillhub.cn,所有来源融合进同一个排序。上面的二维码技能来自 [SkillHub](https://evermind.ai/skillhub),即 [SkillCorpus](https://github.com/EverMind-AI/SkillCorpus) 的托管端点。 ## 安装——把这段话粘给你的 agent diff --git a/skillcorpus_plugin/engine-python/README.md b/skillcorpus_plugin/engine-python/README.md index 730e53e..465bea4 100644 --- a/skillcorpus_plugin/engine-python/README.md +++ b/skillcorpus_plugin/engine-python/README.md @@ -6,8 +6,8 @@ An agent host wants to answer one question before every turn: *given what the user just said, which skills should the model see?* This package answers it — searching a local skills directory, a remote catalog such as [SkillHub](https://evermind.ai/skillhub) (the hosted endpoint over -[SkillCorpus](https://github.com/EverMind-AI/SkillCorpus)'s 96,401 vetted -skills), and the agent's own accumulated skills, fusing the results, +[SkillCorpus](https://github.com/EverMind-AI/SkillCorpus)), and the agent's +own accumulated skills, fusing the results, narrowing them with a model, and returning the text to inject. ```python @@ -87,9 +87,8 @@ without filtering it first. `GET /openapi/v1/skills?q=`, `/skills/{id}`, `/skills/{id}/download`, each answering `{error, requestId, status, result}` with `status == 0` for success. [SkillHub](https://evermind.ai/skillhub) is a public one, serving -the [SkillCorpus](https://github.com/EverMind-AI/SkillCorpus) corpus — -96,401 skills, each carrying its upstream license, retrieval quality -measured in the [corpus paper](https://arxiv.org/abs/2607.15557) — or run +the [SkillCorpus](https://github.com/EverMind-AI/SkillCorpus) corpus. Each +skill carries its upstream license, with retrieval quality measured in the [corpus paper](https://arxiv.org/abs/2607.15557) — or run your own. Leave it unset and everything else works against a local directory. diff --git a/skillcorpus_plugin/engine-typescript/README.md b/skillcorpus_plugin/engine-typescript/README.md index f797ace..f3ebabe 100644 --- a/skillcorpus_plugin/engine-typescript/README.md +++ b/skillcorpus_plugin/engine-typescript/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Per-turn skill retrieval. Every turn, this searches local directories and an optional remote catalog — such as [SkillHub](https://evermind.ai/skillhub), the hosted endpoint over [SkillCorpus](https://github.com/EverMind-AI/SkillCorpus)'s 96,401 vetted skills — against what the user just wrote, and puts the matching skill bodies in front of the model before it is called. +Per-turn skill retrieval. Every turn, this searches local directories and the configured remote sources — EverMind SkillHub, ClawHub, and skillhub.cn are enabled by default — against what the user just wrote, and puts the matching skill bodies in front of the model before it is called. `dsh-tool-skill` solves the same problem the other way: it publishes a catalog of every skill and lets the model load one by name. The two are alternatives — running both publishes the same skills twice, once as a tool schema and once as injected text. A deployment mounting this plugin disables `dsh-tool-skill` (and any other plugin that publishes the same skill catalog). diff --git a/skillcorpus_plugin/engine-typescript/README.zh.md b/skillcorpus_plugin/engine-typescript/README.zh.md index 73bdaae..6e3ab99 100644 --- a/skillcorpus_plugin/engine-typescript/README.zh.md +++ b/skillcorpus_plugin/engine-typescript/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -逐轮技能检索。每一轮,本插件用用户刚写下的内容去检索本地目录和可选的远程目录服务——例如 [SkillHub](https://evermind.ai/skillhub),即 [SkillCorpus](https://github.com/EverMind-AI/SkillCorpus)(96,401 条经审核、许可宽松的技能)的托管端点——并在模型被调用之前把匹配到的技能正文放到它面前。 +逐轮技能检索。每一轮,本插件用用户刚写下的内容检索本地目录和已配置的远程来源;EverMind SkillHub、ClawHub 与 skillhub.cn 默认开启。匹配到的技能正文会在模型调用之前进入上下文。 `dsh-tool-skill` 用相反的方式解决同一问题:它发布一份包含全部技能的目录,让模型按名称加载。二者是替代关系——同时运行会把同一批技能发布两次,一次作为工具 schema,一次作为注入文本。挂载本插件的部署应禁用 `dsh-tool-skill`(以及任何发布同一技能目录的其他插件)。 diff --git a/skillcorpus_plugin/plugin-openclaw/README.md b/skillcorpus_plugin/plugin-openclaw/README.md index d7f8c2d..346c725 100644 --- a/skillcorpus_plugin/plugin-openclaw/README.md +++ b/skillcorpus_plugin/plugin-openclaw/README.md @@ -1,7 +1,7 @@ # skillsearch for OpenClaw Skill retrieval on `before_prompt_build`. Every turn, this searches a local -skills directory and an optional remote catalog against what the user just +skills directory and the configured remote sources against what the user just wrote, narrows the result with a model, and returns the matching skill bodies for the host to prepend. diff --git a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md index e9b0fd9..2e7b561 100644 --- a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md +++ b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md @@ -35,7 +35,8 @@ The repository-root `INSTALL.agent.md` routes WorkBuddy installs here. | WorkBuddy | `~/.workbuddy-ai/settings.json` exists, or `WorkBuddy AI.app` is installed, or your own hook payloads carry `"client": "WorkBuddy"` | Note where this repository is checked out (clone it if the user gave you only -the URL): every path below is relative to the repository root. +the URL), then change into its `skillcorpus_plugin/` directory. Every relative +path and command below starts there. ## WorkBuddy @@ -66,11 +67,9 @@ report. Then, with the user's go-ahead, perform the file-level install. `` is `version` from `plugin-workbuddy/.codebuddy-plugin/plugin.json`. -`` is the marketplace name: when the source is a packaged -marketplace (a git repo whose root carries -`.codebuddy-plugin/marketplace.json`), read its `name` field; when -installing straight from this repository checkout, ask the user what to -call it. Read these values, never invent them. +`` is the marketplace name from the repository root +`../.codebuddy-plugin/marketplace.json`; for this repository it is +`skillcorpus`. Read these values, never invent them. 1. **Back up** `~/.workbuddy-ai/settings.json`, `~/.workbuddy-ai/plugins/installed_plugins.json` and diff --git a/skillcorpus_plugin/plugin-workbuddy/README.md b/skillcorpus_plugin/plugin-workbuddy/README.md index ad7f97d..11683f5 100644 --- a/skillcorpus_plugin/plugin-workbuddy/README.md +++ b/skillcorpus_plugin/plugin-workbuddy/README.md @@ -72,23 +72,6 @@ cache and read the selected two lazily (−20ms), give the fingerprint a few-second TTL so most turns skip the walk (−34ms), and cache the tokenised corpus alongside the scan (−15ms). -## Known limitation: CJK ranking - -The engine tokenises CJK per ideograph. On this host — a Chinese product with -Chinese skill descriptions — that ranks long documents with common characters -above short relevant ones. Measured over the same 46 skills: - -| query | unigram (engine) | bigram | -| --- | --- | --- | -| 做个 PPT 讲下季度进展 | stock-research-report-expert | **ardot-slides** | -| 把这个设计稿转成前端代码 | ardot-design-to-code | ardot-design-to-code | - -`季度` matching any document containing 季 or 度 separately is the mechanism. -Bigrams fix it, and the fix belongs in both engines and their parity tests -rather than in this adapter — an adapter that quietly tokenises differently -from `engine-python` would break the one property the two implementations -promise. - ## Configuration No host document reaches a hook, so configuration is a file the plugin owns, @@ -113,9 +96,10 @@ within one, and the fused list degenerates into whole-source blocks), and `localWeight 1.0 / hubWeight 0.85` seats the local directory first — tried the other way round on 2026-08-18, and the catalog's top two for a poster task both depended on infrastructure this machine lacked while the local -skill that runs here sat unread in seat three. EverMind SkillHub (`hubEndpoint`, `https://skillhub.evermind.ai`), ClawHub -(`clawhubEndpoint`), and skillhub.cn (`skillhubCnEndpoint`) are enabled by default at their public API -URLs; set either endpoint to an empty string to disable that source. +skill that runs here sat unread in seat three. EverMind SkillHub +(`hubEndpoint`, `https://skillhub.evermind.ai`), ClawHub (`clawhubEndpoint`), +and skillhub.cn (`skillhubCnEndpoint`) are enabled by default at their public +API URLs; set any endpoint to an empty string to disable that source. ## Install — paste this to WorkBuddy @@ -127,7 +111,7 @@ does well. Paste this into a WorkBuddy session (fill in the plugin source): > > 插件源(git 地址或本地打包目录):`<源地址>` > -> 严格按照源里 `plugin-workbuddy/INSTALL.agent.md` 的步骤执行:每做完一步 +> 严格按照源里 `skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md` 的步骤执行:每做完一步 > 简短汇报结果;任何一步失败就停下来告诉我,不要跳过,也不要自己想办法 > 绕过;改任何配置文件之前,先做带时间戳的备份,并把要做的改动展示给我; > 市场名和版本号从文件里读,不要自己编。装完后按剧本的自检清单逐项验证, From 8edf8055aaad70c9ca7e2edd6279da356eeae0b9 Mon Sep 17 00:00:00 2001 From: yao pengfei Date: Wed, 26 Aug 2026 09:26:17 +0000 Subject: [PATCH 4/8] docs(plugin): record v0.1.0 release history --- skillcorpus_plugin/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/skillcorpus_plugin/CHANGELOG.md b/skillcorpus_plugin/CHANGELOG.md index 9046d0c..418e995 100644 --- a/skillcorpus_plugin/CHANGELOG.md +++ b/skillcorpus_plugin/CHANGELOG.md @@ -293,3 +293,14 @@ the corpus paper's citation; per-implementation READMEs document the new configuration keys, and the cross-implementation equality claim now points at the parity suite that enforces it. + +## 0.1.0 — 2026-08-25 + +Initial public release of SkillCorpus Plugins. + +- Added the shared Python retrieval engine. +- Added initial Hermes and Raven adapters. +- Added TypeScript retrieval support for DeepSeek Harness. +- Added OpenClaw and WorkBuddy plugin integrations. +- Added local and SkillHub retrieval, ranking, model gating, and safe bundle + extraction. From f84f951c99e35cdc4c5be821bf154920226ec672 Mon Sep 17 00:00:00 2001 From: yao pengfei Date: Wed, 26 Aug 2026 09:36:07 +0000 Subject: [PATCH 5/8] fix(workbuddy): ship marketplace runtime bundle --- .github/workflows/plugin-ci.yml | 4 + .gitignore | 2 + skillcorpus_plugin/.gitignore | 2 + skillcorpus_plugin/CHANGELOG.md | 12 +- .../plugin-workbuddy/dist/hook.mjs | 1946 +++++++++++++++++ .../scripts/verify_release_versions.py | 3 + 6 files changed, 1968 insertions(+), 1 deletion(-) create mode 100755 skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 40d78a8..58b2c11 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -88,6 +88,10 @@ jobs: working-directory: skillcorpus_plugin/${{ matrix.directory }} - run: npm run ci working-directory: skillcorpus_plugin/${{ matrix.directory }} + - name: Verify checked-in WorkBuddy runtime is current + if: matrix.directory == 'plugin-workbuddy' + run: git diff --exit-code -- dist/hook.mjs + working-directory: skillcorpus_plugin/plugin-workbuddy - run: node ../scripts/verify_npm_package.mjs . working-directory: skillcorpus_plugin/${{ matrix.directory }} diff --git a/.gitignore b/.gitignore index 9437547..779bddd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ __pycache__/ *.egg-info/ build/ dist/ +!skillcorpus_plugin/plugin-workbuddy/dist/ +!skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs # top-level inputs/artifacts (private / generated) configs/sources.full.yaml diff --git a/skillcorpus_plugin/.gitignore b/skillcorpus_plugin/.gitignore index 681e0a5..841ee1f 100644 --- a/skillcorpus_plugin/.gitignore +++ b/skillcorpus_plugin/.gitignore @@ -10,3 +10,5 @@ node_modules/ lib/ *.tsbuildinfo dist/ +!plugin-workbuddy/dist/ +!plugin-workbuddy/dist/hook.mjs diff --git a/skillcorpus_plugin/CHANGELOG.md b/skillcorpus_plugin/CHANGELOG.md index 418e995..916f632 100644 --- a/skillcorpus_plugin/CHANGELOG.md +++ b/skillcorpus_plugin/CHANGELOG.md @@ -4,7 +4,17 @@ ### Added -- **Default multi-source retrieval** now searches local skills, EverMind when configured, ClawHub, and skillhub.cn concurrently. Each source contributes at most two candidates; suspicious or malicious entries are rejected, bundles are safely cached, source failures are isolated, and the final gate still selects 0–2 skills. ClawHub and skillhub.cn can each be disabled with an empty endpoint. +- **A root WorkBuddy marketplace manifest** lets WorkBuddy discover and install + Skill Search through its standard marketplace flow. The checked-in + `dist/hook.mjs` is part of that marketplace source so GitHub git/ZIP installs + contain the command declared by `hooks/hooks.json`. CI rebuilds the bundle + and fails if the checked-in runtime is stale. + +- **Default multi-source retrieval** now searches local skills, EverMind, ClawHub, + and skillhub.cn concurrently. Each source contributes at most two candidates; + suspicious or malicious entries are rejected, bundles are safely cached, + source failures are isolated, and the final gate still selects 0–2 skills. + EverMind, ClawHub, and skillhub.cn can each be disabled with an empty endpoint. - **PathGuard placeholder resolution** — both engines resolve `{{SKILL_DIR}}`, `{{SKILL_DIR:}}`, `{{AGENT_STATE_DIR}}`, diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs new file mode 100755 index 0000000..8049a26 --- /dev/null +++ b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs @@ -0,0 +1,1946 @@ +#!/usr/bin/env node + +// src/hook.ts +import { appendFileSync, mkdirSync as mkdirSync2 } from "node:fs"; +import { dirname as dirname2 } from "node:path"; + +// src/config.ts +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +var CACHE_PATH_RE = /[/\\]plugins[/\\]cache[/\\]([^/\\]+)[/\\]/; +var MARKETPLACE_RE = /^[A-Za-z0-9._-]+$/; +var FALLBACK_MARKETPLACE = "skillcorpus-marketplace"; +function validMarketplace(value) { + return MARKETPLACE_RE.test(value) && value !== "." && value !== ".."; +} +function marketplaceName(argv1 = process.argv[1] ?? "", env = process.env) { + const override = env.SKILLSEARCH_MARKETPLACE?.trim(); + if (override && validMarketplace(override)) return override; + const parsed = CACHE_PATH_RE.exec(argv1)?.[1]; + return parsed && validMarketplace(parsed) ? parsed : FALLBACK_MARKETPLACE; +} +function dataDirectory(argv1 = process.argv[1] ?? "", env = process.env, home = homedir()) { + const override = env.SKILLSEARCH_DATA_DIR?.trim(); + if (override) { + if (override === "~") return home; + if (override.startsWith("~/") || override.startsWith("~\\")) return join(home, override.slice(2)); + return override; + } + return join(home, ".workbuddy-ai", "plugins", "data", `skillsearch-${marketplaceName(argv1, env)}`); +} +var DATA_DIR = dataDirectory(); +var MAX_TIMEOUT_MS = 8e3; +var DEFAULTS = { + // Both roots WorkBuddy actually keeps skills in: what the user installed, + // and what plugins brought with them. + skillsDirs: ["~/.workbuddy-ai/skills", "~/.workbuddy-ai/plugins/cache"], + hubEndpoint: "https://skillhub.evermind.ai", + hubApiKey: "", + clawhubEndpoint: "https://clawhub.ai", + skillhubCnEndpoint: "https://api.skillhub.cn", + bundleCacheDir: "", + model: "", + modelBaseUrl: "https://api.openai.com/v1", + modelApiKey: "", + topK: 2, + gatePool: 10, + maxSelect: 2, + indexBody: false, + // Off, unlike every other host. A rewrite is a model round-trip inside the + // gap between the user pressing enter and the reply starting, and this host + // has no way to show that it is working. + rewrite: false, + gate: void 0, + // ClawHub measured about 4s through the supported proxy. Keep enough room for + // search plus one cached-or-downloaded body, while staying below the host’s + // own 10s hook timeout so the hook can fail open first. + timeoutMs: 8e3, + availableTools: [], + // Local first, catalog third. Tried the other way on 2026-08-18: the + // catalog's top two for a poster task both depended on infrastructure this + // machine does not have (a private ngrok MCP, a NANO_BANANA key), and the + // model spent its whole reasoning budget on them while the local skill that + // actually runs here sat unread in seat three. Curated-local beats + // unvetted-remote wherever both have an answer; the catalog earns its seat + // where local has nothing. + localWeight: 1, + hubWeight: 0.85, + rrfK: 10, + indexCachePath: join(DATA_DIR, "index-cache.json"), + logPath: join(DATA_DIR, "skillsearch.log") +}; +var ENV_KEYS = { + skillsDirs: "SKILLSEARCH_SKILLS_DIRS", + hubEndpoint: "SKILLSEARCH_HUB_ENDPOINT", + hubApiKey: "SKILLSEARCH_HUB_API_KEY", + clawhubEndpoint: "SKILLSEARCH_CLAWHUB_ENDPOINT", + skillhubCnEndpoint: "SKILLSEARCH_SKILLHUB_CN_ENDPOINT", + bundleCacheDir: "SKILLSEARCH_BUNDLE_CACHE_DIR", + model: "SKILLSEARCH_MODEL", + modelBaseUrl: "SKILLSEARCH_MODEL_BASE_URL", + modelApiKey: "SKILLSEARCH_MODEL_API_KEY", + topK: "SKILLSEARCH_TOP_K", + gatePool: "SKILLSEARCH_GATE_POOL", + maxSelect: "SKILLSEARCH_MAX_SELECT", + indexBody: "SKILLSEARCH_INDEX_BODY", + rewrite: "SKILLSEARCH_REWRITE", + gate: "SKILLSEARCH_GATE", + timeoutMs: "SKILLSEARCH_TIMEOUT_MS", + availableTools: "SKILLSEARCH_AVAILABLE_TOOLS", + localWeight: "SKILLSEARCH_LOCAL_WEIGHT", + hubWeight: "SKILLSEARCH_HUB_WEIGHT", + rrfK: "SKILLSEARCH_RRF_K", + indexCachePath: "SKILLSEARCH_INDEX_CACHE_PATH", + logPath: "SKILLSEARCH_LOG_PATH" +}; +function asList(value) { + if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean); + if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean); + return void 0; +} +function asNumber(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return void 0; +} +function asBoolean(value) { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const text = value.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(text)) return true; + if (["false", "0", "no", "off"].includes(text)) return false; + } + return void 0; +} +function asEndpoint(value, fallback) { + return typeof value === "string" ? value.trim() : fallback; +} +function asText(value) { + if (typeof value !== "string") return void 0; + const trimmed = value.trim(); + return trimmed ? trimmed : void 0; +} +function readConfigDocument(path = join(DATA_DIR, "config.json")) { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} +function loadConfig(document, env = process.env) { + const source = document ?? {}; + const pick = (key) => { + const variable = ENV_KEYS[key]; + const fromEnv = variable ? env[variable] : void 0; + return fromEnv !== void 0 && fromEnv !== "" ? fromEnv : source[key]; + }; + return { + skillsDirs: asList(pick("skillsDirs")) ?? DEFAULTS.skillsDirs, + hubEndpoint: asEndpoint(pick("hubEndpoint"), DEFAULTS.hubEndpoint), + hubApiKey: asText(pick("hubApiKey")) ?? DEFAULTS.hubApiKey, + clawhubEndpoint: asEndpoint(pick("clawhubEndpoint"), DEFAULTS.clawhubEndpoint), + skillhubCnEndpoint: asEndpoint(pick("skillhubCnEndpoint"), DEFAULTS.skillhubCnEndpoint), + bundleCacheDir: asText(pick("bundleCacheDir")) ?? DEFAULTS.bundleCacheDir, + model: asText(pick("model")) ?? DEFAULTS.model, + modelBaseUrl: asText(pick("modelBaseUrl")) ?? DEFAULTS.modelBaseUrl, + modelApiKey: asText(pick("modelApiKey")) ?? DEFAULTS.modelApiKey, + topK: asNumber(pick("topK")) ?? DEFAULTS.topK, + gatePool: asNumber(pick("gatePool")) ?? DEFAULTS.gatePool, + maxSelect: asNumber(pick("maxSelect")) ?? DEFAULTS.maxSelect, + indexBody: asBoolean(pick("indexBody")) ?? DEFAULTS.indexBody, + rewrite: asBoolean(pick("rewrite")) ?? DEFAULTS.rewrite, + gate: asBoolean(pick("gate")), + // Clamped below the host's own hook timeout in `hooks.json` (10s). Past + // it the host kills the process first, and a killed hook fails the turn + // rather than costing it its skills — the one outcome this plugin exists + // to avoid. Two settings that must stay ordered, so the code orders them. + timeoutMs: Math.min(asNumber(pick("timeoutMs")) ?? DEFAULTS.timeoutMs, MAX_TIMEOUT_MS), + availableTools: asList(pick("availableTools")) ?? DEFAULTS.availableTools, + localWeight: asNumber(pick("localWeight")) ?? DEFAULTS.localWeight, + hubWeight: asNumber(pick("hubWeight")) ?? DEFAULTS.hubWeight, + rrfK: asNumber(pick("rrfK")) ?? DEFAULTS.rrfK, + indexCachePath: asText(pick("indexCachePath")) ?? DEFAULTS.indexCachePath, + logPath: asText(pick("logPath")) ?? DEFAULTS.logPath + }; +} + +// src/retrieve.ts +import { homedir as homedir2 } from "node:os"; +import { join as join8 } from "node:path"; + +// ../engine-typescript/src/engine.ts +import { createHash } from "node:crypto"; + +// ../engine-typescript/src/fusion.ts +var RRF_K = 60; +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 contribution = weight / (rrfK + rank); + const seen = merged.get(key); + if (seen === void 0) { + merged.set(key, { + score: contribution, + best: hit, + bestClaim: contribution, + sources: [sourceName2] + }); + continue; + } + seen.score += contribution; + seen.sources.push(sourceName2); + if (contribution > seen.bestClaim) { + seen.best = hit; + seen.bestClaim = contribution; + } + } + } + return [...merged.values()].sort((a, b) => b.score - a.score).slice(0, k).map(({ score, best, sources }) => ({ + ...best, + meta: { ...best.meta, rrfScore: score, contributingSources: [...sources] } + })); +} + +// ../engine-typescript/src/deadline.ts +function withTimeout(promise, ms) { + let timer; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`timed out after ${ms}ms`)); + }, ms); + }); + return Promise.race([promise, deadline]).finally(() => { + clearTimeout(timer); + }); +} +async function bounded(run, ms, outer) { + const controller = new AbortController(); + const onOuterAbort = () => { + controller.abort(); + }; + outer?.addEventListener("abort", onOuterAbort, { once: true }); + const attempt = run(controller.signal); + attempt.catch(() => { + }); + try { + return await withTimeout(attempt, ms); + } catch (error) { + controller.abort(); + throw error; + } finally { + outer?.removeEventListener("abort", onOuterAbort); + } +} + +// ../engine-typescript/src/replies.ts +var THINK = /[\s\S]*?<\/think>/g; +var FENCED = /```(?:json)?\s*\n?([\s\S]*?)\n?```/; +var BRACED = /\{[\s\S]*\}/; +function extractJsonObject(content) { + const text = (content ?? "").replace(THINK, "").trim(); + if (!text) return void 0; + const candidates = []; + const fenced = FENCED.exec(text); + if (fenced?.[1] !== void 0) candidates.push(fenced[1].trim()); + const braced = BRACED.exec(text); + if (braced) candidates.push(braced[0]); + candidates.push(text); + for (const candidate of candidates) { + let data; + try { + data = JSON.parse(candidate); + } catch { + continue; + } + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + return data; + } + } + return void 0; +} + +// ../engine-typescript/src/gate.ts +var BODY_EXCERPT_CHARS = 300; +var LLMGateFilter = class { + model; + maxSelect; + fallbackTopK; + timeoutMs; + constructor(model, options = {}) { + this.model = model; + this.maxSelect = options.maxSelect ?? 2; + this.fallbackTopK = options.fallbackTopK ?? this.maxSelect; + this.timeoutMs = options.timeoutMs ?? 2e4; + } + /** + * Narrow `candidates` to the skills worth injecting for `task`. + * + * `availableTools` enables the environment check; without it the gate still + * judges relevance. Returns the top `fallbackTopK` candidates on timeout, + * transport failure, or an unparseable reply — a broken gate degrades to + * unfiltered retrieval rather than to silence. + * + * @param task - the user's words, unrewritten: the gate judges the real ask. + * @param candidates - the fused pool, best first. + * @param availableTools - tools this agent can call, enabling the hard rule. + * @param signal - aborts the call when the turn is cancelled. + * @returns the kept candidates, at most `maxSelect`, possibly empty. + */ + async filter(task, candidates, availableTools, signal) { + if (candidates.length === 0) return []; + const { catalog, byId } = buildCatalog(candidates); + const prompt = this.buildPrompt(task, catalog, availableTools); + let content; + try { + content = await bounded( + (s) => this.model.complete(prompt, { signal: s }), + this.timeoutMs, + signal + ); + } catch { + return candidates.slice(0, this.fallbackTopK); + } + let selectedIds; + try { + selectedIds = parseResponse(content); + } catch { + return candidates.slice(0, this.fallbackTopK); + } + const selected = []; + for (const id of selectedIds.slice(0, this.maxSelect)) { + const hit = byId.get(id); + if (hit) selected.push(hit); + } + return selected; + } + buildPrompt(task, catalog, availableTools) { + let toolsBlock = ""; + if (availableTools && availableTools.length > 0) { + const names = [...new Set(availableTools)].sort().join(", "); + toolsBlock = `# Agent Tools + +The agent's ONLY available tools are: ${names}. + +**Hard rule**: a skill is NOT relevant if its workflow requires any tool, file, or environment that the agent lacks. Inspect EACH candidate's body excerpt and exclude it if you see any of: +- A specific external API / SDK / vendor (e.g. \`\`nyne-deep-research\`\`, \`\`musicbrainz\`\`, \`\`bandcamp\`\`, \`\`-api\`\` suffix, vendor wrapper). +- Environment placeholders or paths that won't exist in this runtime: \`\`\${CLAUDE_PLUGIN_ROOT}\`\`, \`\`{baseDir}\`\`, \`\`{overrides}\`\`, \`\`.aiwg/\`\`, \`\`\${SKILL_HOME}\`\`, \`\`$ARGUMENTS\`\` as a slot, references to \`\`\${...}\`\` template variables. +- Slash-command triggers (e.g. \`\`/research-query\`\`) \u2014 the agent has no slash dispatcher. +- \`\`Parent agent:\`\` style multi-agent framework assumptions, or references to other SKILL.md files under unspecified directories. +- Agent personas, role-play, creative writing, content generation \u2014 these are not research procedures. + +**Only include** skills whose body describes a self-contained procedure that the agent can execute with just the listed tools (e.g. query-writing strategies, verification workflows, search-result interpretation). + +`; + } + return `You are a skill selector for an autonomous agent. + +# Task + +${task} + +` + toolsBlock + `# Candidate Skills + +${catalog} + +# Instructions + +1. **Plan**: briefly think about what the task requires and which sequence of available-tool calls would achieve it. +2. **Filter**: for EACH candidate skill, ask "can the agent execute this skill's workflow using only the available tools above?" If no, drop it \u2014 no matter how topically relevant. +3. **Match**: among the survivors, a skill is relevant ONLY if it provides a procedure or strategy directly useful for a core part of your plan. Vague topical overlap is not enough. +4. **Decide**: select AT MOST ${this.maxSelect} skill(s). If no skill survives both the tool check and the relevance check, you MUST return an empty list. Selecting an irrelevant or unexecutable skill is strictly worse than selecting none. + +Return ONLY a JSON object on a single line: +{"plan": "1-sentence plan", "skills": ["qualified_id_1"]} + +Or when nothing applies: {"plan": "...", "skills": []} + +Use the EXACT qualified_id strings from the candidate list above.`; + } +}; +function buildCatalog(candidates) { + const lines = []; + const byId = /* @__PURE__ */ new Map(); + for (const h of candidates) { + const sid = h.qualifiedId; + let desc = (h.meta.description ?? "").trim().replace(/\n/g, " "); + if (!desc) desc = "(no description)"; + if (desc.length > 200) desc = `${desc.slice(0, 197)}...`; + const body = h.content.trim(); + const excerpt = body.split(/\s+/).join(" ").slice(0, BODY_EXCERPT_CHARS) || "(no body)"; + lines.push(`- ${sid}: ${desc} + Body excerpt: ${excerpt}`); + byId.set(sid, h); + } + return { catalog: lines.join("\n"), byId }; +} +function parseResponse(content) { + const data = extractJsonObject(content); + if (data === void 0) throw new Error("no JSON object in reply"); + const skills = data.skills; + if (!Array.isArray(skills)) throw new Error("missing 'skills' array"); + return skills.filter((s) => typeof s === "string" && s.length > 0); +} + +// ../engine-typescript/src/refs.ts +import { existsSync, statSync } from "node:fs"; +import { join as join2 } from "node:path"; +var BUNDLED_DIRS = ["references", "scripts", "assets", "examples"]; +var MD_LINK_RE = new RegExp( + String.raw`\[([^\]]+)\]\((?:\.{0,2}/)?((?:${BUNDLED_DIRS.join("|")})/[^)\s]+)\)`, + "g" +); +var BASE_DIR_REF_RE = /\{baseDir\}\/(\S+?)(?=[\s)'"`]|$)/g; +var BARE_BASE_DIR_RE = /\{baseDir\}(?!\/)/g; +var CODE_FENCE_RE = /(```[\s\S]*?```)/; +function resolveRefs(body, skillDir) { + if (!body) return { body: "", anyResolved: false }; + const hasDir = !!skillDir && isDirectory(skillDir); + if (!hasDir) { + const stripped = body.includes("{baseDir}") ? body.replaceAll("{baseDir}/", "").replaceAll("{baseDir}", "") : body; + return { body: stripped, anyResolved: false }; + } + const baseDir = skillDir; + let anyResolved = false; + const mdSub = (match, label, rel) => { + const trimmed = rel.replace(/[.,;:]+$/, ""); + const cut = firstIndexOfAny(trimmed, ["#", "?"]); + const fragment = cut === -1 ? "" : trimmed.slice(cut); + const relFile = cut === -1 ? trimmed : trimmed.slice(0, cut); + if (relFile && existsSync(join2(baseDir, relFile))) { + anyResolved = true; + return `[${label}](${baseDir}/${relFile}${fragment})`; + } + return match; + }; + const segments = body.split(CODE_FENCE_RE); + let out = segments.map((segment) => segment.startsWith("```") ? segment : segment.replace(MD_LINK_RE, mdSub)).join(""); + if (out.includes("{baseDir}")) { + out = out.replace(BASE_DIR_REF_RE, (match, ref) => { + const trimmed = ref.replace(/[.,;:]+$/, ""); + if (trimmed && existsSync(join2(baseDir, trimmed))) { + anyResolved = true; + return `${baseDir}/${ref}`; + } + return match; + }); + out = out.replace(BARE_BASE_DIR_RE, () => { + anyResolved = true; + return baseDir; + }); + } + return { body: out, anyResolved }; +} +function isDirectory(path) { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} +function firstIndexOfAny(text, needles) { + const found = needles.map((n) => text.indexOf(n)).filter((i) => i !== -1); + return found.length === 0 ? -1 : Math.min(...found); +} + +// ../engine-typescript/src/rewriter.ts +var REWRITE_PROMPT = `Rewrite the following user query for skill retrieval. Remove noise (paths, IDs, timestamps, boilerplate). Keep task type, domain, required capabilities, and key technical details. Do NOT answer or solve the query \u2014 only rewrite it. + +Return JSON: {"rewritten_query": "..." or null} + +{query}`; +var QUERY_MAX_LENGTH = 2e3; +var TIMEOUT_MS = 5e3; +var QueryRewriter = class { + model; + timeoutMs; + constructor(model, options = {}) { + this.model = model; + this.timeoutMs = options.timeoutMs ?? TIMEOUT_MS; + } + /** + * Rewrite `query` for retrieval. + * + * @param query - the user's words for this turn. + * @param signal - aborts the call when the turn is cancelled. + * @returns the rewrite, or an empty one meaning "search the raw query". + * A blank query, a transport failure and an unparsable reply all land + * there; none of them stops the search. + */ + async analyze(query, signal) { + const truncated = query.trim().slice(0, QUERY_MAX_LENGTH); + if (!truncated) return { rewrittenQuery: "" }; + const prompt = REWRITE_PROMPT.replace("{query}", () => truncated); + let content; + try { + content = await bounded( + (s) => this.model.complete(prompt, { signal: s }), + this.timeoutMs, + signal + ); + } catch { + return { rewrittenQuery: "" }; + } + return parse(content); + } +}; +function parse(content) { + const data = extractJsonObject(content); + if (data === void 0) return { rewrittenQuery: "" }; + const record = data; + const rewritten = typeof record.rewritten_query === "string" ? record.rewritten_query.trim() : ""; + return { rewrittenQuery: rewritten }; +} + +// ../engine-typescript/src/engine.ts +var SkillSearchEngine = class { + sources; + rewriter; + gate; + fetchBody; + materialise; + onDiagnostic; + topK; + rrfK; + gatePool; + overFetch; + perSourceMax; + dedupBy; + heading; + refs; + constructor(parts, options = {}) { + this.sources = parts.sources; + this.rewriter = parts.rewriter; + this.gate = parts.gate; + this.fetchBody = parts.fetchBody; + this.materialise = parts.materialise; + this.onDiagnostic = parts.onDiagnostic; + this.topK = options.topK ?? 2; + this.rrfK = options.rrfK; + this.gatePool = options.gatePool ?? 10; + this.overFetch = options.overFetch ?? 2; + this.perSourceMax = options.perSourceMax ?? 2; + this.dedupBy = options.dedupBy ?? "qualifiedId"; + this.heading = options.heading ?? "# Skills"; + this.refs = options.resolveRefs ?? true; + } + /** Whether anything is configured to search. */ + get enabled() { + return this.sources.length > 0; + } + /** + * Search for `query` and render what survives. + * @param query - the user's words for this turn. + * @param options - this turn's cancellation and tool list. + * @returns the block to inject, or `''` when this turn gets no skills. + */ + async retrieve(query, options = {}) { + const hits = await this.hits(query, options); + return hits.length === 0 ? "" : this.render(hits); + } + /** + * Run the pipeline and return the selection unrendered. + * @param query - the user's words for this turn. + * @param options - this turn's cancellation and tool list. + * @returns the selected skills, empty on any failure; never rejects. + */ + async hits(query, options = {}) { + if (!this.enabled || !query.trim()) return []; + try { + return await this.run(query, options); + } catch { + return []; + } + } + async run(query, options) { + const signal = options.signal; + let searchQuery = query; + if (this.rewriter) { + const { rewrittenQuery } = await this.rewriter.analyze(query, signal); + if (rewrittenQuery) searchQuery = rewrittenQuery; + } + const poolSize = this.gate ? this.gatePool : this.topK; + const perSource = Math.min(this.perSourceMax, poolSize * this.overFetch); + const results = await Promise.all( + this.sources.map(async (source) => { + const startedAt = Date.now(); + try { + const hits2 = await source.search(searchQuery, signal ? { signal } : {}, perSource); + this.diagnose({ + source: source.name, + stage: "search", + elapsedMs: Date.now() - startedAt, + hitCount: hits2.length + }); + return { name: source.name, weight: source.weight, hits: hits2 }; + } catch (error) { + this.diagnose({ + source: source.name, + stage: "search", + elapsedMs: Date.now() - startedAt, + hitCount: 0, + error: errorMessage(error) + }); + return { name: source.name, weight: source.weight, hits: [] }; + } + }) + ); + let hits = this.rrfK === void 0 ? rrfMergeWeighted(results, poolSize, this.dedupBy) : rrfMergeWeighted(results, poolSize, this.dedupBy, this.rrfK); + if (hits.length === 0) return []; + hits = await this.hydrateBodies(hits, signal); + hits = hits.filter((hit) => !["clawhub", "skillhub_cn"].includes(String(hit.meta.source)) || Boolean(hit.content)); + hits = dedupExactBodies(hits); + if (hits.length === 0) return []; + hits = this.resolveLocalRefs(hits); + if (this.gate) { + hits = await this.gate.filter(query, hits, options.availableTools, signal); + } + return this.resolveHitRefs(hits.slice(0, this.topK), signal); + } + diagnose(diagnostic) { + try { + this.onDiagnostic?.(diagnostic); + } catch { + } + } + /** Rewrite refs for hits that already know their directory. */ + resolveLocalRefs(hits) { + if (!this.refs) return hits; + return hits.map((hit) => { + const skillDir = hit.meta.skillDir; + if (typeof skillDir !== "string" || !skillDir || !hit.content) return hit; + const { body } = resolveRefs(hit.content, skillDir); + return body === hit.content ? hit : { ...hit, content: body }; + }); + } + /** + * Give each survivor a directory, then rewrite its refs against it. + * + * A local hit was already resolved before the gate; this pass exists for + * the remote ones, whose bundle is extracted first when the host + * supplied a way to. A failure there leaves the body unresolved. + */ + async resolveHitRefs(hits, signal) { + if (!this.refs) return hits; + return Promise.all(hits.map(async (hit) => { + let current = hit; + if (typeof current.meta.skillDir !== "string" && this.materialise) { + const startedAt = Date.now(); + const source = sourceName(current); + try { + const installed = await this.materialise(current, signal); + this.diagnose({ + source, + stage: "materialise", + elapsedMs: Date.now() - startedAt, + succeeded: Boolean(installed) + }); + if (installed) { + current = { + ...current, + content: installed.body || current.content, + meta: { ...current.meta, skillDir: installed.dir } + }; + } + } catch (error) { + this.diagnose({ + source, + stage: "materialise", + elapsedMs: Date.now() - startedAt, + succeeded: false, + error: errorMessage(error) + }); + return current; + } + } + const skillDir = current.meta.skillDir; + if (typeof skillDir !== "string" || !skillDir || !current.content) return current; + const { body } = resolveRefs(current.content, skillDir); + return body === current.content ? current : { ...current, content: body }; + })); + } + /** Fill in bodies for hits a source returned as metadata only. */ + async hydrateBodies(hits, signal) { + const fetchBody = this.fetchBody; + if (!fetchBody) return hits; + return Promise.all( + hits.map(async (hit) => { + if (hit.content) return hit; + const startedAt = Date.now(); + const source = sourceName(hit); + try { + const out = await fetchBody(hit, signal); + this.diagnose({ + source, + stage: "hydrate", + elapsedMs: Date.now() - startedAt, + succeeded: Boolean(out && (typeof out === "string" || out.body)) + }); + if (!out) return hit; + if (typeof out === "string") return { ...hit, content: out }; + const next = { ...hit }; + if (out.body) next.content = out.body; + if (out.record) next.meta = { ...hit.meta, _fetched: out.record }; + return next; + } catch (error) { + this.diagnose({ + source, + stage: "hydrate", + elapsedMs: Date.now() - startedAt, + succeeded: false, + error: errorMessage(error) + }); + return hit; + } + }) + ); + } + /** + * Render hits into the injected block. + * + * A hit whose files are on disk gets its directory named and a sentence + * telling the model how to reach them; a body saying `scripts/x.sh` is + * otherwise read as relative to the agent's cwd. + * + * @param hits - the selection, in the order the model should see it. + * @returns the model-facing block, or `''` when no hit carried a body. + */ + render(hits) { + const parts = []; + for (const hit of hits) { + const skillDir = hit.meta.skillDir; + const header = skillDir ? `### Skill: ${hit.name} [${hit.qualifiedId}] +**Skill directory**: \`${skillDir}\` +Relative refs (e.g. \`references/x.md\`, \`./scripts/y.sh\`) resolve under this directory \u2014 use the absolute form for read_file / exec. +` : `### Skill: ${hit.name} [${hit.qualifiedId}] +`; + parts.push(header); + const content = hit.content.trim(); + if (content) parts.push(content); + } + const body = parts.join("\n\n"); + return body ? `${this.heading} + +${body}` : ""; + } +}; +function dedupExactBodies(hits) { + const output = []; + const positions = /* @__PURE__ */ new Map(); + for (const hit of hits) { + const body = normaliseBody(hit.content); + if (!body) { + output.push(hit); + continue; + } + const digest = createHash("sha256").update(body).digest("hex"); + const existing = positions.get(digest); + if (existing === void 0) { + positions.set(digest, output.length); + output.push(hit); + } else if (isLocal(hit) && !isLocal(output[existing])) { + output[existing] = hit; + } + } + return output; +} +function normaliseBody(body) { + return body.replace(/\r\n?/g, "\n").split("\n").map((line) => line.trimEnd()).join("\n").trim(); +} +function isLocal(hit) { + return String(hit.meta.source) === "local" || typeof hit.meta.skillDir === "string" && Boolean(hit.meta.skillDir); +} +function sourceName(hit) { + const source = hit.meta.source; + return typeof source === "string" && source ? source : hit.qualifiedId.split("/", 1)[0] || "unknown"; +} +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +// ../engine-typescript/src/hub-source.ts +import { existsSync as existsSync2 } from "node:fs"; +import { join as join4 } from "node:path"; + +// ../engine-typescript/src/bundle.ts +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { readdir } from "node:fs/promises"; +import { isAbsolute, join as join3, relative, resolve } from "node:path"; + +// ../engine-typescript/src/zip.ts +import { inflateRawSync } from "node:zlib"; +var EOCD_SIGNATURE = 101010256; +var CENTRAL_SIGNATURE = 33639248; +var LOCAL_SIGNATURE = 67324752; +var STORED = 0; +var DEFLATED = 8; +function findEndOfCentralDirectory(buffer) { + const minimum = 22; + if (buffer.length < minimum) throw new Error("not a zip archive: too short"); + const earliest = Math.max(0, buffer.length - minimum - 65535); + for (let offset = buffer.length - minimum; offset >= earliest; offset -= 1) { + if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset; + } + throw new Error("not a zip archive: no end-of-central-directory record"); +} +function readName(buffer, start, length) { + if (start + length > buffer.length) throw new Error("zip entry name runs past the archive"); + return buffer.toString("utf8", start, start + length); +} +function readZipEntries(buffer) { + const eocd = findEndOfCentralDirectory(buffer); + const entryCount = buffer.readUInt16LE(eocd + 10); + const directoryOffset = buffer.readUInt32LE(eocd + 16); + if (directoryOffset > buffer.length) throw new Error("zip central directory is out of range"); + const entries = []; + let cursor = directoryOffset; + for (let index = 0; index < entryCount; index += 1) { + if (cursor + 46 > buffer.length) throw new Error("zip central directory is truncated"); + if (buffer.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) { + throw new Error(`zip central directory entry ${index} has a bad signature`); + } + const method = buffer.readUInt16LE(cursor + 10); + const compressedSize = buffer.readUInt32LE(cursor + 20); + const declaredSize = buffer.readUInt32LE(cursor + 24); + const nameLength = buffer.readUInt16LE(cursor + 28); + const extraLength = buffer.readUInt16LE(cursor + 30); + const commentLength = buffer.readUInt16LE(cursor + 32); + const localOffset = buffer.readUInt32LE(cursor + 42); + const name = readName(buffer, cursor + 46, nameLength); + cursor += 46 + nameLength + extraLength + commentLength; + if (name.endsWith("/")) continue; + entries.push({ + name, + declaredSize, + read() { + if (method !== STORED && method !== DEFLATED) { + throw new Error(`${name}: unsupported compression method ${method}`); + } + if (localOffset + 30 > buffer.length) throw new Error(`${name}: local header out of range`); + if (buffer.readUInt32LE(localOffset) !== LOCAL_SIGNATURE) { + throw new Error(`${name}: bad local header signature`); + } + const localNameLength = buffer.readUInt16LE(localOffset + 26); + const localExtraLength = buffer.readUInt16LE(localOffset + 28); + const start = localOffset + 30 + localNameLength + localExtraLength; + const end = start + compressedSize; + if (end > buffer.length) throw new Error(`${name}: data runs past the archive`); + const raw = buffer.subarray(start, end); + const out = method === STORED ? Buffer.from(raw) : inflateRawSync(raw, { maxOutputLength: declaredSize }); + if (out.length !== declaredSize) { + throw new Error( + `${name}: inflated to ${out.length} bytes, directory declared ${declaredSize}` + ); + } + return out; + } + }); + } + return entries; +} + +// ../engine-typescript/src/bundle.ts +var MAX_ENTRY_BYTES = 8 * 1024 * 1024; +var MAX_TOTAL_BYTES = 64 * 1024 * 1024; +var ALLOWED_SUFFIXES = /* @__PURE__ */ new Set([ + "", + ".md", + ".txt", + ".json", + ".jsonl", + ".yaml", + ".yml", + ".toml", + ".csv", + ".tsv", + ".cfg", + ".ini", + ".xml", + ".html", + ".htm", + ".sql", + ".env", + ".sh", + ".py", + ".js", + ".mjs", + ".cjs", + ".ts", + ".rb", + ".pl", + ".lua", + ".ps1", + ".bat", + ".svg", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".pdf" +]); +function suffixOf(name) { + const base = name.slice(name.lastIndexOf("/") + 1); + const dot = base.lastIndexOf("."); + return dot <= 0 ? "" : base.slice(dot).toLowerCase(); +} +async function extractBundle(archive, destination) { + const staging = `${destination}.incoming-${process.pid}-${Math.random().toString(16).slice(2, 10)}`; + const root = resolve(staging); + let total = 0; + try { + await mkdir(staging, { recursive: true }); + for (const entry of readZipEntries(archive)) { + const target = resolve(root, entry.name); + const inside = relative(root, target); + if (inside.startsWith("..") || isAbsolute(inside)) { + throw new Error(`unsafe zip path: ${entry.name}`); + } + if (!ALLOWED_SUFFIXES.has(suffixOf(entry.name))) continue; + if (entry.declaredSize > MAX_ENTRY_BYTES) continue; + if (total + entry.declaredSize > MAX_TOTAL_BYTES) { + throw new Error("zip uncompressed total too large"); + } + const data = entry.read(); + total += data.length; + await mkdir(join3(target, ".."), { recursive: true }); + await writeFile(target, data); + } + try { + await rename(staging, destination); + } catch (error) { + const { access } = await import("node:fs/promises"); + await access(destination).catch(() => { + throw error; + }); + await rm(staging, { recursive: true, force: true }); + } + } catch (error) { + await rm(staging, { recursive: true, force: true }); + throw error; + } +} +async function bundleRoot(destination) { + let entries; + try { + entries = await readdir(destination, { withFileTypes: true }); + } catch { + return destination; + } + const visible = entries.filter((entry) => !entry.name.startsWith(".")); + const only = visible[0]; + if (visible.length === 1 && only?.isDirectory()) return join3(destination, only.name); + return destination; +} + +// ../engine-typescript/src/relevance.ts +var STOP = /* @__PURE__ */ new Set([ + "a", + "an", + "the", + "to", + "for", + "with", + "using", + "use", + "create", + "make", + "help", + "please", + "and", + "or", + "of", + "in", + "on", + "my", + "me", + "i", + "want", + "need", + "how", + "can", + "from", + "this", + "that", + "these", + "those", + "such", + "no", + "\u5E2E\u6211", + "\u8BF7", + "\u4E00\u4E2A", + "\u4E00\u4E0B", + "\u5982\u4F55", + "\u600E\u4E48", + "\u4F7F\u7528", + "\u9700\u8981", + "\u60F3\u8981", + "\u8FDB\u884C" +]); +var ALIASES = { + k8s: ["kubernetes"], + pr: ["pull", "request"], + ppt: ["powerpoint"], + pptx: ["powerpoint"], + postgres: ["postgresql"], + transcription: ["transcribe"] +}; +var GENERIC = /* @__PURE__ */ new Set([ + "extract", + "review", + "deploy", + "deployment", + "generate", + "generator", + "analysis", + "optimize", + "optimization", + "process", + "processing", + "data", + "code", + "task" +]); +function queryTerms(query) { + const chunks = query.toLowerCase().match(/[a-z0-9+#.-]+|[\p{Script=Han}]+/gu) ?? []; + const raw = chunks.flatMap((chunk) => { + if (!/^[\p{Script=Han}]+$/u.test(chunk) || chunk.length < 2) return [chunk]; + return Array.from({ length: chunk.length - 1 }, (_, index) => chunk.slice(index, index + 2)); + }); + const terms = []; + for (const token of raw) { + if (STOP.has(token) || token.length < 2) continue; + const normalized = token.replace(/^[.-]+|[.-]+$/g, ""); + const expanded = ALIASES[normalized] ?? [stem(normalized)]; + for (const term of expanded) { + if (term && !STOP.has(term) && !terms.includes(term)) terms.push(term); + } + } + return terms; +} +function checkKeywordRelevance(query, hit) { + const terms = queryTerms(query); + if (terms.length === 0) { + return { passed: false, matchedTerms: [], requiredMatched: false, matchRatio: 0 }; + } + const tags = Array.isArray(hit.meta.tags) ? hit.meta.tags.join(" ") : ""; + const haystack = `${hit.name} ${String(hit.meta.description ?? "")} ${tags}`.toLowerCase(); + const matched = terms.filter((term) => containsTerm(haystack, term)); + const required = terms.filter((term) => !GENERIC.has(term)); + const requiredMatched = required.length === 0 || required.some((term) => matched.includes(term)); + const minimum = terms.length >= 4 ? 2 : 1; + return { + passed: requiredMatched && matched.length >= minimum, + matchedTerms: matched, + requiredMatched, + matchRatio: matched.length / terms.length + }; +} +function stem(token) { + if (/[+#.-]/.test(token)) return token; + if (token.endsWith("ies") && token.length > 4) return `${token.slice(0, -3)}y`; + if (token.endsWith("ing") && token.length > 5) return token.slice(0, -3); + if (token.endsWith("ed") && token.length > 4) return token.slice(0, -2); + if (token.endsWith("s") && token.length > 4 && !/(ss|us|is|es)$/.test(token)) { + return token.slice(0, -1); + } + return token; +} +function containsTerm(text, term) { + if (/^[a-z0-9+#.-]+$/.test(term)) { + const special = "\\^$.*+?()[]{}|"; + const escaped = Array.from(term, (char) => special.includes(char) ? `\\\\${char}` : char).join(""); + return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, "i").test(text); + } + return text.includes(term); +} + +// ../engine-typescript/src/hub-source.ts +var OK_TOKENS = /* @__PURE__ */ new Set(["ok", "success"]); +var SkillHubClient = class { + base; + apiKey; + timeoutMs; + downloadTimeoutMs; + cacheDir; + source; + constructor(endpoint, options = {}) { + this.base = endpoint.replace(/\/+$/, ""); + this.apiKey = options.apiKey; + this.timeoutMs = options.timeoutMs ?? 2e3; + this.downloadTimeoutMs = options.downloadTimeoutMs ?? 3e4; + this.cacheDir = options.cacheDir; + this.source = options.source ?? "cli"; + } + /** + * Download a skill's bundle and extract it, or reuse an extracted copy. + * + * @param id - the catalog's own id for the skill. + * @param meta - the skill's record, when the caller already fetched it; + * `slug` and `version` from it form the cache key. + * @param signal - aborts the download when the turn is cancelled. + * @returns the directory the skill's own paths resolve against, and the + * body the catalog stores, when the record carried one. + * @throws Error when no cache directory is configured, or the archive is + * 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"); + 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}`); + 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 : "" + }; + } + /** + * Fetch one bundle's bytes. + * + * @param id - the catalog's own id for the skill. + * @param signal - aborts the request when the turn is cancelled. + * @returns the archive. + */ + async download(id, signal) { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, this.downloadTimeoutMs); + const onAbort = () => { + controller.abort(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + const headers = {}; + if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`; + const response = await fetch(this.downloadUrl(id), { headers, signal: controller.signal }); + if (!response.ok) throw new Error(`catalog returned HTTP ${response.status} for a bundle`); + return Buffer.from(await response.arrayBuffer()); + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + } + /** + * Search the catalog. Metadata only — no bodies. + * @param query - the search text, sent as `q`. + * @param signal - aborts the request when the turn is cancelled. + * @param limit - how many entries to ask the catalog for. Sent explicitly: + * the catalog's own default page may be smaller than the fan-out wants. + * @returns the entries the catalog matched, in its own order. + */ + async search(query, signal, limit = 20) { + const url = `${this.base}/openapi/v1/skills?q=${encodeURIComponent(query)}&limit=${Math.max(1, Math.floor(limit))}`; + const result = await this.getJson(url, signal); + const items = result.items; + return Array.isArray(items) ? items : []; + } + /** + * Fetch one skill's full record. + * @param id - the catalog's own id for the skill. + * @param signal - aborts the request when the turn is cancelled. + * @returns the record, including `skill_md` when the catalog carries it. + */ + async get(id, signal) { + const url = `${this.base}/openapi/v1/skills/${encodeURIComponent(id)}`; + return await this.getJson(url, signal); + } + /** + * Build the bundle URL for a caller that will download and extract it. + * @param id - the catalog's own id for the skill. + * @returns the download URL, tagged with this client's `source`. + */ + downloadUrl(id) { + return `${this.base}/openapi/v1/skills/${encodeURIComponent(id)}/download?source=${this.source}`; + } + async getJson(url, signal) { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, this.timeoutMs); + const onAbort = () => { + controller.abort(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + const headers = { "X-Request-ID": randomId() }; + if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`; + const res = await fetch(url, { headers, signal: controller.signal }); + if (!res.ok) throw new Error(`catalog returned HTTP ${res.status}`); + const envelope = await res.json(); + if (envelope.status !== 0 || !OK_TOKENS.has(envelope.error ?? "")) { + throw new Error(`catalog error ${envelope.error ?? "unknown"} (status ${envelope.status})`); + } + return envelope.result ?? {}; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + } +}; +var HubSkillSource = class { + name = "hub"; + weight; + client; + minSafety; + minQuality; + maxCandidates; + constructor(client, options = {}) { + this.client = client; + this.weight = options.weight ?? 0.85; + this.minSafety = options.minSafety ?? 0.7; + this.minQuality = options.minQuality ?? 0.45; + this.maxCandidates = options.maxCandidates ?? 2; + } + async search(query, options, k) { + const limit = Math.min(k, this.maxCandidates); + if (limit <= 0) return []; + const items = await this.client.search(query, options.signal, Math.max(limit * 4, limit)); + const hits = []; + for (const item of items) { + const id = item.id; + const name = item.name; + if (!id || !name) continue; + if (item.score_safety !== void 0 && item.score_safety < this.minSafety) continue; + if (item.quality_score !== void 0 && item.quality_score < this.minQuality) continue; + const candidate = { + qualifiedId: `hub/${id}`, + name, + content: "", + score: item.quality_score ?? 0, + meta: { + source: "hub", + id, + skillId: item.skill_id, + description: item.description, + category: item.category, + qualityScore: item.quality_score, + installCount: item.install_count, + tags: item.tags + } + }; + const relevance = checkKeywordRelevance(query, candidate); + if (!relevance.passed) continue; + hits.push({ ...candidate, meta: { ...candidate.meta, keywordRelevance: relevance } }); + if (hits.length >= limit) break; + } + return hits; + } +}; +function randomId() { + return Array.from( + { length: 4 }, + () => Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0") + ).join(""); +} + +// ../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"; +var MarketplaceClient = class { + kind; + base; + cacheDir; + timeoutMs; + downloadTimeoutMs; + constructor(kind, endpoint, options) { + this.kind = kind; + this.base = endpoint.replace(/\/+$/, ""); + this.cacheDir = options.cacheDir; + this.timeoutMs = options.timeoutMs ?? 5e3; + this.downloadTimeoutMs = options.downloadTimeoutMs ?? 3e4; + } + async search(query, signal, limit = 2) { + return this.kind === "clawhub" ? this.searchClawHub(query, signal, limit) : this.searchSkillHubCn(query, signal, limit); + } + async install(hit, signal) { + const slug = String(hit.meta.slug ?? hit.meta.id); + const owner = String(hit.meta.owner ?? ""); + const version = String(hit.meta.version ?? "v0"); + const key = `${this.kind}-${owner ? `${owner}_` : ""}${slug}@${version}`.replace(/[^A-Za-z0-9_.@-]+/g, "_"); + const destination = join5(this.cacheDir, key); + if (!existsSync3(destination)) { + let archive; + try { + archive = await this.download(slug, owner, version, signal); + } catch (error) { + throw new Error(`download failed: ${errorMessage2(error)}`, { cause: error }); + } + try { + await extractBundle(archive, destination); + } catch (error) { + throw new Error(`extract failed: ${errorMessage2(error)}`, { cause: error }); + } + } + try { + const dir = await bundleRoot(destination); + const skillMd = await readFile(join5(dir, "SKILL.md"), "utf8"); + return { dir, body: stripFrontmatter(skillMd) }; + } catch (error) { + await rm2(destination, { recursive: true, force: true }).catch(() => { + }); + throw new Error(`read skill failed: ${errorMessage2(error)}`, { cause: error }); + } + } + async searchClawHub(query, signal, limit) { + const url = new URL(`${this.base}/api/v1/search`); + url.searchParams.set("q", query); + url.searchParams.set("limit", String(limit)); + url.searchParams.set("nonSuspiciousOnly", "true"); + const payload = await this.json(url, signal); + return (payload.results ?? []).flatMap((raw) => { + const slug = String(raw.slug ?? ""); + const native = raw.native; + const skill = native?.skill; + const trust = raw.trust; + if (!slug || trust?.visibility === "blocked" || trust?.installability === "blocked") return []; + return [{ + id: String(raw.id ?? slug), + slug, + name: String(raw.displayName ?? slug), + description: String(raw.summary ?? skill?.summary ?? ""), + score: Number(raw.score ?? 0), + owner: String(raw.ownerHandle ?? ""), + version: String(raw.version ?? skill?.latestVersionId ?? "v0"), + suspicious: skill?.isSuspicious === true, + installable: trust?.installability == null || trust.installability === "installable", + tags: Array.isArray(skill?.topics) ? skill.topics.map(String) : [] + }]; + }); + } + async searchSkillHubCn(query, signal, limit) { + const url = new URL(`${this.base}/api/skills`); + url.searchParams.set("keyword", query); + url.searchParams.set("sortBy", "score"); + url.searchParams.set("order", "desc"); + url.searchParams.set("page", "1"); + url.searchParams.set("pageSize", String(limit)); + const payload = await this.json(url, signal); + if (payload.code !== 0) throw new Error("skillhub.cn search failed"); + return (payload.data?.skills ?? []).flatMap((raw) => { + const slug = String(raw.slug ?? ""); + if (!slug || malicious(raw.securityReports)) return []; + const namespace = raw.namespace; + return [{ + id: String(namespace?.canonicalName ?? slug), + slug, + name: String(raw.name ?? slug), + description: String(raw.description_zh ?? raw.description ?? ""), + score: Number(raw.score ?? 0), + owner: String(raw.ownerName ?? namespace?.handle ?? ""), + version: String(raw.version ?? "v0"), + installable: true + }]; + }); + } + async download(slug, owner, version, signal) { + const url = new URL(`${this.base}/api/v1/download`); + url.searchParams.set("slug", slug); + if (this.kind === "clawhub" && owner) url.searchParams.set("ownerHandle", owner); + if (this.kind === "skillhub_cn" && version !== "v0") url.searchParams.set("version", version); + url.searchParams.set("source", this.kind === "skillhub_cn" ? "dsh" : "cli"); + return this.bytes(url, signal, this.downloadTimeoutMs); + } + async json(url, signal) { + const bytes = await this.bytes(url, signal, this.timeoutMs); + return JSON.parse(bytes.toString("utf8")); + } + async bytes(url, signal, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const abort = () => controller.abort(); + if (signal?.aborted) controller.abort(); + signal?.addEventListener("abort", abort, { once: true }); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) throw new Error(`${this.kind} returned HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + } + } +}; +var MarketplaceSkillSource = class { + constructor(client, options = {}) { + this.client = client; + this.name = client.kind; + this.weight = options.weight ?? 0.75; + } + client; + name; + weight; + async search(query, options, k) { + const items = await this.client.search(query, options.signal, Math.min(2, k)); + return items.filter((item) => !item.suspicious && item.installable !== false).slice(0, Math.min(2, k)).map((item) => ({ + qualifiedId: `${this.name}/${item.id}`, + name: item.name, + content: "", + score: item.score, + meta: { + source: this.name, + id: item.id, + slug: item.slug, + owner: item.owner, + version: item.version, + description: item.description, + tags: item.tags + } + })); + } +}; +function malicious(value) { + if (!value || typeof value !== "object") return false; + return Object.values(value).some((report) => report && typeof report === "object" && ["malicious", "suspicious"].includes(String(report.status))); +} +function stripFrontmatter(text) { + if (!text.startsWith("---")) return text; + const end = text.indexOf("\n---", 3); + return end < 0 ? text : text.slice(end + 4).replace(/^\n+/, ""); +} +function errorMessage2(error) { + return error instanceof Error ? error.message : String(error); +} + +// src/cached-local-source.ts +import { mkdirSync, readFileSync as readFileSync2, readdirSync, renameSync, statSync as statSync2, writeFileSync } from "node:fs"; +import { dirname, join as join7 } 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"; + +// ../engine-typescript/src/bm25.ts +var TOKEN_RE = /[a-z0-9]{2,}|[一-鿿]+/g; +var CJK_RE = /^[一-鿿]/; +function tokenize(text) { + const out = []; + for (const run of text.toLowerCase().match(TOKEN_RE) ?? []) { + if (!CJK_RE.test(run)) { + out.push(run); + continue; + } + if (run.length === 1) out.push(run); + else for (let i = 0; i < run.length - 1; i += 1) out.push(run.slice(i, i + 2)); + } + return out; +} +var STOPWORD_DF_RATIO = 0.5; +var STOPWORD_MIN_CORPUS = 10; +var BM25Okapi = class { + k1; + b; + corpusSize; + avgdl; + /** Per document, its term frequencies and its length, kept together. */ + docs; + idf; + /** + * Terms this corpus cannot distinguish on. + * + * A word in over half the documents carries no ranking signal here — in + * a skills directory that is "skill", "run", "use", the vocabulary of + * the format itself — but its idf stays just above zero, so every + * document holding it still collects score and an unrelated query still + * produces a confident-looking ranked list. + * + * Below `STOPWORD_MIN_CORPUS` documents this stays empty: on a corpus of + * three, a term in two is over the threshold, and pruning the query down + * to nothing is a worse answer than a weak ranking. + */ + stopwords; + constructor(tokenizedCorpus, k1 = 1.5, b = 0.75) { + this.k1 = k1; + this.b = b; + this.corpusSize = tokenizedCorpus.length; + this.avgdl = this.corpusSize ? tokenizedCorpus.reduce((a, d) => a + d.length, 0) / this.corpusSize : 0; + this.docs = []; + const df = /* @__PURE__ */ new Map(); + for (const doc of tokenizedCorpus) { + const freqs = /* @__PURE__ */ new Map(); + for (const tok of doc) freqs.set(tok, (freqs.get(tok) ?? 0) + 1); + this.docs.push({ freqs, len: doc.length }); + for (const tok of freqs.keys()) df.set(tok, (df.get(tok) ?? 0) + 1); + } + const n = this.corpusSize; + this.idf = /* @__PURE__ */ new Map(); + const stopwords = /* @__PURE__ */ new Set(); + for (const [term, count] of df) { + this.idf.set(term, Math.log(1 + (n - count + 0.5) / (count + 0.5))); + if (n >= STOPWORD_MIN_CORPUS && count / n > STOPWORD_DF_RATIO) stopwords.add(term); + } + this.stopwords = stopwords; + } + /** Score every document against the query. Index-aligned with the corpus. */ + /** + * Score every document in the corpus against one query. + * @param queryTokens - the tokenized query, from `tokenize`. + * @returns one score per document, in corpus order; 0 where nothing matched. + */ + getScores(queryTokens) { + const scores = new Array(this.corpusSize).fill(0); + if (queryTokens.length === 0 || this.corpusSize === 0) return scores; + for (const term of queryTokens) { + if (this.stopwords.has(term)) continue; + const idf = this.idf.get(term) ?? 0; + if (idf <= 0) continue; + for (const [i, doc] of this.docs.entries()) { + const f = doc.freqs.get(term) ?? 0; + if (f === 0) continue; + const norm = this.k1 * (1 - this.b + this.b * doc.len / (this.avgdl || 1)); + scores[i] = (scores[i] ?? 0) + idf * f * (this.k1 + 1) / (f + norm); + } + } + return scores; + } +}; + +// ../engine-typescript/src/local-source.ts +var SKILL_FILE = "SKILL.md"; +var INDEXED_BODY_CHARS = 4e3; +var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); +var LocalSkillSource = class { + name = "local"; + weight = 1; + roots; + maxDepth; + indexBody; + cache; + index; + constructor(roots, options = {}) { + this.roots = roots; + this.maxDepth = options.maxDepth ?? 5; + this.indexBody = options.indexBody ?? false; + } + /** Drop the scan and the index. Call when a `SKILL.md` changes on disk. */ + invalidate() { + this.cache = void 0; + this.index = void 0; + } + async search(query, options, k) { + const { bm25, skills } = await this.ensureIndex(); + if (skills.length === 0) return []; + options.signal?.throwIfAborted(); + const scores = bm25.getScores(tokenize(query)); + return skills.map((skill, i) => ({ score: scores[i] ?? 0, skill })).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, k).map(({ score, skill }) => ({ + qualifiedId: `local/${skill.name}`, + name: skill.name, + content: skill.content, + score, + meta: { + source: "local", + description: skill.description, + // 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 + } + })); + } + async ensureIndex() { + if (this.index) return this.index; + const skills = await this.listAll(); + const corpus = skills.map((s) => tokenize(formatSkillText(s, this.indexBody))); + this.index = { bm25: new BM25Okapi(corpus), skills }; + return this.index; + } + /** + * Scan every root once and cache the result. + * @returns every skill found, first root winning a name collision. + */ + async listAll() { + if (this.cache) return this.cache; + const found = []; + const seen = /* @__PURE__ */ new Set(); + for (const root of this.roots) { + for await (const file of walk(root.path, this.maxDepth)) { + let text; + try { + text = await readFile2(file, "utf8"); + } catch { + continue; + } + const { meta, body } = parseFrontmatter(text); + const dir = file.slice(0, file.length - SKILL_FILE.length - 1); + const name = meta.name ?? basename(dir); + const key = `${root.name}/${name}`; + if (seen.has(key)) continue; + seen.add(key); + found.push({ + name, + description: meta.description ?? "", + content: body, + source: root.name, + dir + }); + } + } + this.cache = found; + return found; + } +}; +function formatSkillText(skill, indexBody = false) { + const parts = [skill.name, skill.name, skill.description]; + if (indexBody) parts.push(skill.content.slice(0, INDEXED_BODY_CHARS)); + return parts.join(" "); +} +async function* walk(root, maxDepth) { + const stack = [{ dir: root, depth: 0 }]; + for (let next = stack.pop(); next !== void 0; next = stack.pop()) { + const { dir, depth } = next; + if (depth > maxDepth) continue; + let entries; + try { + entries = await readdir2(dir, { withFileTypes: true }); + } catch { + continue; + } + 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); + } + } + } +} +function parseFrontmatter(text) { + if (!text.startsWith("---")) return { meta: {}, body: text }; + const end = text.indexOf("\n---", 3); + if (end === -1) return { meta: {}, body: text }; + const head = text.slice(3, end); + const body = text.slice(end + 4).replace(/^\n+/, ""); + const meta = {}; + for (const line of head.split("\n")) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + const key = line.slice(0, colon); + if (key.startsWith(" ") || key.startsWith(" ") || key.startsWith("#")) continue; + meta[key.trim()] = line.slice(colon + 1).trim().replace(/^["']|["']$/g, ""); + } + return { meta, body }; +} + +// src/cached-local-source.ts +var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "__pycache__", "node_modules", ".venv", "venv"]); +var SKILL_FILE2 = "SKILL.md"; +var CachedLocalSkillSource = class extends LocalSkillSource { + cachePath; + rootPaths; + depth; + constructor(roots, options) { + super(roots, options); + this.cachePath = options.cachePath; + this.rootPaths = roots.map((root) => root.path); + this.depth = options.maxDepth ?? 5; + } + /** + * The parent's scan, served from disk when nothing on disk has changed. + * @returns every skill found, first root winning a name collision. + */ + async listAll() { + if (!this.cachePath) return super.listAll(); + const fingerprint = this.fingerprint(); + const cached = this.read(); + if (cached && cached.fingerprint === fingerprint) return cached.skills; + const skills = await super.listAll(); + this.write({ version: 1, fingerprint, 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); + return `${parts.length}|${hash(parts.join("\n"))}`; + } + read() { + try { + const parsed = JSON.parse(readFileSync2(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; + return Array.isArray(file.skills) ? file : void 0; + } catch { + return void 0; + } + } + write(file) { + try { + mkdirSync(dirname(this.cachePath), { recursive: true }); + const temp = `${this.cachePath}.${process.pid}.tmp`; + writeFileSync(temp, JSON.stringify(file)); + renameSync(temp, this.cachePath); + } catch { + } + } +}; +function collect(dir, depth, out) { + if (depth < 0) return; + let entries; + try { + entries = readdirSync(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) { + try { + out.push(`${path}:${statSync2(path).mtimeMs}`); + } catch { + } + } + } +} +function hash(text) { + let value = 5381; + for (let index = 0; index < text.length; index += 1) { + value = (value * 33 ^ text.charCodeAt(index)) >>> 0; + } + return value.toString(36); +} + +// src/model.ts +function createChatModel(options) { + if (!options.model) return void 0; + const base = options.baseUrl.replace(/\/+$/, ""); + return { + async complete(prompt, opts) { + const headers = { "Content-Type": "application/json" }; + if (options.apiKey) headers.Authorization = `Bearer ${options.apiKey}`; + const response = await fetch(`${base}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model: options.model, + messages: [{ role: "user", content: prompt }], + temperature: 0 + }), + ...opts.signal ? { signal: opts.signal } : {} + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`model endpoint returned HTTP ${response.status}: ${detail.slice(0, 200)}`); + } + const body = await response.json(); + return body.choices?.[0]?.message?.content ?? ""; + } + }; +} + +// src/retrieve.ts +function expandHome(path, home = homedir2()) { + if (path === "~") return home; + if (path.startsWith("~/")) return join8(home, path.slice(2)); + return path; +} +function buildEngine(config, onDiagnostic) { + const sources = []; + const dirs = config.skillsDirs.map((dir) => expandHome(dir)).filter(Boolean); + if (dirs.length > 0) { + const local = new CachedLocalSkillSource( + dirs.map((path) => ({ path, name: "local" })), + { indexBody: config.indexBody, cachePath: expandHome(config.indexCachePath) } + ); + local.weight = config.localWeight; + sources.push(local); + } + let client; + if (config.hubEndpoint) { + client = new SkillHubClient(config.hubEndpoint, { + ...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") + }); + const hub = new HubSkillSource(client); + hub.weight = config.hubWeight; + sources.push(hub); + } + const marketplaceClients = /* @__PURE__ */ new Map(); + for (const [kind, endpoint] of [ + ["clawhub", config.clawhubEndpoint], + ["skillhub_cn", config.skillhubCnEndpoint] + ]) { + if (!endpoint) continue; + const marketplace = new MarketplaceClient(kind, endpoint, { + cacheDir: expandHome(config.bundleCacheDir) || join8(homedir2(), ".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)), + downloadTimeoutMs: Math.max(1, config.timeoutMs) + }); + marketplaceClients.set(kind, marketplace); + sources.push(new MarketplaceSkillSource(marketplace)); + } + const model = createChatModel({ + baseUrl: config.modelBaseUrl, + apiKey: config.modelApiKey, + model: config.model + }); + return new SkillSearchEngine( + { + sources, + ...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 }) } : {}, + ...client || marketplaceClients.size > 0 ? { + fetchBody: async (hit, signal) => { + const marketplace = marketplaceClients.get(String(hit.meta.source)); + if (marketplace) { + const installed = await marketplace.install(hit, signal); + return { body: installed.body, record: { _installed: installed } }; + } + if (hit.meta.source !== "hub" || !client) return void 0; + const record = await client.get(String(hit.meta.id), signal); + return { + ...typeof record.skill_md === "string" ? { body: record.skill_md } : {}, + record + }; + }, + materialise: async (hit, signal) => { + const marketplace = marketplaceClients.get(String(hit.meta.source)); + if (marketplace) { + const fetched2 = hit.meta._fetched; + const installed2 = fetched2?._installed ?? await marketplace.install(hit, signal); + return { dir: installed2.dir, body: installed2.body }; + } + if (hit.meta.source !== "hub" || !client) return void 0; + const fetched = hit.meta._fetched; + const installed = await client.install(String(hit.meta.id), fetched, signal); + return { dir: installed.dir, body: installed.skillMd }; + } + } : {} + }, + { topK: config.topK, gatePool: config.gatePool, rrfK: config.rrfK } + ); +} +async function retrieveForTurn(query, config, deps = {}, onDiagnostic) { + if (!query.trim()) return ""; + let engine; + try { + engine = (deps.buildEngineFn ?? buildEngine)(config, onDiagnostic); + } catch { + return ""; + } + if (!engine.enabled) return ""; + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, config.timeoutMs); + try { + return await engine.retrieve(query, { + signal: controller.signal, + ...config.availableTools.length > 0 ? { availableTools: config.availableTools } : {} + }); + } catch { + return ""; + } finally { + clearTimeout(timer); + } +} + +// src/hook.ts +async function readStdin(stream = process.stdin) { + if (stream.isTTY) return ""; + const chunks = []; + stream.setEncoding("utf8"); + for await (const chunk of stream) chunks.push(String(chunk)); + return chunks.join(""); +} +function queryOf(payload) { + return typeof payload.prompt === "string" ? payload.prompt.trim() : ""; +} +function selectedSkills(block) { + return [...block.matchAll(/^### Skill: (.+?)\s+\[([^/\]]+)\//gm)].map((match) => `${match[1]}[${match[2]}]`); +} +function resultFor(block) { + return block ? { + continue: true, + hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: block } + } : { continue: true }; +} +function log(config, entry) { + if (!config.logPath) return; + try { + mkdirSync2(dirname2(config.logPath), { recursive: true }); + appendFileSync(config.logPath, `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry })} +`); + } catch { + } +} +async function runTurn(input, deps = {}) { + const config = deps.config ?? loadConfig(readConfigDocument()); + const startedAt = Date.now(); + let payload = {}; + try { + const parsed = JSON.parse(input || "{}"); + if (parsed && typeof parsed === "object") payload = parsed; + } catch { + } + const query = queryOf(payload); + let block = ""; + let failure = null; + const sourceDiagnostics = []; + if (query) { + try { + block = await (deps.retrieveFn ?? retrieveForTurn)( + query, + config, + {}, + (diagnostic) => { + sourceDiagnostics.push(diagnostic); + } + ); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + } + log(config, { + prompt: query.slice(0, 120), + model: payload.model ?? null, + agent_type: payload.agent_type ?? null, + skills: selectedSkills(block), + injected_chars: block.length, + elapsed_ms: Date.now() - startedAt, + sources: sourceDiagnostics, + error: failure + }); + return resultFor(block); +} +async function main() { + let result = { continue: true }; + try { + result = await runTurn(await readStdin()); + } catch { + } + process.stdout.write(JSON.stringify(result), () => { + process.exit(0); + }); +} +var invokedDirectly = process.argv[1] !== void 0 && /hook\.(mjs|ts|js)$/.test(process.argv[1]); +if (invokedDirectly) void main(); +export { + log, + queryOf, + readStdin, + resultFor, + runTurn, + selectedSkills +}; diff --git a/skillcorpus_plugin/scripts/verify_release_versions.py b/skillcorpus_plugin/scripts/verify_release_versions.py index 74f169c..c44578b 100644 --- a/skillcorpus_plugin/scripts/verify_release_versions.py +++ b/skillcorpus_plugin/scripts/verify_release_versions.py @@ -38,6 +38,9 @@ def text_version(path: str, pattern: str) -> str: expected_source = (ROOT / "plugin-workbuddy").resolve() if source != expected_source: raise SystemExit(f"WorkBuddy marketplace source must resolve to {expected_source}, got {source}") +for required in (source / "dist/hook.mjs", source / "hooks/hooks.json"): + if not required.is_file(): + raise SystemExit(f"WorkBuddy marketplace is missing required runtime file: {required}") plugin_manifest = json.loads((source / ".codebuddy-plugin/plugin.json").read_text(encoding="utf-8")) if plugin_manifest.get("name") != entry["name"]: raise SystemExit("WorkBuddy marketplace and plugin manifest names disagree") From 9864c478740af9d02e7cce0de6015df0ce7669fa Mon Sep 17 00:00:00 2001 From: yao pengfei Date: Wed, 26 Aug 2026 09:37:12 +0000 Subject: [PATCH 6/8] docs(workbuddy): use standard marketplace install --- skillcorpus_plugin/INSTALL.agent.md | 4 +- skillcorpus_plugin/README.md | 2 +- skillcorpus_plugin/README.zh.md | 2 +- .../plugin-workbuddy/INSTALL.agent.md | 132 ++++++------------ skillcorpus_plugin/plugin-workbuddy/README.md | 61 ++++---- 5 files changed, 78 insertions(+), 123 deletions(-) diff --git a/skillcorpus_plugin/INSTALL.agent.md b/skillcorpus_plugin/INSTALL.agent.md index 1f0978d..5152950 100644 --- a/skillcorpus_plugin/INSTALL.agent.md +++ b/skillcorpus_plugin/INSTALL.agent.md @@ -38,8 +38,8 @@ path and command below starts there. ## WorkBuddy -WorkBuddy installs are file-level marketplace surgery with their own -playbook: follow [`plugin-workbuddy/INSTALL.agent.md`](plugin-workbuddy/INSTALL.agent.md) +WorkBuddy installs through its standard plugin marketplace. Its own playbook +covers discovery and restart verification: follow [`plugin-workbuddy/INSTALL.agent.md`](plugin-workbuddy/INSTALL.agent.md) step by step — do not improvise a WorkBuddy install from this file. The same rules apply there, plus two stricter ones it states: report each step as you finish it, and never route around a failed step. diff --git a/skillcorpus_plugin/README.md b/skillcorpus_plugin/README.md index 845be33..8f19586 100644 --- a/skillcorpus_plugin/README.md +++ b/skillcorpus_plugin/README.md @@ -39,7 +39,7 @@ The playbook it follows is [`INSTALL.agent.md`](INSTALL.agent.md) — human-read | Your host | Do this | Details | | --- | --- | --- | -| **WorkBuddy** | build `plugin-workbuddy`, register it as a marketplace, enable — file-level steps an agent does well: paste the prompt in its README | [plugin-workbuddy](plugin-workbuddy#install--paste-this-to-workbuddy) | +| **WorkBuddy** | add `EverMind-AI/SkillCorpus` in the standard plugin marketplace, install **Skill Search**, then restart | [plugin-workbuddy](plugin-workbuddy#install) | | **Hermes** | `pip install ./engine-python && cp -r plugin-hermes "$HERMES_HOME/plugins/skillsearch" && hermes memory setup` | [plugin-hermes](plugin-hermes#install) | | **OpenClaw** | `npm install --prefix plugin-openclaw && npm run --prefix plugin-openclaw build`, then two keys in `openclaw.json` | [plugin-openclaw](plugin-openclaw#install) | | **DeepSeek Harness** | copy `engine-typescript/` to `packages/skill/skill-search/`, add a `cordis.yml` row | [engine-typescript](engine-typescript#where-this-goes) | diff --git a/skillcorpus_plugin/README.zh.md b/skillcorpus_plugin/README.zh.md index b9d2259..3365f97 100644 --- a/skillcorpus_plugin/README.zh.md +++ b/skillcorpus_plugin/README.zh.md @@ -33,7 +33,7 @@ this directory — use the absolute form for read_file / exec. | 你的宿主 | 操作 | 详情 | | --- | --- | --- | -| **WorkBuddy** | 构建 `plugin-workbuddy`、注册为 marketplace、启用——文件级步骤,agent 干最合适:把它 README 里的 prompt 粘给 WorkBuddy | [plugin-workbuddy](https://github.com/EverMind-AI/SkillCorpus/blob/main/skillcorpus_plugin/plugin-workbuddy/README.md#install--paste-this-to-workbuddy) | +| **WorkBuddy** | 在标准插件市场添加 `EverMind-AI/SkillCorpus`,安装 **Skill Search**,然后重启 | [plugin-workbuddy](https://github.com/EverMind-AI/SkillCorpus/blob/main/skillcorpus_plugin/plugin-workbuddy/README.md#install) | | **Hermes** | `pip install ./engine-python && cp -r plugin-hermes "$HERMES_HOME/plugins/skillsearch" && hermes memory setup` | [plugin-hermes](https://github.com/EverMind-AI/SkillCorpus/blob/main/skillcorpus_plugin/plugin-hermes/README.md#install) | | **OpenClaw** | `npm install --prefix plugin-openclaw && npm run --prefix plugin-openclaw build`,再往 `openclaw.json` 加两个键 | [plugin-openclaw](https://github.com/EverMind-AI/SkillCorpus/blob/main/skillcorpus_plugin/plugin-openclaw/README.md#install) | | **DeepSeek Harness** | 把 `engine-typescript/` 拷到 `packages/skill/skill-search/`,`cordis.yml` 加一行 | [engine-typescript](https://github.com/EverMind-AI/SkillCorpus/blob/main/skillcorpus_plugin/engine-typescript/README.md#where-this-goes) | diff --git a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md index 2e7b561..e186ed1 100644 --- a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md +++ b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md @@ -40,58 +40,26 @@ path and command below starts there. ## WorkBuddy -WorkBuddy loads plugins, not processes: the seam is a `UserPromptSubmit` -hook the plugin declares, spawned once per turn. Three host behaviours are -load-bearing, all established against 5.3.13 by experiment: - -- a failed hook **fails the whole turn** (`HookBlockedError`), so the shipped - entry never exits non-zero; -- the injected block never reaches the transcript, so the plugin keeps its - own log; -- the panel's directory-sourced marketplaces install without writing - `installed_plugins.json`, and such plugins **stop loading after a - restart** — which is why the steps below write the install records - directly instead of using the panel. - -Build first (the marketplace copy ships `dist/`; a source checkout must -build it): - -```bash -npm install --prefix plugin-workbuddy -npm run --prefix plugin-workbuddy build # produces dist/hook.mjs -``` - -Before going further, confirm `plugin-workbuddy/dist/hook.mjs` and -`plugin-workbuddy/hooks/hooks.json` both exist — missing either, stop and -report. - -Then, with the user's go-ahead, perform the file-level install. `` -is `version` from `plugin-workbuddy/.codebuddy-plugin/plugin.json`. -`` is the marketplace name from the repository root -`../.codebuddy-plugin/marketplace.json`; for this repository it is -`skillcorpus`. Read these values, never invent them. - -1. **Back up** `~/.workbuddy-ai/settings.json`, - `~/.workbuddy-ai/plugins/installed_plugins.json` and - `~/.workbuddy-ai/plugins/known_marketplaces.json`. -2. **Register the marketplace** in `known_marketplaces.json`: add a key - `` shaped like the existing `workbuddy-builtin` entry, with - `type` `"git"`, `source` shaped - `{"source": "git", "url": "", "path": ""}`, - and `installLocation` naming the checkout's absolute path. Update an - existing key rather than duplicating it; touch no other key. -3. **Copy into the cache**: the `plugin-workbuddy/` directory (at minimum - `.codebuddy-plugin/`, `hooks/`, `dist/`) to - `~/.workbuddy-ai/plugins/cache//skillsearch//`. -4. **Record the install** in `installed_plugins.json` under - `"skillsearch@"`: an array holding one object shaped like the - existing entries — `scope` `"user"`, `installPath` (the step-3 - directory), `version`, `installedAt`, `lastUpdated` (ISO 8601, now). -5. **Enable** in `settings.json`: add `"skillsearch@": true` to - `enabledPlugins`. Touch nothing else in that file — not `sandbox`, not - a `hooks` key (the plugin declares its own hook), not other plugins' - entries. -6. Tell the user to quit and reopen WorkBuddy. +WorkBuddy discovers this repository through the root +`.codebuddy-plugin/marketplace.json`. Use the host's marketplace installer; +do not edit `settings.json`, `installed_plugins.json`, or +`known_marketplaces.json` by hand. + +1. Open **Experts · Skills · Connectors → Skills → Plugin Marketplace**. +2. Add `EverMind-AI/SkillCorpus` as a marketplace source. A git URL or release + zip works; do not use a local directory, which is not persistent across a + restart on WorkBuddy 5.3.13. +3. Before installation, confirm `CODEBUDDY_DISABLE_EXTENDED_PLUGIN_HOOKS` is not + `1` in the environment that launches WorkBuddy. If it is, extended plugin + hooks are disabled globally: clear it from that launcher and fully restart + WorkBuddy before continuing. +4. In the `skillcorpus` marketplace, install and enable **Skill Search** + (`skillsearch`, version read from its plugin manifest). +5. Fully quit and reopen WorkBuddy. + +If the marketplace or plugin is not discovered, stop and report the exact UI +error and host logs. Do not route around discovery by copying files into the +cache or editing WorkBuddy's internal JSON records. ## Network and optional model configuration @@ -115,43 +83,29 @@ from their current path. ## Verification — definition of done -1. **Plugin discovered**: after the restart, the install directory gains an - `.in_use/` marker written by the host, and the plugin appears in - the panel as installed and enabled. -2. **Create a test skill**: - -```bash -mkdir -p ~/.workbuddy-ai/skills/pdf-tables -printf -- '---\nname: pdf-tables\ndescription: Extract tables from PDF documents, scanned or native, into CSV.\n---\nUse camelot for native PDFs.\n' > ~/.workbuddy-ai/skills/pdf-tables/SKILL.md -``` - -3. **Positive probe**: in a fresh WorkBuddy task ask *"扫描版 PDF 发票里的 - 表格怎么提取?"* — then confirm the turn's line in - `~/.workbuddy-ai/plugins/data/skillsearch-/skillsearch.log` - names `pdf-tables[local]`. -4. **Negative probe**: ask *"今天天气怎么样?"* — confirm that turn's line - shows `injected_chars: 0`. -5. **Report**: list every file you created or edited (with the diffs), the - probe results, and how to undo everything (the section below). - -If step 3 fails: confirm the restart actually happened (stale `.in_use` -pids from before the restart mean it did not); confirm the skill landed -under a scanned directory (`skillsDirs` defaults to -`~/.workbuddy-ai/skills` and `~/.workbuddy-ai/plugins/cache`); then read -the log's `error` field for the turn. Report what you find rather than -retrying blindly. +Installation is complete only after these checks pass: + +1. **Discovered after restart:** the plugin still appears installed and enabled, + and its install directory contains a live `.in_use/` marker. +2. **Hook runs:** create a fresh task and ask a skill-related question. Confirm + the new line in + `~/.workbuddy-ai/plugins/data/skillsearch-skillcorpus/skillsearch.log` + records the turn and its selected skills/source diagnostics. +3. **No-match stays empty:** ask `zxqv-7319,请只原样回复这段字符串` and confirm + the log records `injected_chars: 0`. Do not use a weather question: the + public marketplaces contain real weather skills. + +If a check fails, report the failed step, the log entry, and the marketplace +and plugin versions. Do not invoke `hook.mjs` by hand; that tests the bundle, +not whether WorkBuddy loaded it. ## Uninstall -When the user asks to remove skillsearch: - -1. Remove the `"skillsearch@"` keys from `enabledPlugins` in - `settings.json` and from `installed_plugins.json`, the `` entry - from `known_marketplaces.json`, and the cache directory - `~/.workbuddy-ai/plugins/cache//skillsearch/`. -2. Offer to delete the plugin's data directory - (`~/.workbuddy-ai/plugins/data/skillsearch-/` — config, log, - index cache) and the bundle cache - (`~/.workbuddy-ai/skillsearch-bundles/`). -3. Restore or delete the `.bak-skillsearch` backups per the user's call. -4. Show the diffs, same rule as installing. +1. Uninstall **Skill Search** from the `skillcorpus` marketplace in WorkBuddy. +2. Remove the marketplace source if no other SkillCorpus plugin uses it. +3. Fully quit and reopen WorkBuddy, then confirm the plugin no longer has a + live `.in_use/` marker. +4. Offer to delete its state directory + (`~/.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. diff --git a/skillcorpus_plugin/plugin-workbuddy/README.md b/skillcorpus_plugin/plugin-workbuddy/README.md index 11683f5..eb4eb9f 100644 --- a/skillcorpus_plugin/plugin-workbuddy/README.md +++ b/skillcorpus_plugin/plugin-workbuddy/README.md @@ -101,33 +101,34 @@ skill that runs here sat unread in seat three. EverMind SkillHub and skillhub.cn (`skillhubCnEndpoint`) are enabled by default at their public API URLs; set any endpoint to an empty string to disable that source. -## Install — paste this to WorkBuddy - -WorkBuddy is itself an agent, and this install is file-level work an agent -does well. Paste this into a WorkBuddy session (fill in the plugin source): - -> 帮我在 WorkBuddy 里安装 skillsearch 插件。它是一个 UserPromptSubmit hook, -> 每轮对话前按我的提问检索本机与远端的 skill,把最相关的注入上下文。 -> -> 插件源(git 地址或本地打包目录):`<源地址>` -> -> 严格按照源里 `skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md` 的步骤执行:每做完一步 -> 简短汇报结果;任何一步失败就停下来告诉我,不要跳过,也不要自己想办法 -> 绕过;改任何配置文件之前,先做带时间戳的备份,并把要做的改动展示给我; -> 市场名和版本号从文件里读,不要自己编。装完后按剧本的自检清单逐项验证, -> 把结果和所有改动过的文件汇报给我。 - -The playbook it follows is [`INSTALL.agent.md`](INSTALL.agent.md) — backup, -marketplace registration, cache copy, install record, enablement, then a -positive and a negative retrieval probe. The same prompt in English works; -the playbook is English. - -### Packaging notes - -The panel route (**Experts · Skills · Connectors → Skills → 插件市场**) -accepts a local directory, `owner/repo`, a git URL or a zip — but **use git -or zip, not a local directory**. A directory-sourced marketplace installs -the plugin without writing an entry to `installed_plugins.json` or copying -it into `plugins/cache/`, and after a restart its hooks stop loading until -the plugin panel is opened again. Observed on 5.3.13 — and the reason the -playbook writes the install records directly. +## Install + +Use WorkBuddy's standard marketplace flow: + +1. Open **Experts · Skills · Connectors → Skills → Plugin Marketplace**. +2. Add `EverMind-AI/SkillCorpus` (or its git URL/release zip) as a marketplace + source. Do not use a local directory; it is not persistent across restart on + WorkBuddy 5.3.13. +3. Confirm `CODEBUDDY_DISABLE_EXTENDED_PLUGIN_HOOKS` is not `1` in the + environment that launches WorkBuddy; that value disables every extended + plugin hook. Clear it from the launcher and fully restart before continuing. +4. Open the `skillcorpus` marketplace and install and enable **Skill Search**. +5. Fully quit and reopen WorkBuddy. + +Do not manually edit WorkBuddy's internal JSON files or copy the plugin into +its cache. The root `.codebuddy-plugin/marketplace.json` is the discovery entry, +and WorkBuddy owns the install records. + +To delegate the installation to WorkBuddy, paste: + +> Install Skill Search from the `EverMind-AI/SkillCorpus` marketplace using +> WorkBuddy's standard plugin marketplace. Do not manually edit +> `settings.json`, `installed_plugins.json`, or `known_marketplaces.json`, and +> do not copy files into the plugin cache. Fully restart WorkBuddy, then follow +> `skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md` to verify the live +> `.in_use/` marker and one real hook turn. Stop and report any discovery +> failure instead of bypassing it. + +Installation is not complete until the restart and hook checks in +[`INSTALL.agent.md`](INSTALL.agent.md) pass. A plugin card alone does not prove +the host loaded its hook. From c2f6dc801f5d6d7925007d4bef5b97973f1116db Mon Sep 17 00:00:00 2001 From: yao pengfei Date: Thu, 27 Aug 2026 02:52:19 +0000 Subject: [PATCH 7/8] docs(workbuddy): make in-use marker optional --- skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md | 11 +++++++---- skillcorpus_plugin/plugin-workbuddy/README.md | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md index e186ed1..4632650 100644 --- a/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md +++ b/skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md @@ -85,8 +85,10 @@ from their current path. Installation is complete only after these checks pass: -1. **Discovered after restart:** the plugin still appears installed and enabled, - and its install directory contains a live `.in_use/` marker. +1. **Discovered after restart:** the plugin still appears installed and enabled. + Some WorkBuddy builds create a live `.in_use/` marker in the install + directory; treat that marker as optional diagnostic evidence, not as a + requirement. 2. **Hook runs:** create a fresh task and ask a skill-related question. Confirm the new line in `~/.workbuddy-ai/plugins/data/skillsearch-skillcorpus/skillsearch.log` @@ -103,8 +105,9 @@ not whether WorkBuddy loaded it. 1. Uninstall **Skill Search** from the `skillcorpus` marketplace in WorkBuddy. 2. Remove the marketplace source if no other SkillCorpus plugin uses it. -3. Fully quit and reopen WorkBuddy, then confirm the plugin no longer has a - live `.in_use/` marker. +3. Fully quit and reopen WorkBuddy, then confirm the plugin is no longer + installed or enabled and that a fresh task produces no new Skill Search log + entry. A stale or absent `.in_use/` marker is not authoritative. 4. Offer to delete its state directory (`~/.workbuddy-ai/plugins/data/skillsearch-skillcorpus/`) and bundle cache (`~/.workbuddy-ai/skillsearch-bundles/`). These contain only plugin cache, diff --git a/skillcorpus_plugin/plugin-workbuddy/README.md b/skillcorpus_plugin/plugin-workbuddy/README.md index eb4eb9f..641e833 100644 --- a/skillcorpus_plugin/plugin-workbuddy/README.md +++ b/skillcorpus_plugin/plugin-workbuddy/README.md @@ -125,9 +125,10 @@ To delegate the installation to WorkBuddy, paste: > WorkBuddy's standard plugin marketplace. Do not manually edit > `settings.json`, `installed_plugins.json`, or `known_marketplaces.json`, and > do not copy files into the plugin cache. Fully restart WorkBuddy, then follow -> `skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md` to verify the live -> `.in_use/` marker and one real hook turn. Stop and report any discovery -> failure instead of bypassing it. +> `skillcorpus_plugin/plugin-workbuddy/INSTALL.agent.md` to verify one real hook +> turn and its Skill Search log entry. A live `.in_use/` marker is optional +> diagnostic evidence because some WorkBuddy builds do not create one. Stop and +> report any discovery failure instead of bypassing it. Installation is not complete until the restart and hook checks in [`INSTALL.agent.md`](INSTALL.agent.md) pass. A plugin card alone does not prove From e2dc4f19e7febd529650792e8e212753f3a86405 Mon Sep 17 00:00:00 2001 From: yao pengfei Date: Thu, 27 Aug 2026 03:19:52 +0000 Subject: [PATCH 8/8] build(workbuddy): refresh runtime after PathGuard merge --- .../plugin-workbuddy/dist/hook.mjs | 95 ++++++++++++++++--- 1 file changed, 81 insertions(+), 14 deletions(-) diff --git a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs index 8049a26..f6791fe 100755 --- a/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs +++ b/skillcorpus_plugin/plugin-workbuddy/dist/hook.mjs @@ -2,7 +2,7 @@ // src/hook.ts import { appendFileSync, mkdirSync as mkdirSync2 } from "node:fs"; -import { dirname as dirname2 } from "node:path"; +import { dirname as dirname3 } from "node:path"; // src/config.ts import { readFileSync } from "node:fs"; @@ -68,7 +68,8 @@ var DEFAULTS = { hubWeight: 0.85, rrfK: 10, indexCachePath: join(DATA_DIR, "index-cache.json"), - logPath: join(DATA_DIR, "skillsearch.log") + logPath: join(DATA_DIR, "skillsearch.log"), + resolvePlaceholders: false }; var ENV_KEYS = { skillsDirs: "SKILLSEARCH_SKILLS_DIRS", @@ -92,7 +93,8 @@ var ENV_KEYS = { hubWeight: "SKILLSEARCH_HUB_WEIGHT", rrfK: "SKILLSEARCH_RRF_K", indexCachePath: "SKILLSEARCH_INDEX_CACHE_PATH", - logPath: "SKILLSEARCH_LOG_PATH" + logPath: "SKILLSEARCH_LOG_PATH", + resolvePlaceholders: "SKILLSEARCH_RESOLVE_PLACEHOLDERS" }; function asList(value) { if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean); @@ -165,7 +167,8 @@ function loadConfig(document, env = process.env) { hubWeight: asNumber(pick("hubWeight")) ?? DEFAULTS.hubWeight, rrfK: asNumber(pick("rrfK")) ?? DEFAULTS.rrfK, indexCachePath: asText(pick("indexCachePath")) ?? DEFAULTS.indexCachePath, - logPath: asText(pick("logPath")) ?? DEFAULTS.logPath + logPath: asText(pick("logPath")) ?? DEFAULTS.logPath, + resolvePlaceholders: asBoolean(pick("resolvePlaceholders")) ?? DEFAULTS.resolvePlaceholders }; } @@ -391,7 +394,7 @@ function parseResponse(content) { // ../engine-typescript/src/refs.ts import { existsSync, statSync } from "node:fs"; -import { join as join2 } from "node:path"; +import { dirname, join as join2 } from "node:path"; var BUNDLED_DIRS = ["references", "scripts", "assets", "examples"]; var MD_LINK_RE = new RegExp( String.raw`\[([^\]]+)\]\((?:\.{0,2}/)?((?:${BUNDLED_DIRS.join("|")})/[^)\s]+)\)`, @@ -449,6 +452,30 @@ function firstIndexOfAny(text, needles) { const found = needles.map((n) => text.indexOf(n)).filter((i) => i !== -1); return found.length === 0 ? -1 : Math.min(...found); } +var PLACEHOLDER_RE = /\{\{([A-Z_]+)(?::([A-Za-z0-9._-]+))?\}\}/g; +function resolvePlaceholders(body, skillDir, runtime = {}) { + if (!body || !body.includes("{{")) return body; + const sd = skillDir || void 0; + if (sd) { + body = body.replaceAll("{{SKILL_DIR}}/", `${sd.replace(/\/+$/, "")}/`); + body = body.replaceAll("{{SKILL_DIR}}", sd); + } + return body.replace(PLACEHOLDER_RE, (match, name, arg) => { + if (name === "SKILL_DIR") { + return sd && arg && arg !== "." && arg !== ".." ? join2(dirname(sd), arg) : match; + } + if (name === "AGENT_STATE_DIR") { + return runtime.stateDir || runtime.outputDir || match; + } + if (name === "HOME") { + return runtime.homeDir || runtime.outputDir || match; + } + if (name === "OUTPUT_DIR") { + return runtime.outputDir || match; + } + return match; + }); +} // ../engine-typescript/src/rewriter.ts var REWRITE_PROMPT = `Rewrite the following user query for skill retrieval. Remove noise (paths, IDs, timestamps, boilerplate). Keep task type, domain, required capabilities, and key technical details. Do NOT answer or solve the query \u2014 only rewrite it. @@ -515,6 +542,8 @@ var SkillSearchEngine = class { dedupBy; heading; refs; + placeholders; + runtime; constructor(parts, options = {}) { this.sources = parts.sources; this.rewriter = parts.rewriter; @@ -530,6 +559,12 @@ var SkillSearchEngine = class { this.dedupBy = options.dedupBy ?? "qualifiedId"; this.heading = options.heading ?? "# Skills"; this.refs = options.resolveRefs ?? true; + this.placeholders = options.resolvePlaceholders ?? false; + this.runtime = { + outputDir: options.outputDir, + homeDir: options.homeDir, + stateDir: options.stateDir + }; } /** Whether anything is configured to search. */ get enabled() { @@ -602,7 +637,7 @@ var SkillSearchEngine = class { if (this.gate) { hits = await this.gate.filter(query, hits, options.availableTools, signal); } - return this.resolveHitRefs(hits.slice(0, this.topK), signal); + return this.resolvePlaceholders(await this.resolveHitRefs(hits.slice(0, this.topK), signal)); } diagnose(diagnostic) { try { @@ -666,6 +701,26 @@ var SkillSearchEngine = class { return body === current.content ? current : { ...current, content: body }; })); } + /** + * Fill PathGuard placeholders (`{{SKILL_DIR}}`, `{{HOME}}`, …) per agent. + * + * Unlike `resolveLocalRefs` / `resolveHitRefs` this never touches the + * filesystem and is not gated by `resolveRefs`: a placeholder already names + * its target, and only the host knows it. It runs last, once every surviving + * hit has its `skillDir` settled. + */ + resolvePlaceholders(hits) { + if (!this.placeholders) return hits; + return hits.map((hit) => { + const content = hit.content; + const source = String(hit.meta.source ?? ""); + const trusted = ["local", "builtin", "hub"].includes(source) || hit.meta.pathguardProcessed === true; + if (!trusted || !content || !content.includes("{{")) return hit; + const skillDir = typeof hit.meta.skillDir === "string" ? hit.meta.skillDir : void 0; + const body = resolvePlaceholders(content, skillDir, this.runtime); + return body === content ? hit : { ...hit, content: body }; + }); + } /** Fill in bodies for hits a source returned as metadata only. */ async hydrateBodies(hits, signal) { const fetchBody = this.fetchBody; @@ -1417,7 +1472,7 @@ function errorMessage2(error) { // src/cached-local-source.ts import { mkdirSync, readFileSync as readFileSync2, readdirSync, renameSync, statSync as statSync2, writeFileSync } from "node:fs"; -import { dirname, join as join7 } from "node:path"; +import { dirname as dirname2, join as join7 } from "node:path"; // ../engine-typescript/src/local-source.ts import { readFile as readFile2, readdir as readdir2 } from "node:fs/promises"; @@ -1679,7 +1734,7 @@ var CachedLocalSkillSource = class extends LocalSkillSource { } write(file) { try { - mkdirSync(dirname(this.cachePath), { recursive: true }); + mkdirSync(dirname2(this.cachePath), { recursive: true }); const temp = `${this.cachePath}.${process.pid}.tmp`; writeFileSync(temp, JSON.stringify(file)); renameSync(temp, this.cachePath); @@ -1749,7 +1804,7 @@ function expandHome(path, home = homedir2()) { if (path.startsWith("~/")) return join8(home, path.slice(2)); return path; } -function buildEngine(config, onDiagnostic) { +function buildEngine(config, onDiagnostic, workspaceDir) { const sources = []; const dirs = config.skillsDirs.map((dir) => expandHome(dir)).filter(Boolean); if (dirs.length > 0) { @@ -1828,14 +1883,25 @@ function buildEngine(config, onDiagnostic) { } } : {} }, - { topK: config.topK, gatePool: config.gatePool, rrfK: config.rrfK } + { + topK: config.topK, + gatePool: config.gatePool, + rrfK: config.rrfK, + // PathGuard placeholders' per-agent facts. WorkBuddy's own config root + // 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"), + resolvePlaceholders: config.resolvePlaceholders + } ); } -async function retrieveForTurn(query, config, deps = {}, onDiagnostic) { +async function retrieveForTurn(query, config, deps = {}, onDiagnostic, workspaceDir) { if (!query.trim()) return ""; let engine; try { - engine = (deps.buildEngineFn ?? buildEngine)(config, onDiagnostic); + engine = (deps.buildEngineFn ?? buildEngine)(config, onDiagnostic, workspaceDir); } catch { return ""; } @@ -1879,7 +1945,7 @@ function resultFor(block) { function log(config, entry) { if (!config.logPath) return; try { - mkdirSync2(dirname2(config.logPath), { recursive: true }); + mkdirSync2(dirname3(config.logPath), { recursive: true }); appendFileSync(config.logPath, `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry })} `); } catch { @@ -1906,7 +1972,8 @@ async function runTurn(input, deps = {}) { {}, (diagnostic) => { sourceDiagnostics.push(diagnostic); - } + }, + payload.cwd ); } catch (error) { failure = error instanceof Error ? error.message : String(error);