From 8f61a67957b3732e65a8faf89d22d95a949e5385 Mon Sep 17 00:00:00 2001 From: "daibo@machinepulse.ai" Date: Tue, 28 Apr 2026 12:40:29 +0800 Subject: [PATCH 1/4] feat(openclaw-plugin): bridge W2A sensors to OpenClaw via runEmbeddedAgent Native OpenClaw plugin that loads enabled sensors from ~/.world2agent/sensors.json in-process, queues each emitted signal per sensor session, and dispatches it through api.runtime.agent.runEmbeddedAgent so a fresh agent turn handles every signal. Verified end-to-end against a local OpenClaw 2026.4.15 gateway with @world2agent/sensor-hackernews + openrouter/moonshotai/kimi-k2.6. Hard contracts enforced at register(): - agents.defaults.contextInjection must equal "continuation-skip"; missing setting causes plugin start to throw with the exact field path to fix. Same check fires up front in `openclaw world2agent sensor add` so the install path fails before mutating manifest state. - api.runtime.agent.runEmbeddedAgent must be present; surfaces as an M0 spike message rather than a silent no-op. - register() stays synchronous; OpenClaw's plugin loader drops registers that return a Promise. Async startup work runs as fire-and-forget. Other notable bits: - Resolves provider/model from agents.defaults.model.primary because runEmbeddedAgent itself ignores the operator default and falls back to openai/gpt-5.4 when nothing is passed. - Pins sessionId per sensor (`w2a-`, sanitised against SAFE_SESSION_ID_RE) so continuation turns reuse the same transcript; sessionKey carries the colon-namespaced lane for run serialisation. - HttpDispatcher (POST /w2a/ingest, HMAC + X-Request-ID dedup) and the IsolatedRunner skeleton are wired so out-of-process sensors can come online without re-architecting the plugin. - Skill-routing default is the dedicated W2A agent's agents.list[].skills allowlist; prompt-prefix `Use skill: ` is the M1 fallback when no allowlist is configured. Tests: 9 vitest cases covering manifest read/write, dispatcher serialisation + prompt-prefix fallback, HttpDispatcher HMAC/dedup, contextInjection startup throw, and synchronous register. Co-Authored-By: Claude Opus 4.7 (1M context) --- openclaw-plugin/.gitignore | 4 + openclaw-plugin/README.md | 54 + openclaw-plugin/openclaw.plugin.json | 66 + openclaw-plugin/package-lock.json | 1322 +++++++++++++++++ openclaw-plugin/package.json | 69 + .../skills/world2agent-manage/SKILL.md | 79 + .../skills/world2agent-manage/scripts/add.sh | 5 + .../skills/world2agent-manage/scripts/list.sh | 5 + .../world2agent-manage/scripts/reload.sh | 4 + .../world2agent-manage/scripts/remove.sh | 5 + openclaw-plugin/src/cli.ts | 220 +++ openclaw-plugin/src/config.ts | 133 ++ openclaw-plugin/src/dispatch.ts | 263 ++++ openclaw-plugin/src/index.ts | 7 + openclaw-plugin/src/install.ts | 223 +++ openclaw-plugin/src/isolated.ts | 240 +++ openclaw-plugin/src/manifest.ts | 176 +++ .../src/openclaw/plugin-sdk/plugin-entry.ts | 6 + .../src/openclaw/plugin-sdk/types.ts | 135 ++ openclaw-plugin/src/paths.ts | 93 ++ openclaw-plugin/src/plugin.ts | 117 ++ openclaw-plugin/src/prompt.ts | 44 + openclaw-plugin/src/runner/bin.ts | 129 ++ openclaw-plugin/src/runner/config-stream.ts | 4 + openclaw-plugin/src/runner/http-transport.ts | 64 + openclaw-plugin/src/runtime.ts | 160 ++ openclaw-plugin/src/supervisor/shared.ts | 117 ++ openclaw-plugin/src/types.ts | 104 ++ .../test/context-injection.test.ts | 52 + openclaw-plugin/test/dispatch.test.ts | 146 ++ openclaw-plugin/test/manifest.test.ts | 89 ++ .../test/supervisor-shared.test.ts | 84 ++ openclaw-plugin/tsconfig.json | 19 + openclaw-plugin/vitest.config.ts | 9 + 34 files changed, 4247 insertions(+) create mode 100644 openclaw-plugin/.gitignore create mode 100644 openclaw-plugin/README.md create mode 100644 openclaw-plugin/openclaw.plugin.json create mode 100644 openclaw-plugin/package-lock.json create mode 100644 openclaw-plugin/package.json create mode 100644 openclaw-plugin/skills/world2agent-manage/SKILL.md create mode 100755 openclaw-plugin/skills/world2agent-manage/scripts/add.sh create mode 100755 openclaw-plugin/skills/world2agent-manage/scripts/list.sh create mode 100755 openclaw-plugin/skills/world2agent-manage/scripts/reload.sh create mode 100755 openclaw-plugin/skills/world2agent-manage/scripts/remove.sh create mode 100644 openclaw-plugin/src/cli.ts create mode 100644 openclaw-plugin/src/config.ts create mode 100644 openclaw-plugin/src/dispatch.ts create mode 100644 openclaw-plugin/src/index.ts create mode 100644 openclaw-plugin/src/install.ts create mode 100644 openclaw-plugin/src/isolated.ts create mode 100644 openclaw-plugin/src/manifest.ts create mode 100644 openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts create mode 100644 openclaw-plugin/src/openclaw/plugin-sdk/types.ts create mode 100644 openclaw-plugin/src/paths.ts create mode 100644 openclaw-plugin/src/plugin.ts create mode 100644 openclaw-plugin/src/prompt.ts create mode 100644 openclaw-plugin/src/runner/bin.ts create mode 100644 openclaw-plugin/src/runner/config-stream.ts create mode 100644 openclaw-plugin/src/runner/http-transport.ts create mode 100644 openclaw-plugin/src/runtime.ts create mode 100644 openclaw-plugin/src/supervisor/shared.ts create mode 100644 openclaw-plugin/src/types.ts create mode 100644 openclaw-plugin/test/context-injection.test.ts create mode 100644 openclaw-plugin/test/dispatch.test.ts create mode 100644 openclaw-plugin/test/manifest.test.ts create mode 100644 openclaw-plugin/test/supervisor-shared.test.ts create mode 100644 openclaw-plugin/tsconfig.json create mode 100644 openclaw-plugin/vitest.config.ts diff --git a/openclaw-plugin/.gitignore b/openclaw-plugin/.gitignore new file mode 100644 index 0000000..c9c9210 --- /dev/null +++ b/openclaw-plugin/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +*.tsbuildinfo + diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md new file mode 100644 index 0000000..266168d --- /dev/null +++ b/openclaw-plugin/README.md @@ -0,0 +1,54 @@ +# @world2agent/openclaw-plugin + +Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into embedded OpenClaw agent turns. + +The default path is in-process: enabled sensors are imported directly inside the plugin process and each signal is sent to `api.runtime.agent.runEmbeddedAgent(...)`. `isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. + +## Install + +1. Set the required OpenClaw agent config first: + + ```yaml + agents: + defaults: + contextInjection: continuation-skip + ``` + +2. Install dependencies and build this package: + + ```bash + cd world2agent-plugins/openclaw-plugin + pnpm install + pnpm build + ``` + +3. Add the plugin package to your OpenClaw plugin search/install path and enable `@world2agent/openclaw-plugin`. + +4. Use the registered CLI: + + ```bash + openclaw world2agent sensor list + openclaw world2agent sensor add @world2agent/sensor-hackernews --config-file ./hackernews.json + ``` + +## Scope + +- Reads and writes the W2A sensor manifest at `~/.world2agent/sensors.json` by default. +- Runs sensors in-process unless a sensor entry sets `isolated: true`. +- Reuses the Hermes runner/supervisor patterns instead of inventing a second isolation protocol. +- Uses a stable per-sensor embedded-agent session id: `w2a:`. +- Requires plugin config `ingestUrl` only when `isolated: true` sensors are used. + +## ContextInjection Prerequisite + +This plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"`. + +That check also runs before `openclaw world2agent sensor add`. There is no warning mode, no fallback mode, and no override flag. The design requires a hard failure because OpenClaw's default `"always"` setting would re-inject bootstrap on every sensor signal and silently turn high-frequency sensors into a token sink. + +## Relation To `hermes-sensor-bridge` + +`hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `runEmbeddedAgent(...)`. + +## Known M0 Spike + +`api.runtime.agent.runEmbeddedAgent(...)` from a third-party external plugin remains a live-install verification point. This package guards it defensively and throws a clear error if the runtime helper is absent, but a real OpenClaw install still has to confirm the end-to-end external-plugin path. diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json new file mode 100644 index 0000000..550e803 --- /dev/null +++ b/openclaw-plugin/openclaw.plugin.json @@ -0,0 +1,66 @@ +{ + "id": "world2agent", + "name": "World2Agent", + "description": "Run World2Agent sensors inside OpenClaw and dispatch signals into embedded agent turns.", + "skills": [ + "./skills/world2agent-manage" + ], + "commandAliases": [ + "world2agent" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "sensorsManifestPath": { + "type": "string", + "description": "Absolute or relative path to the World2Agent sensor manifest." + }, + "stateDir": { + "type": "string", + "description": "Directory for per-sensor FileSensorStore state." + }, + "sessionDir": { + "type": "string", + "description": "Directory for plugin-managed embedded-agent session files." + }, + "workspaceDir": { + "type": "string", + "description": "Workspace directory passed to runEmbeddedAgent when the OpenClaw runtime helper is unavailable." + }, + "ingestUrl": { + "type": "string", + "description": "Absolute URL for the plugin's /w2a/ingest route when isolated runners are enabled." + }, + "defaultAgentId": { + "type": "string", + "default": "world2agent", + "description": "Dedicated OpenClaw agent id whose skills allowlist should receive W2A skills." + }, + "provider": { + "type": "string", + "description": "Provider passed to runEmbeddedAgent (e.g. 'openai-codex' for OAuth, 'openai' for API key). When unset OpenClaw resolves from agent/global defaults." + }, + "model": { + "type": "string", + "description": "Model id passed to runEmbeddedAgent (e.g. 'gpt-5.4'). When unset OpenClaw resolves from agent/global defaults." + }, + "requestTimeoutMs": { + "type": "integer", + "minimum": 1, + "default": 120000, + "description": "Timeout passed to runEmbeddedAgent and isolated ingest POSTs." + }, + "ingestHmacSecretFile": { + "type": "string", + "description": "Path to the HMAC secret used by isolated runner ingest requests." + }, + "ingestDedupTtlMs": { + "type": "integer", + "minimum": 1000, + "default": 3600000, + "description": "How long X-Request-ID dedup entries stay in memory." + } + } + } +} diff --git a/openclaw-plugin/package-lock.json b/openclaw-plugin/package-lock.json new file mode 100644 index 0000000..a52177d --- /dev/null +++ b/openclaw-plugin/package-lock.json @@ -0,0 +1,1322 @@ +{ + "name": "@world2agent/openclaw-plugin", + "version": "0.0.0-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@world2agent/openclaw-plugin", + "version": "0.0.0-dev", + "license": "Apache-2.0", + "dependencies": { + "@world2agent/sdk": "file:../../world2agent-typescript-sdk", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@world2agent/sdk": { + "version": "0.1.0-alpha.1", + "resolved": "file:../../world2agent-typescript-sdk", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "zod": "^3.25.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json new file mode 100644 index 0000000..aa785e8 --- /dev/null +++ b/openclaw-plugin/package.json @@ -0,0 +1,69 @@ +{ + "name": "@world2agent/openclaw-plugin", + "version": "0.0.0-dev", + "description": "World2Agent native plugin for OpenClaw", + "license": "Apache-2.0", + "author": "MachinePulse Pte. Ltd.", + "homepage": "https://github.com/machinepulse-ai/world2agent", + "repository": { + "type": "git", + "url": "git+https://github.com/machinepulse-ai/world2agent-plugins.git", + "directory": "openclaw-plugin" + }, + "bugs": { + "url": "https://github.com/machinepulse-ai/world2agent-plugins/issues" + }, + "keywords": [ + "world2agent", + "w2a", + "openclaw", + "plugin", + "sensor" + ], + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc --build", + "clean": "rm -rf dist *.tsbuildinfo", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "pnpm run clean && pnpm run build" + }, + "dependencies": { + "@world2agent/sdk": "file:../../world2agent-typescript-sdk", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + }, + "files": [ + "dist", + "openclaw.plugin.json", + "skills", + "README.md" + ], + "openclaw": { + "extensions": [ + "./src/index.ts" + ], + "runtimeExtensions": [ + "./dist/index.js" + ] + }, + "publishConfig": { + "access": "public" + } +} + diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md new file mode 100644 index 0000000..69ca8a7 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -0,0 +1,79 @@ +--- +name: world2agent-manage +description: Manage World2Agent sensors for OpenClaw. Use when the user asks to install, list, remove, or inspect W2A sensors, or wants an outside-world source such as Hacker News, GitHub, RSS, calendars, or market feeds. +user-invocable: false +--- + +# World2Agent Sensor Management + +You manage the user's World2Agent sensors on this OpenClaw machine. + +All mutations go through the `openclaw world2agent` CLI. The shell scripts in +`scripts/` are thin wrappers around those commands. + +## Prerequisite + +Before adding sensors, OpenClaw must be configured with: + +```yaml +agents: + defaults: + contextInjection: continuation-skip +``` + +If that field is not set exactly, `openclaw world2agent sensor add` will fail on +purpose. Do not try to work around it. + +## List sensors + +Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/list.sh" +``` + +## Install a sensor + +1. Confirm the npm package name with the user. +2. Inspect the sensor package's `SETUP.md` to determine the config fields it needs. +3. Write a temporary JSON file containing the sensor config object only. +4. Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/add.sh" --config-file +``` + +Optional flags: + +- `--sensor-id ` if the user wants a non-default instance id. +- `--isolated` if the sensor should run out-of-process. + +Never invent credentials or secrets. Ask the user when the config requires them. + +## Remove a sensor + +Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/remove.sh" +``` + +Pass `--purge` only if the user explicitly wants the generated OpenClaw skill +directory removed too. + +## Reload sensors + +Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/reload.sh" +``` + +## Output style + +After each action, summarize: + +- which sensor ids were affected +- whether the reload succeeded +- any warnings or errors returned by the CLI + diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/add.sh b/openclaw-plugin/skills/world2agent-manage/scripts/add.sh new file mode 100755 index 0000000..7a38c22 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/add.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent sensor add "$@" + diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/list.sh b/openclaw-plugin/skills/world2agent-manage/scripts/list.sh new file mode 100755 index 0000000..bb42a74 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/list.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent sensor list "$@" + diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/reload.sh b/openclaw-plugin/skills/world2agent-manage/scripts/reload.sh new file mode 100755 index 0000000..6ed2b42 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/reload.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent reload "$@" diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/remove.sh b/openclaw-plugin/skills/world2agent-manage/scripts/remove.sh new file mode 100755 index 0000000..a85fb9d --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/remove.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent sensor remove "$@" + diff --git a/openclaw-plugin/src/cli.ts b/openclaw-plugin/src/cli.ts new file mode 100644 index 0000000..32705d6 --- /dev/null +++ b/openclaw-plugin/src/cli.ts @@ -0,0 +1,220 @@ +import { join } from "node:path"; +import { packageToSkillId } from "@world2agent/sdk"; +import { + assertContextInjectionCompatible, + loadEffectiveOpenClawConfig, + upsertDedicatedAgentSkillAllowlist, +} from "./config.js"; +import { ensurePackageInstalled, loadConfigFile, maybeUninstallPackage, runCommand, writeGeneratedSkill } from "./install.js"; +import { + defaultSensorId, + readManifest, + removePath, + removeSensorEntry, + upsertSensorEntry, + writeManifest, +} from "./manifest.js"; +import type { + OpenClawConfig, + OpenClawPluginApi, +} from "./openclaw/plugin-sdk/types.js"; +import type { RequiredWorld2AgentPluginConfig, SensorEntry, World2AgentPaths } from "./types.js"; + +export interface World2AgentCliServices { + api: OpenClawPluginApi; + paths: World2AgentPaths; + pluginConfig: RequiredWorld2AgentPluginConfig; +} + +export function registerWorld2AgentCli(services: World2AgentCliServices): void { + services.api.registerCli?.( + ({ program }) => { + const root = program.command("world2agent").description("Manage World2Agent sensors"); + const sensor = root.command("sensor").description("Manage sensor instances"); + + sensor + .command("list") + .description("List configured sensors") + .action(async () => { + printJson(await runListCommand(services)); + }); + + sensor + .command("add ") + .description("Install and configure a sensor") + .option("--sensor-id ", "Override the default sensor id") + .option("--config-file ", "Path to the sensor config JSON file") + .option("--isolated", "Run this sensor out-of-process") + .action(async (pkg: string, options: Record) => { + printJson(await runAddCommand(services, pkg, options)); + }); + + sensor + .command("remove ") + .description("Remove a configured sensor") + .option("--purge", "Remove the generated skill directory and best-effort uninstall the package") + .action(async (sensorId: string, options: Record) => { + printJson(await runRemoveCommand(services, sensorId, options)); + }); + + root + .command("reload") + .description("Ask the running gateway plugin instance to reload sensors") + .action(async () => { + printJson(await runReloadCommand()); + }); + }, + { + descriptors: [ + { + name: "world2agent", + description: "Manage World2Agent sensors", + hasSubcommands: true, + }, + ], + }, + ); +} + +async function runListCommand( + services: World2AgentCliServices, +): Promise { + const config = await loadEffectiveOpenClawConfig(services.api); + const manifest = await readManifest(services.paths); + return { + ok: true, + contextInjection: config.agents?.defaults?.contextInjection ?? null, + dedicated_agent_id: services.pluginConfig.defaultAgentId, + sensors: manifest.sensors, + }; +} + +async function runAddCommand( + services: World2AgentCliServices, + pkg: string, + options: Record, +): Promise { + const config = await loadEffectiveOpenClawConfig(services.api); + assertContextInjectionCompatible(config); + + const installed = await ensurePackageInstalled(pkg); + const sensorId = optionString(options, "sensorId") ?? defaultSensorId(pkg); + const configFile = optionString(options, "configFile"); + const isolated = optionBoolean(options, "isolated"); + const skillId = packageToSkillId(pkg); + const sensorConfig = await loadConfigFile(configFile, installed); + await writeGeneratedSkill(services.paths, pkg, installed); + + const manifest = await readManifest(services.paths); + const entry: SensorEntry = { + sensor_id: sensorId, + pkg, + skill_id: skillId, + enabled: true, + isolated, + config: sensorConfig, + }; + await writeManifest(services.paths, upsertSensorEntry(manifest, entry)); + + const allowlist = await maybePersistAllowlist( + services.api, + config, + services.pluginConfig.defaultAgentId, + skillId, + ); + const reload = await runReloadCommand(); + + return { + ok: true, + sensor_id: sensorId, + skill_id: skillId, + isolated, + allowlist, + reload, + }; +} + +async function runRemoveCommand( + services: World2AgentCliServices, + sensorId: string, + options: Record, +): Promise { + const manifest = await readManifest(services.paths); + const { manifest: nextManifest, removed } = removeSensorEntry(manifest, sensorId); + if (!removed) { + throw new Error(`Sensor not found: ${sensorId}`); + } + + await writeManifest(services.paths, nextManifest); + + const purge = optionBoolean(options, "purge"); + if (purge) { + await removePath(join(services.paths.openclawSkillsDir, removed.skill_id)); + const stillUsesPackage = nextManifest.sensors.some((entry) => entry.pkg === removed.pkg); + if (!stillUsesPackage) { + await maybeUninstallPackage(removed.pkg); + } + } + + return { + ok: true, + removed, + purge, + reload: await runReloadCommand(), + }; +} + +async function runReloadCommand(): Promise { + try { + const { stdout } = await runCommand("openclaw", [ + "gateway", + "call", + "world2agent.reload", + "--json", + ]); + + try { + return JSON.parse(stdout); + } catch { + return { + ok: true, + raw: stdout.trim(), + }; + } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function maybePersistAllowlist( + api: OpenClawPluginApi, + config: OpenClawConfig, + agentId: string, + skillId: string, +): Promise { + const result = upsertDedicatedAgentSkillAllowlist(config, agentId, skillId); + if (result.changed && typeof api.runtime?.config?.writeConfigFile === "function") { + await api.runtime.config.writeConfigFile(result.nextConfig); + } + return { + changed: result.changed, + warning: result.warning, + }; +} + +function printJson(value: unknown): void { + process.stdout.write(JSON.stringify(value, null, 2) + "\n"); +} + +function optionString(options: Record, key: string): string | undefined { + const value = options[key]; + return typeof value === "string" && value.trim() !== "" ? value : undefined; +} + +function optionBoolean(options: Record, key: string): boolean { + return options[key] === true; +} + diff --git a/openclaw-plugin/src/config.ts b/openclaw-plugin/src/config.ts new file mode 100644 index 0000000..d22a810 --- /dev/null +++ b/openclaw-plugin/src/config.ts @@ -0,0 +1,133 @@ +import type { + OpenClawAgentConfig, + OpenClawConfig, + OpenClawPluginApi, + OpenClawPluginConfig, +} from "./openclaw/plugin-sdk/types.js"; +import type { RequiredWorld2AgentPluginConfig } from "./types.js"; + +export const REQUIRED_CONTEXT_INJECTION = "continuation-skip"; + +export async function loadEffectiveOpenClawConfig( + api: OpenClawPluginApi, +): Promise { + if (typeof api.runtime?.config?.loadConfig === "function") { + return api.runtime.config.loadConfig(); + } + return api.config ?? {}; +} + +export function assertContextInjectionCompatible(config: OpenClawConfig): void { + const got = config.agents?.defaults?.contextInjection; + if (got === REQUIRED_CONTEXT_INJECTION) return; + + throw new Error( + "OpenClaw config field `agents.defaults.contextInjection` must be set to " + + `"${REQUIRED_CONTEXT_INJECTION}" for @world2agent/openclaw-plugin. ` + + `Current value: ${JSON.stringify(got)}. Update that exact field and retry.`, + ); +} + +export function normalizePluginConfig( + value: unknown, +): RequiredWorld2AgentPluginConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as OpenClawPluginConfig) + : {}; + + return { + sensorsManifestPath: asOptionalString(raw.sensorsManifestPath), + stateDir: asOptionalString(raw.stateDir), + sessionDir: asOptionalString(raw.sessionDir), + workspaceDir: asOptionalString(raw.workspaceDir), + ingestUrl: asOptionalString(raw.ingestUrl), + defaultAgentId: asOptionalString(raw.defaultAgentId) ?? "world2agent", + provider: asOptionalString((raw as Record).provider), + model: asOptionalString((raw as Record).model), + requestTimeoutMs: asPositiveInteger(raw.requestTimeoutMs) ?? 120_000, + ingestHmacSecretFile: asOptionalString(raw.ingestHmacSecretFile), + ingestDedupTtlMs: asPositiveInteger(raw.ingestDedupTtlMs) ?? 3_600_000, + }; +} + +export function hasDedicatedAgentSkillsAllowlist( + config: OpenClawConfig, + agentId: string, +): boolean { + const agent = findDedicatedAgent(config, agentId); + return Array.isArray(agent?.skills); +} + +export function findDedicatedAgent( + config: OpenClawConfig, + agentId: string, +): OpenClawAgentConfig | undefined { + return config.agents?.list?.find( + (entry) => entry.id === agentId || entry.name === agentId, + ); +} + +export function upsertDedicatedAgentSkillAllowlist( + config: OpenClawConfig, + agentId: string, + skillId: string, +): { + changed: boolean; + nextConfig: OpenClawConfig; + warning: string | null; +} { + const currentAgent = findDedicatedAgent(config, agentId); + if (!currentAgent) { + return { + changed: false, + nextConfig: config, + warning: + `Dedicated agent ${JSON.stringify(agentId)} was not found in agents.list; ` + + "installed skill will rely on prompt-prefix fallback until that agent exists.", + }; + } + + const currentSkills = Array.isArray(currentAgent.skills) + ? [...currentAgent.skills] + : []; + if (currentSkills.includes(skillId)) { + return { + changed: false, + nextConfig: config, + warning: null, + }; + } + + currentSkills.push(skillId); + currentSkills.sort(); + + const nextConfig: OpenClawConfig = structuredClone(config); + const nextAgent = findDedicatedAgent(nextConfig, agentId); + if (!nextAgent) { + return { + changed: false, + nextConfig: config, + warning: + `Dedicated agent ${JSON.stringify(agentId)} disappeared while cloning config; ` + + "installed skill will rely on prompt-prefix fallback.", + }; + } + nextAgent.skills = currentSkills; + + return { + changed: true, + nextConfig, + warning: null, + }; +} + +function asOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" ? value : undefined; +} + +function asPositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 + ? value + : undefined; +} diff --git a/openclaw-plugin/src/dispatch.ts b/openclaw-plugin/src/dispatch.ts new file mode 100644 index 0000000..7ebb112 --- /dev/null +++ b/openclaw-plugin/src/dispatch.ts @@ -0,0 +1,263 @@ +import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { join } from "node:path"; +import { hasDedicatedAgentSkillsAllowlist } from "./config.js"; +import { renderSignalPrompt } from "./prompt.js"; +import type { OpenClawConfig } from "./openclaw/plugin-sdk/types.js"; +import type { + Dispatcher, + DispatcherDispatchInput, + EmbeddedDispatcherOptions, + HttpDispatcherOptions, + HttpIngestEnvelope, +} from "./types.js"; + +const RUN_EMBEDDED_AGENT_ERROR = + "M0 spike unverified: api.runtime.agent.runEmbeddedAgent not found — verify against a live OpenClaw install"; + +export function assertEmbeddedAgentRuntime(options: EmbeddedDispatcherOptions): void { + if (typeof options.api.runtime?.agent?.runEmbeddedAgent !== "function") { + throw new Error(RUN_EMBEDDED_AGENT_ERROR); + } +} + +export class EmbeddedDispatcher implements Dispatcher { + private readonly options: EmbeddedDispatcherOptions; + private readonly queues = new Map>(); + + constructor(options: EmbeddedDispatcherOptions) { + assertEmbeddedAgentRuntime(options); + this.options = options; + } + + async dispatch(input: DispatcherDispatchInput): Promise { + // OpenClaw enforces SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i; + // colons aren't allowed in sessionId. sessionKey is the colon-namespaced lane. + const sessionId = input.sessionId ?? `w2a-${sanitizeSessionId(input.sensorId)}`; + const previous = this.queues.get(sessionId) ?? Promise.resolve(); + + const next = previous + .catch(() => undefined) + .then(() => this.dispatchNow(input, sessionId)); + + this.queues.set(sessionId, next); + try { + return await next; + } finally { + if (this.queues.get(sessionId) === next) { + this.queues.delete(sessionId); + } + } + } + + private async dispatchNow( + input: DispatcherDispatchInput, + sessionId: string, + ): Promise { + const prompt = renderSignalPrompt(input.signal, { + skillId: input.skillId, + useSkillPrefix: !hasDedicatedAgentSkillsAllowlist( + this.options.openclawConfigRef.current, + this.options.pluginConfig.defaultAgentId, + ), + }); + + const config = this.options.openclawConfigRef.current; + const runtimeAgent = this.options.api.runtime!.agent!; + const agentId = this.options.pluginConfig.defaultAgentId ?? "main"; + const sessionKey = `w2a:${input.sensorId}`; + const openclawHome = this.options.paths.openclawHome; + const workspaceDir = + this.options.pluginConfig.workspaceDir ?? + tryCall(() => runtimeAgent.resolveAgentWorkspaceDir?.(config, agentId)) ?? + join(openclawHome, "workspace"); + const agentDir = + tryCall(() => runtimeAgent.resolveAgentDir?.(config, agentId)) ?? + join(openclawHome, "agents", agentId); + const sessionFile = join(agentDir, "sessions", `${sessionId}.jsonl`); + const timeoutMs = + this.options.pluginConfig.requestTimeoutMs ?? + tryCall(() => runtimeAgent.resolveAgentTimeoutMs?.(config)) ?? + 120_000; + + // OpenClaw's runEmbeddedAgent silently defaults to "openai/gpt-5.4" when + // provider/model are absent — it does NOT read agents.defaults.model.primary. + // Resolve the effective default ourselves so signal-driven runs follow the + // operator's configured model. + const { provider, model } = resolveProviderAndModel( + config, + this.options.pluginConfig, + ); + + return runtimeAgent.runEmbeddedAgent!({ + sessionId, + sessionKey, + agentId, + runId: randomUUID(), + sessionFile, + workspaceDir, + agentDir, + config, + prompt, + timeoutMs, + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + } as Parameters>[0]); + } +} + +export class CliDispatcher implements Dispatcher { + async dispatch(_input: DispatcherDispatchInput): Promise { + // TODO: Keep this as an escape hatch only; do not make it load-bearing. + throw new Error("CliDispatcher is not implemented in M4 skeleton"); + } +} + +export class HttpDispatcher { + private readonly embeddedDispatcher: Dispatcher; + private readonly hmacSecret: string; + private readonly dedup = new RequestDeduper(); + private readonly dedupTtlMs: number; + + constructor(options: HttpDispatcherOptions) { + this.embeddedDispatcher = options.embeddedDispatcher; + this.hmacSecret = options.hmacSecret; + this.dedupTtlMs = options.dedupTtlMs; + } + + createRoute() { + return { + path: "/w2a/ingest", + auth: "plugin" as const, + handler: async (req: IncomingMessage, res: ServerResponse) => { + await this.handle(req, res); + }, + }; + } + + async handle(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== "POST") { + writeJson(res, 405, { ok: false, error: "method not allowed" }); + return; + } + + const body = await readBody(req); + if (!this.verifyHmac(body, req.headers["x-webhook-signature"])) { + writeJson(res, 401, { ok: false, error: "invalid signature" }); + return; + } + + const requestId = req.headers["x-request-id"]; + if (typeof requestId !== "string" || requestId.trim() === "") { + writeJson(res, 400, { ok: false, error: "missing X-Request-ID" }); + return; + } + if (this.dedup.seen(requestId, this.dedupTtlMs)) { + writeJson(res, 202, { ok: true, deduped: true }); + return; + } + + let payload: HttpIngestEnvelope; + try { + payload = JSON.parse(body) as HttpIngestEnvelope; + } catch (error) { + writeJson(res, 400, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + await this.embeddedDispatcher.dispatch({ + sensorId: payload.sensor_id, + skillId: payload.skill_id, + signal: payload.signal, + }); + + writeJson(res, 202, { ok: true }); + } + + private verifyHmac(body: string, signatureHeader: string | string[] | undefined): boolean { + if (typeof signatureHeader !== "string") return false; + const expected = Buffer.from( + createHmac("sha256", this.hmacSecret).update(body).digest("hex"), + "hex", + ); + const got = Buffer.from(signatureHeader, "hex"); + return expected.length === got.length && timingSafeEqual(expected, got); + } +} + +class RequestDeduper { + private readonly seenAt = new Map(); + + seen(id: string, ttlMs: number): boolean { + const now = Date.now(); + this.prune(now, ttlMs); + if (this.seenAt.has(id)) { + return true; + } + this.seenAt.set(id, now); + if (this.seenAt.size > 1_024) { + const oldest = this.seenAt.keys().next().value; + if (oldest) this.seenAt.delete(oldest); + } + return false; + } + + private prune(now: number, ttlMs: number): void { + for (const [id, seenAt] of this.seenAt) { + if (now - seenAt > ttlMs) { + this.seenAt.delete(id); + } + } + } +} + +function sanitizeSessionId(value: string): string { + // OpenClaw SAFE_SESSION_ID_RE: /^[a-z0-9][a-z0-9._-]{0,127}$/i. Map invalid + // chars to "-" (allowed) rather than "_" (also allowed but mixed-style). + return value.replace(/[^A-Za-z0-9._-]/g, "-"); +} + +function tryCall(fn: () => T): T | undefined { + try { + return fn(); + } catch { + return undefined; + } +} + +function resolveProviderAndModel( + config: OpenClawConfig, + pluginConfig: { provider?: string; model?: string }, +): { provider?: string; model?: string } { + if (pluginConfig.provider && pluginConfig.model) { + return { provider: pluginConfig.provider, model: pluginConfig.model }; + } + const primary = config.agents?.defaults?.model?.primary; + if (typeof primary === "string") { + const slash = primary.indexOf("/"); + if (slash > 0) { + return { + provider: pluginConfig.provider ?? primary.slice(0, slash), + model: pluginConfig.model ?? primary.slice(slash + 1), + }; + } + } + return { provider: pluginConfig.provider, model: pluginConfig.model }; +} + +async function readBody(req: IncomingMessage): Promise { + let raw = ""; + for await (const chunk of req) { + raw += chunk.toString(); + } + return raw; +} + +function writeJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body, null, 2)); +} diff --git a/openclaw-plugin/src/index.ts b/openclaw-plugin/src/index.ts new file mode 100644 index 0000000..3ae4db4 --- /dev/null +++ b/openclaw-plugin/src/index.ts @@ -0,0 +1,7 @@ +import { createWorld2AgentPlugin } from "./plugin.js"; + +export { definePluginEntry } from "./openclaw/plugin-sdk/plugin-entry.js"; +export type * from "./openclaw/plugin-sdk/types.js"; +export { createWorld2AgentPlugin } from "./plugin.js"; + +export default createWorld2AgentPlugin(); diff --git a/openclaw-plugin/src/install.ts b/openclaw-plugin/src/install.ts new file mode 100644 index 0000000..2a007bf --- /dev/null +++ b/openclaw-plugin/src/install.ts @@ -0,0 +1,223 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { packageToSkillId } from "@world2agent/sdk"; +import { pathExists, removePath } from "./manifest.js"; +import type { World2AgentPaths } from "./types.js"; + +export interface InstalledPackageInfo { + packageJsonPath: string; + packageRoot: string; + packageJson: Record; +} + +export function pluginPackageRoot(): string { + return fileURLToPath(new URL("../", import.meta.url)); +} + +export async function resolveInstalledPackage( + pkg: string, +): Promise { + const require = createRequire(import.meta.url); + try { + const entryPath = require.resolve(pkg, { + paths: [pluginPackageRoot()], + }); + const packageJsonPath = await findNearestPackageJson(dirname(entryPath)); + const raw = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record; + return { + packageJsonPath, + packageRoot: dirname(packageJsonPath), + packageJson: raw, + }; + } catch { + return null; + } +} + +export async function ensurePackageInstalled( + pkg: string, +): Promise { + const existing = await resolveInstalledPackage(pkg); + if (existing) return existing; + + const localRepo = await findLocalSensorRepo(pkg); + if (localRepo) { + await linkLocalPackage(pkg, localRepo); + const linked = await resolveInstalledPackage(pkg); + if (linked) return linked; + } + + await runCommand("npm", ["install", "--no-save", pkg], { + cwd: pluginPackageRoot(), + }); + const installed = await resolveInstalledPackage(pkg); + if (!installed) { + throw new Error(`Failed to resolve installed package ${pkg}`); + } + return installed; +} + +export async function maybeUninstallPackage( + pkg: string, +): Promise { + try { + await runCommand("npm", ["uninstall", "--no-save", pkg], { + cwd: pluginPackageRoot(), + }); + } catch { + // best effort + } +} + +export async function writeGeneratedSkill( + paths: World2AgentPaths, + pkg: string, + installed: InstalledPackageInfo, +): Promise { + const skillId = packageToSkillId(pkg); + const sourceType = String( + (installed.packageJson.w2a as Record | undefined)?.source_type ?? pkg, + ); + const signals = ( + (installed.packageJson.w2a as Record | undefined)?.signals as + | string[] + | undefined + )?.join(", "); + + const skillDir = join(paths.openclawSkillsDir, skillId); + await mkdir(skillDir, { recursive: true }); + const skillMd = [ + "---", + `name: ${skillId}`, + `description: Handle World2Agent signals from ${pkg}.`, + "user-invocable: false", + "---", + "", + `# ${skillId}`, + "", + `Handle W2A signals from \`${pkg}\` (source type: \`${sourceType}\`).`, + "", + "## Inputs", + "- The prompt body contains markdown context plus a fenced JSON copy of the full `signal` object.", + signals ? `- Common signal types: ${signals}` : "- Inspect `signal.event.type` for the exact event kind.", + "", + "## Behavior", + "- Parse the JSON when you need structured fields.", + "- If the signal is irrelevant or obviously low-value, skip silently.", + "- If it is actionable, reply briefly with the key fact, why it matters, and any obvious next step.", + "", + "## Notes", + "- This skill was generated by @world2agent/openclaw-plugin because the sensor package does not ship a richer OpenClaw-specific handler yet.", + "- If the dedicated World2Agent agent does not expose a skills allowlist, the plugin falls back to `Use skill: ` prompt routing.", + "", + ].join("\n"); + await writeFile(join(skillDir, "SKILL.md"), skillMd, "utf8"); + return skillId; +} + +export async function loadConfigFile( + configFile: string | undefined, + installed: InstalledPackageInfo, +): Promise> { + if (!configFile) { + const setupPath = String( + (installed.packageJson.w2a as Record | undefined)?.setup ?? "SETUP.md", + ); + throw new Error( + `Interactive setup is not implemented; use --config-file . Sensor guidance: ${join( + installed.packageRoot, + setupPath, + )}`, + ); + } + + const raw = JSON.parse(await readFile(configFile, "utf8")) as unknown; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`Config file must contain a JSON object: ${configFile}`); + } + return raw as Record; +} + +export async function runCommand( + command: string, + args: string[], + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + } = {}, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolvePromise({ stdout, stderr }); + return; + } + reject( + new Error( + `${command} ${args.join(" ")} failed with code ${code}: ${ + stderr.trim() || stdout.trim() + }`, + ), + ); + }); + }); +} + +async function findLocalSensorRepo(pkg: string): Promise { + if (!pkg.startsWith("@world2agent/sensor-")) return null; + + const slug = pkg.split("/").pop()?.replace(/^sensor-/, ""); + if (!slug) return null; + + const candidate = resolve(pluginPackageRoot(), "..", "..", "world2agent-sensors", slug); + return (await pathExists(join(candidate, "package.json"))) ? candidate : null; +} + +async function linkLocalPackage(pkg: string, sourceDir: string): Promise { + const scope = pkg.split("/")[0]; + const name = pkg.split("/")[1]; + if (!scope || !name) { + throw new Error(`Invalid package name: ${pkg}`); + } + + const target = join(pluginPackageRoot(), "node_modules", scope, name); + await mkdir(dirname(target), { recursive: true }); + await removePath(target); + await symlink(sourceDir, target, "dir"); +} + +async function findNearestPackageJson(startDir: string): Promise { + let current = startDir; + for (;;) { + const candidate = join(current, "package.json"); + if (await pathExists(candidate)) { + return candidate; + } + const parent = dirname(current); + if (parent === current) { + throw new Error(`Could not find package.json above ${startDir}`); + } + current = parent; + } +} + diff --git a/openclaw-plugin/src/isolated.ts b/openclaw-plugin/src/isolated.ts new file mode 100644 index 0000000..0fc6093 --- /dev/null +++ b/openclaw-plugin/src/isolated.ts @@ -0,0 +1,240 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { buildIsolatedRunnerEnv, hashConfig, shouldRestartIsolatedHandle } from "./supervisor/shared.js"; +import type { + ApplyResult, + IsolatedRunnerHandle, + RequiredWorld2AgentPluginConfig, + SensorEntry, + World2AgentPaths, +} from "./types.js"; + +const NO_RESTART_EXIT_CODES = new Set([0, 10, 11, 12]); + +interface ChildHandle extends IsolatedRunnerHandle { + webhookUrl: string; + process: ChildProcessWithoutNullStreams; + stopping: boolean; + lastExitCode: number | null; + restartCount: number; +} + +export interface IsolatedRunnerManagerOptions { + paths: World2AgentPaths; + pluginConfig: RequiredWorld2AgentPluginConfig; + ingestUrl?: string; + hmacSecret: string; + log: (line: string) => void; +} + +export class IsolatedRunnerManager { + private readonly options: IsolatedRunnerManagerOptions; + private readonly handles = new Map(); + private readonly desiredEntries = new Map(); + private readonly runnerBin = fileURLToPath(new URL("./runner/bin.js", import.meta.url)); + + constructor(options: IsolatedRunnerManagerOptions) { + this.options = options; + } + + async apply(entries: SensorEntry[]): Promise { + const desired = entries.filter((entry) => entry.enabled !== false && entry.isolated === true); + const result: ApplyResult = { + started: [], + restarted: [], + stopped: [], + failed: [], + }; + + this.desiredEntries.clear(); + for (const entry of desired) { + this.desiredEntries.set(entry.sensor_id, entry); + } + + for (const [sensorId, handle] of [...this.handles.entries()]) { + if (!this.desiredEntries.has(sensorId)) { + await this.terminate(handle); + result.stopped.push(sensorId); + } + } + + for (const entry of desired) { + if (!this.options.ingestUrl) { + result.failed.push({ + sensor_id: entry.sensor_id, + error: + "isolated runner requires plugin config `ingestUrl` so the subprocess can POST /w2a/ingest", + }); + continue; + } + + const existing = this.handles.get(entry.sensor_id); + if (!existing) { + try { + await this.spawn(entry); + result.started.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + continue; + } + + if (!shouldRestartIsolatedHandle(existing, entry, this.options.ingestUrl)) { + continue; + } + + try { + await this.terminate(existing); + await this.spawn(entry); + result.restarted.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + } + + return result; + } + + async terminateAll(graceMs = 5_000): Promise { + this.desiredEntries.clear(); + for (const handle of [...this.handles.values()]) { + await this.terminate(handle, graceMs); + } + } + + private async spawn(entry: SensorEntry, restartCount = 0): Promise { + if (!this.options.ingestUrl) { + throw new Error( + "isolated runner requires plugin config `ingestUrl` so the subprocess can POST /w2a/ingest", + ); + } + + const proc = spawn(process.execPath, [this.runnerBin], { + env: buildIsolatedRunnerEnv({ + pkg: entry.pkg, + sensorId: entry.sensor_id, + skillId: entry.skill_id, + ingestUrl: this.options.ingestUrl, + hmacSecret: this.options.hmacSecret, + statePath: join(this.options.paths.stateDir, `${entry.sensor_id}.json`), + }), + stdio: ["pipe", "pipe", "pipe"], + }); + + const handle: ChildHandle = { + sensorId: entry.sensor_id, + pkg: entry.pkg, + skillId: entry.skill_id, + isolated: true, + configHash: hashConfig(entry.config), + startedAt: Date.now(), + cleanup: async () => { + await this.terminate(handle); + }, + webhookUrl: this.options.ingestUrl, + process: proc, + stopping: false, + lastExitCode: null, + restartCount, + }; + + this.handles.set(entry.sensor_id, handle); + proc.on("exit", (code, signal) => { + void this.handleExit(handle, code, signal); + }); + pipeStream(proc.stdout, (line) => this.options.log(`[w2a/${entry.sensor_id}] ${line}`)); + pipeStream(proc.stderr, (line) => this.options.log(`[w2a/${entry.sensor_id}] ${line}`)); + proc.stdin.end(JSON.stringify(entry.config ?? {}) + "\n"); + + return handle; + } + + private async terminate(handle: ChildHandle, graceMs = 5_000): Promise { + handle.stopping = true; + if (handle.process.exitCode !== null || handle.process.killed) { + this.handles.delete(handle.sensorId); + return; + } + + const exitPromise = once(handle.process, "exit").catch(() => []); + try { + handle.process.kill("SIGTERM"); + } catch { + this.handles.delete(handle.sensorId); + return; + } + + const timedOut = await Promise.race([ + exitPromise.then(() => false), + delay(graceMs).then(() => true), + ]); + if (timedOut) { + try { + handle.process.kill("SIGKILL"); + } catch { + // no-op + } + await exitPromise; + } + + this.handles.delete(handle.sensorId); + } + + private async handleExit( + handle: ChildHandle, + code: number | null, + signal: NodeJS.Signals | null, + ): Promise { + handle.lastExitCode = code; + + const current = this.handles.get(handle.sensorId); + if (current !== handle) return; + this.handles.delete(handle.sensorId); + this.options.log( + `[w2a/${handle.sensorId}] isolated runner exited code=${String(code)} signal=${String(signal)}`, + ); + + if (handle.stopping) return; + if (code !== null && NO_RESTART_EXIT_CODES.has(code)) return; + + const nextEntry = this.desiredEntries.get(handle.sensorId); + if (!nextEntry) return; + if (!this.options.ingestUrl) return; + + try { + await this.spawn(nextEntry, handle.restartCount + 1); + } catch (error) { + this.options.log( + `[w2a/${handle.sensorId}] isolated restart failed: ${errorMessage(error)}`, + ); + } + } +} + +function pipeStream( + stream: NodeJS.ReadableStream, + onLine: (line: string) => void, +): void { + stream.setEncoding("utf8"); + let buffer = ""; + stream.on("data", (chunk: string) => { + buffer += chunk; + for (;;) { + const index = buffer.indexOf("\n"); + if (index === -1) break; + const line = buffer.slice(0, index).trimEnd(); + buffer = buffer.slice(index + 1); + if (line) onLine(line); + } + }); +} + +function delay(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/openclaw-plugin/src/manifest.ts b/openclaw-plugin/src/manifest.ts new file mode 100644 index 0000000..6109759 --- /dev/null +++ b/openclaw-plugin/src/manifest.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { access, readFile, rm } from "node:fs/promises"; +import { packageToSkillId } from "@world2agent/sdk"; +import { ensureWorld2AgentDirs, writeTextAtomic } from "./paths.js"; +import type { SensorEntry, SensorManifest, World2AgentPaths } from "./types.js"; + +const DEFAULT_MANIFEST: SensorManifest = { + version: 1, + sensors: [], +}; + +export async function readManifest(paths: World2AgentPaths): Promise { + try { + const raw = await readFile(paths.manifestFile, "utf8"); + return parseManifest(JSON.parse(raw) as unknown); + } catch (error) { + if (isMissingFile(error)) { + return structuredClone(DEFAULT_MANIFEST); + } + throw error; + } +} + +export async function writeManifest( + paths: World2AgentPaths, + manifest: SensorManifest, +): Promise { + await ensureWorld2AgentDirs(paths); + const normalized: SensorManifest = { + version: 1, + sensors: manifest.sensors.map(normalizeSensorEntry), + }; + await writeTextAtomic(paths.manifestFile, JSON.stringify(normalized, null, 2) + "\n"); +} + +export function upsertSensorEntry( + manifest: SensorManifest, + entry: SensorEntry, +): SensorManifest { + const normalized = normalizeSensorEntry(entry); + const sensors = manifest.sensors.filter((item) => item.sensor_id !== normalized.sensor_id); + sensors.push(normalized); + sensors.sort((a, b) => a.sensor_id.localeCompare(b.sensor_id)); + return { + version: 1, + sensors, + }; +} + +export function removeSensorEntry( + manifest: SensorManifest, + sensorId: string, +): { + manifest: SensorManifest; + removed: SensorEntry | null; +} { + const removed = manifest.sensors.find((entry) => entry.sensor_id === sensorId) ?? null; + return { + manifest: { + version: 1, + sensors: manifest.sensors.filter((entry) => entry.sensor_id !== sensorId), + }, + removed, + }; +} + +export function normalizeSensorEntry(entry: SensorEntry): SensorEntry { + return { + sensor_id: entry.sensor_id, + pkg: entry.pkg, + skill_id: packageToSkillId(entry.pkg), + enabled: entry.enabled !== false, + isolated: entry.isolated === true, + config: entry.config ?? {}, + }; +} + +export function defaultSensorId(pkg: string): string { + const suffix = pkg.split("/").pop() ?? pkg; + return suffix.replace(/^sensor-/, ""); +} + +export function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + const obj = value as Record; + return `{${Object.keys(obj) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`) + .join(",")}}`; +} + +export function hashConfig(config: unknown): string { + return createHash("sha1").update(stableStringify(config)).digest("hex"); +} + +export async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function removePath(path: string): Promise { + await rm(path, { force: true, recursive: true }); +} + +function parseManifest(raw: unknown): SensorManifest { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("Manifest must be a JSON object"); + } + + const version = (raw as Record).version; + const sensors = (raw as Record).sensors; + if (version !== 1) { + throw new Error(`Unsupported manifest version: ${String(version)}`); + } + if (!Array.isArray(sensors)) { + throw new Error("Manifest field `sensors` must be an array"); + } + + return { + version: 1, + sensors: sensors.map((entry, index) => parseSensorEntry(entry, index)), + }; +} + +function parseSensorEntry(raw: unknown, index: number): SensorEntry { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`Manifest sensor[${index}] must be an object`); + } + + const entry = raw as Record; + const sensorId = expectString(entry.sensor_id, `sensor[${index}].sensor_id`); + const pkg = expectString(entry.pkg, `sensor[${index}].pkg`); + const enabled = entry.enabled === undefined ? true : Boolean(entry.enabled); + const isolated = entry.isolated === true; + const config = entry.config; + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error(`sensor[${index}].config must be an object`); + } + + return { + sensor_id: sensorId, + pkg, + skill_id: + entry.skill_id === undefined + ? packageToSkillId(pkg) + : expectString(entry.skill_id, `sensor[${index}].skill_id`), + enabled, + isolated, + config: config as Record, + }; +} + +function expectString(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function isMissingFile(error: unknown): boolean { + return isNodeError(error) && error.code === "ENOENT"; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + diff --git a/openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts b/openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts new file mode 100644 index 0000000..86b88da --- /dev/null +++ b/openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts @@ -0,0 +1,6 @@ +import type { OpenClawPluginEntry } from "./types.js"; + +export function definePluginEntry(entry: T): T { + return entry; +} + diff --git a/openclaw-plugin/src/openclaw/plugin-sdk/types.ts b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts new file mode 100644 index 0000000..e96b32a --- /dev/null +++ b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts @@ -0,0 +1,135 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +export interface EmbeddedAgentRunRequest { + sessionId: string; + sessionKey?: string; + agentId?: string; + runId: string; + sessionFile: string; + workspaceDir: string; + agentDir?: string; + config?: OpenClawConfig; + prompt: string; + timeoutMs?: number; + [key: string]: unknown; +} + +export interface OpenClawAgentConfig { + id?: string; + name?: string; + skills?: string[]; + [key: string]: unknown; +} + +export interface OpenClawAgentDefaults { + contextInjection?: string; + model?: { + primary?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface OpenClawConfig { + agents?: { + defaults?: OpenClawAgentDefaults; + list?: OpenClawAgentConfig[]; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface OpenClawPluginConfig { + sensorsManifestPath?: string; + stateDir?: string; + sessionDir?: string; + workspaceDir?: string; + ingestUrl?: string; + defaultAgentId?: string; + requestTimeoutMs?: number; + ingestHmacSecretFile?: string; + ingestDedupTtlMs?: number; +} + +export interface OpenClawPluginLogger { + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; +} + +export interface OpenClawRuntimeAgentSessionApi { + resolveSessionFilePath?(config: OpenClawConfig, sessionId: string): string; +} + +export interface OpenClawRuntimeAgentApi { + runEmbeddedAgent?(request: EmbeddedAgentRunRequest): Promise; + resolveAgentDir?(config: OpenClawConfig, agentId?: string): string; + resolveAgentWorkspaceDir?(config: OpenClawConfig, agentId?: string): string; + resolveAgentTimeoutMs?(config: OpenClawConfig): number; + session?: OpenClawRuntimeAgentSessionApi; +} + +export interface OpenClawRuntimeConfigApi { + loadConfig?(): Promise; + writeConfigFile?(config: OpenClawConfig): Promise; +} + +export interface CliCommandBuilder { + description(text: string): CliCommandBuilder; + option(flags: string, description?: string): CliCommandBuilder; + command(name: string): CliCommandBuilder; + action(handler: (...args: any[]) => unknown): CliCommandBuilder; +} + +export interface CliProgram { + command(name: string): CliCommandBuilder; +} + +export type CliRegistrar = (context: { program: CliProgram }) => Promise | void; + +export interface CliCommandDescriptor { + name: string; + description?: string; + hasSubcommands?: boolean; +} + +export interface OpenClawGatewayMethodContext { + payload?: unknown; +} + +export type OpenClawGatewayMethodHandler = ( + context?: OpenClawGatewayMethodContext, +) => Promise | unknown; + +export interface OpenClawHttpRouteRegistration { + path: string; + auth: "plugin"; + handler: (req: IncomingMessage, res: ServerResponse) => Promise | void; +} + +export interface OpenClawPluginApi { + config?: OpenClawConfig; + pluginConfig?: unknown; + registrationMode?: string; + logger?: OpenClawPluginLogger; + resolvePath?(value: string): string; + registerCli?( + registrar: CliRegistrar, + options?: { descriptors?: CliCommandDescriptor[] }, + ): void; + registerGatewayMethod?( + name: string, + handler: OpenClawGatewayMethodHandler, + ): void; + registerHttpRoute?(route: OpenClawHttpRouteRegistration): void; + runtime?: { + agent?: OpenClawRuntimeAgentApi; + config?: OpenClawRuntimeConfigApi; + }; +} + +export interface OpenClawPluginEntry { + id: string; + register(api: OpenClawPluginApi): Promise | void; +} diff --git a/openclaw-plugin/src/paths.ts b/openclaw-plugin/src/paths.ts new file mode 100644 index 0000000..db9e90e --- /dev/null +++ b/openclaw-plugin/src/paths.ts @@ -0,0 +1,93 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { + mkdirSync, + readFileSync, + writeFileSync, + existsSync, + renameSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { randomBytes } from "node:crypto"; +import type { RequiredWorld2AgentPluginConfig, World2AgentPaths } from "./types.js"; + +export function getWorld2AgentPaths( + pluginConfig: RequiredWorld2AgentPluginConfig, + env: NodeJS.ProcessEnv = process.env, +): World2AgentPaths { + const openclawHome = env.OPENCLAW_HOME ?? join(homedir(), ".openclaw"); + const baseDir = env.W2A_HOME ?? join(homedir(), ".world2agent"); + + return { + baseDir, + manifestFile: resolvePath(baseDir, pluginConfig.sensorsManifestPath, "sensors.json"), + stateDir: resolvePath(baseDir, pluginConfig.stateDir, "state"), + sessionDir: resolvePath(baseDir, pluginConfig.sessionDir, "sessions"), + openclawHome, + openclawSkillsDir: join(openclawHome, "skills"), + ingestHmacSecretFile: resolvePath( + baseDir, + pluginConfig.ingestHmacSecretFile, + ".openclaw-ingest-secret", + ), + }; +} + +export async function ensureWorld2AgentDirs( + paths: World2AgentPaths, +): Promise { + await mkdir(paths.baseDir, { recursive: true }); + await mkdir(paths.stateDir, { recursive: true }); + await mkdir(paths.sessionDir, { recursive: true }); + await mkdir(paths.openclawSkillsDir, { recursive: true }); +} + +// OpenClaw's plugin loader does NOT await async register(); we must do +// pre-register filesystem work synchronously. +export function ensureWorld2AgentDirsSync(paths: World2AgentPaths): void { + mkdirSync(paths.baseDir, { recursive: true }); + mkdirSync(paths.stateDir, { recursive: true }); + mkdirSync(paths.sessionDir, { recursive: true }); + mkdirSync(paths.openclawSkillsDir, { recursive: true }); +} + +export function loadOrCreateHmacSecretSync(path: string): string { + if (existsSync(path)) { + const existing = readFileSync(path, "utf8").trim(); + if (existing) return existing; + } + mkdirSync(dirname(path), { recursive: true }); + const next = randomBytes(32).toString("hex"); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${next}\n`); + renameSync(tmp, path); + return next; +} + +export async function readTrimmedText(path: string): Promise { + try { + return (await readFile(path, "utf8")).trim() || null; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return null; + throw error; + } +} + +export async function writeTextAtomic( + path: string, + content: string, +): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, content, "utf8"); + await rename(tmp, path); +} + +function resolvePath(baseDir: string, override: string | undefined, fallback: string): string { + return override ? resolve(override) : join(baseDir, fallback); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + diff --git a/openclaw-plugin/src/plugin.ts b/openclaw-plugin/src/plugin.ts new file mode 100644 index 0000000..c4deb15 --- /dev/null +++ b/openclaw-plugin/src/plugin.ts @@ -0,0 +1,117 @@ +import { EmbeddedDispatcher, HttpDispatcher } from "./dispatch.js"; +import { + assertContextInjectionCompatible, + loadEffectiveOpenClawConfig, + normalizePluginConfig, +} from "./config.js"; +import { registerWorld2AgentCli } from "./cli.js"; +import { readManifest } from "./manifest.js"; +import { + ensureWorld2AgentDirsSync, + loadOrCreateHmacSecretSync, +} from "./paths.js"; +import { definePluginEntry } from "./openclaw/plugin-sdk/plugin-entry.js"; +import type { OpenClawPluginApi } from "./openclaw/plugin-sdk/types.js"; +import { IsolatedRunnerManager } from "./isolated.js"; +import { SensorRuntime } from "./runtime.js"; +import { getWorld2AgentPaths } from "./paths.js"; + +// register() MUST stay synchronous: OpenClaw's plugin loader logs +// "plugin register returned a promise; async registration is ignored" +// and drops every api.register* call that follows an await. +export function createWorld2AgentPlugin() { + return definePluginEntry({ + id: "world2agent", + register(api: OpenClawPluginApi): void { + const pluginConfig = normalizePluginConfig(api.pluginConfig); + const paths = getWorld2AgentPaths(pluginConfig); + ensureWorld2AgentDirsSync(paths); + + registerWorld2AgentCli({ + api, + paths, + pluginConfig, + }); + + if ((api.registrationMode ?? "full") === "cli-metadata") { + return; + } + + const openclawConfig = api.config ?? {}; + assertContextInjectionCompatible(openclawConfig); + const openclawConfigRef = { current: openclawConfig }; + + const embeddedDispatcher = new EmbeddedDispatcher({ + api, + openclawConfigRef, + pluginConfig, + paths, + }); + + const hmacSecret = loadOrCreateHmacSecretSync(paths.ingestHmacSecretFile); + const httpDispatcher = new HttpDispatcher({ + embeddedDispatcher, + hmacSecret, + dedupTtlMs: pluginConfig.ingestDedupTtlMs, + }); + + api.registerHttpRoute?.(httpDispatcher.createRoute()); + + const runtime = new SensorRuntime({ + dispatcher: embeddedDispatcher, + isolatedRunnerManager: new IsolatedRunnerManager({ + paths, + pluginConfig, + ingestUrl: pluginConfig.ingestUrl, + hmacSecret, + log: (line) => log(api, line), + }), + paths, + log: (line) => log(api, line), + }); + + api.registerGatewayMethod?.("world2agent.reload", async () => { + const nextConfig = await loadEffectiveOpenClawConfig(api); + assertContextInjectionCompatible(nextConfig); + openclawConfigRef.current = nextConfig; + const manifest = await readManifest(paths); + return { + ok: true, + applied: await runtime.applyManifest(manifest.sensors), + }; + }); + + if ((api.registrationMode ?? "full") !== "full") { + return; + } + + // Fire-and-forget: must not be awaited because register() itself is sync. + void runStartup({ api, runtime, paths }); + }, + }); +} + +async function runStartup(opts: { + api: OpenClawPluginApi; + runtime: SensorRuntime; + paths: ReturnType; +}): Promise { + try { + const manifest = await readManifest(opts.paths); + const applied = await opts.runtime.applyManifest(manifest.sensors); + if (applied.failed.length > 0) { + log( + opts.api, + `[w2a] startup completed with failures: ${JSON.stringify(applied.failed)}`, + ); + } + } catch (error) { + const logger = opts.api.logger ?? console; + logger.error("[w2a] startup failed:", error); + } +} + +function log(api: OpenClawPluginApi, line: string): void { + const logger = api.logger ?? console; + logger.info(line); +} diff --git a/openclaw-plugin/src/prompt.ts b/openclaw-plugin/src/prompt.ts new file mode 100644 index 0000000..d78772a --- /dev/null +++ b/openclaw-plugin/src/prompt.ts @@ -0,0 +1,44 @@ +import type { Attachment, W2ASignal } from "@world2agent/sdk"; + +export function renderSignalPrompt( + signal: W2ASignal, + options: { + skillId: string; + useSkillPrefix: boolean; + }, +): string { + const attachmentLines = renderAttachmentLines(signal.attachments ?? []); + const body = [ + "# World2Agent Signal", + "", + `Event: ${signal.event.type}`, + signal.event.summary, + attachmentLines ? "" : null, + attachmentLines || null, + "", + "Signal JSON:", + "```json", + JSON.stringify(signal, null, 2), + "```", + ] + .filter((part): part is string => part !== null) + .join("\n"); + + if (!options.useSkillPrefix) { + return body; + } + + return `Use skill: ${options.skillId}\n\n${body}`; +} + +function renderAttachmentLines(attachments: Attachment[]): string { + if (attachments.length === 0) return ""; + + const lines = attachments.map((attachment) => { + const locator = attachment.type === "reference" ? attachment.uri : "inline"; + return `- ${attachment.mime_type} ${attachment.description} (${locator})`; + }); + + return ["Attachments:", ...lines].join("\n"); +} + diff --git a/openclaw-plugin/src/runner/bin.ts b/openclaw-plugin/src/runner/bin.ts new file mode 100644 index 0000000..4e4046f --- /dev/null +++ b/openclaw-plugin/src/runner/bin.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +import { FileSensorStore, startSensor, type SensorSpec } from "@world2agent/sdk"; +import { pathToFileURL } from "node:url"; +import { isAbsolute, resolve } from "node:path"; +import { ingestTransport } from "./http-transport.js"; +import { readJsonFromStdin } from "./config-stream.js"; + +const EXIT_CONFIG_ERROR = 10; +const EXIT_IMPORT_ERROR = 11; +const EXIT_START_ERROR = 12; + +async function main(): Promise { + const env = requireEnv([ + "W2A_PACKAGE", + "W2A_INGEST_URL", + "W2A_HMAC_SECRET", + "W2A_SENSOR_ID", + "W2A_SKILL_ID", + "W2A_STATE_PATH", + ]); + + let config: Record; + try { + config = await readJsonFromStdin(); + } catch (error) { + console.error(error); + process.exit(EXIT_CONFIG_ERROR); + } + + let spec: SensorSpec>; + try { + spec = await loadSensorSpec(env.W2A_PACKAGE); + } catch (error) { + console.error(error); + process.exit(EXIT_IMPORT_ERROR); + } + + const transport = ingestTransport({ + url: env.W2A_INGEST_URL, + hmacSecret: env.W2A_HMAC_SECRET, + sensorId: env.W2A_SENSOR_ID, + skillId: env.W2A_SKILL_ID, + timeoutMs: 120_000, + }); + const store = new FileSensorStore({ path: env.W2A_STATE_PATH }); + + let cleanup: (() => Promise | void) | undefined; + try { + cleanup = await startSensor(spec, { + config, + onSignal: transport, + store, + logger: console, + logEmits: true, + }); + } catch (error) { + console.error(error); + await store.flush().catch(() => {}); + process.exit(EXIT_START_ERROR); + } + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + + try { + await cleanup?.(); + await store.flush(); + } catch (error) { + console.error(error); + process.exit(1); + } + + process.exit(0); + }; + + process.on("SIGTERM", () => { + void shutdown(); + }); + process.on("SIGINT", () => { + void shutdown(); + }); + + const watchdog = setInterval(() => { + if (process.ppid === 1) { + console.error("[w2a-openclaw-runner] parent died; shutting down"); + void shutdown(); + } + }, 5_000); + watchdog.unref(); + + await new Promise(() => {}); +} + +async function loadSensorSpec(pkg: string): Promise>> { + const module = await import(resolveImportTarget(pkg)); + const spec = module.default as SensorSpec> | undefined; + if (!spec || typeof spec.start !== "function") { + throw new Error(`${pkg} does not export a valid default SensorSpec`); + } + return spec; +} + +function resolveImportTarget(pkg: string): string { + if (pkg.startsWith(".") || pkg.startsWith("/") || isAbsolute(pkg)) { + return pathToFileURL(resolve(pkg)).href; + } + return pkg; +} + +function requireEnv(keys: string[]): Record { + const values: Record = {}; + for (const key of keys) { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required env var: ${key}`); + } + values[key] = value; + } + return values; +} + +main().catch((error) => { + console.error(error); + process.exit(99); +}); + diff --git a/openclaw-plugin/src/runner/config-stream.ts b/openclaw-plugin/src/runner/config-stream.ts new file mode 100644 index 0000000..f0170d0 --- /dev/null +++ b/openclaw-plugin/src/runner/config-stream.ts @@ -0,0 +1,4 @@ +import { readJsonFromStdin } from "../supervisor/shared.js"; + +export { readJsonFromStdin }; + diff --git a/openclaw-plugin/src/runner/http-transport.ts b/openclaw-plugin/src/runner/http-transport.ts new file mode 100644 index 0000000..5b369db --- /dev/null +++ b/openclaw-plugin/src/runner/http-transport.ts @@ -0,0 +1,64 @@ +import { createHmac } from "node:crypto"; +import type { W2ASignal } from "@world2agent/sdk"; +import type { HttpIngestEnvelope } from "../types.js"; + +export interface IngestTransportOptions { + url: string; + hmacSecret: string; + sensorId: string; + skillId: string; + timeoutMs: number; + retries?: number; + retryDelayMs?: number; +} + +export function ingestTransport(options: IngestTransportOptions) { + const retries = options.retries ?? 2; + const retryDelayMs = options.retryDelayMs ?? 500; + + return async (signal: W2ASignal): Promise => { + const envelope: HttpIngestEnvelope = { + sensor_id: options.sensorId, + skill_id: options.skillId, + signal, + }; + const body = JSON.stringify(envelope); + const headers: Record = { + "Content-Type": "application/json", + "X-Request-ID": signal.signal_id, + "X-Webhook-Signature": createHmac("sha256", options.hmacSecret) + .update(body) + .digest("hex"), + }; + + let lastError: unknown; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const response = await fetch(options.url, { + method: "POST", + headers, + body, + signal: AbortSignal.timeout(options.timeoutMs), + }); + if (response.ok) return; + if (response.status >= 400 && response.status < 500) { + throw new Error(`HTTP ${response.status}: ${await response.text().catch(() => "")}`); + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + + if (attempt < retries) { + await sleep(retryDelayMs * 2 ** attempt); + } + } + + throw lastError; + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + diff --git a/openclaw-plugin/src/runtime.ts b/openclaw-plugin/src/runtime.ts new file mode 100644 index 0000000..dd9785b --- /dev/null +++ b/openclaw-plugin/src/runtime.ts @@ -0,0 +1,160 @@ +import { FileSensorStore, startSensor, type SensorSpec } from "@world2agent/sdk"; +import { join } from "node:path"; +import type { Dispatcher, ApplyResult, RuntimeHandle, SensorEntry, World2AgentPaths } from "./types.js"; +import { hashConfig } from "./manifest.js"; +import { IsolatedRunnerManager } from "./isolated.js"; +import { resolveImportTarget } from "./supervisor/shared.js"; + +export interface SensorRuntimeOptions { + dispatcher: Dispatcher; + isolatedRunnerManager: IsolatedRunnerManager; + paths: World2AgentPaths; + log: (line: string) => void; +} + +export class SensorRuntime { + private readonly dispatcher: Dispatcher; + private readonly isolatedRunnerManager: IsolatedRunnerManager; + private readonly paths: World2AgentPaths; + private readonly log: (line: string) => void; + private readonly handles = new Map(); + + constructor(options: SensorRuntimeOptions) { + this.dispatcher = options.dispatcher; + this.isolatedRunnerManager = options.isolatedRunnerManager; + this.paths = options.paths; + this.log = options.log; + } + + async applyManifest(entries: SensorEntry[]): Promise { + const desired = entries.filter((entry) => entry.enabled !== false); + const result: ApplyResult = { + started: [], + restarted: [], + stopped: [], + failed: [], + }; + + const desiredInProcess = desired.filter((entry) => entry.isolated !== true); + + for (const [sensorId, handle] of [...this.handles.entries()]) { + if (!desiredInProcess.some((entry) => entry.sensor_id === sensorId)) { + await this.stopHandle(handle); + result.stopped.push(sensorId); + } + } + + for (const entry of desiredInProcess) { + const existing = this.handles.get(entry.sensor_id); + if (!existing) { + try { + await this.startHandle(entry); + result.started.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + continue; + } + + if (matchesHandle(existing, entry)) { + continue; + } + + try { + await this.stopHandle(existing); + await this.startHandle(entry); + result.restarted.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + } + + const isolatedResult = await this.isolatedRunnerManager.apply(desired); + mergeApplyResult(result, isolatedResult); + + return result; + } + + async stopAll(): Promise { + for (const handle of [...this.handles.values()]) { + await this.stopHandle(handle); + } + await this.isolatedRunnerManager.terminateAll(); + } + + private async startHandle(entry: SensorEntry): Promise { + const spec = await loadSensorSpec(entry.pkg); + const store = new FileSensorStore({ + path: join(this.paths.stateDir, `${entry.sensor_id}.json`), + }); + const cleanup = await startSensor(spec, { + config: entry.config, + store, + logger: console, + logEmits: true, + onSignal: async (signal) => { + try { + await this.dispatcher.dispatch({ + sensorId: entry.sensor_id, + skillId: entry.skill_id, + signal, + }); + } catch (error) { + this.log( + `[w2a/${entry.sensor_id}] dispatch failed: ${errorMessage(error)}`, + ); + } + }, + }); + + const handle: RuntimeHandle = { + sensorId: entry.sensor_id, + pkg: entry.pkg, + skillId: entry.skill_id, + isolated: false, + configHash: hashConfig(entry.config), + startedAt: Date.now(), + cleanup, + flush: () => store.flush(), + }; + this.handles.set(entry.sensor_id, handle); + } + + private async stopHandle(handle: RuntimeHandle): Promise { + try { + await handle.cleanup(); + await handle.flush?.(); + } finally { + this.handles.delete(handle.sensorId); + } + } +} + +async function loadSensorSpec(pkg: string): Promise>> { + const module = await import(resolveImportTarget(pkg)); + const spec = module.default as SensorSpec> | undefined; + if (!spec || typeof spec.start !== "function") { + throw new Error(`${pkg} does not export a valid default SensorSpec`); + } + return spec; +} + +function matchesHandle(handle: RuntimeHandle, entry: SensorEntry): boolean { + return ( + handle.pkg === entry.pkg && + handle.skillId === entry.skill_id && + handle.configHash === hashConfig(entry.config) && + handle.isolated === false + ); +} + +function mergeApplyResult(target: ApplyResult, update: ApplyResult): void { + target.started.push(...update.started); + target.restarted.push(...update.restarted); + target.stopped.push(...update.stopped); + target.failed.push(...update.failed); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/openclaw-plugin/src/supervisor/shared.ts b/openclaw-plugin/src/supervisor/shared.ts new file mode 100644 index 0000000..e61df45 --- /dev/null +++ b/openclaw-plugin/src/supervisor/shared.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { isAbsolute, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { SensorEntry } from "../types.js"; + +/** + * Copied in minimal form from hermes-sensor-bridge: + * - src/supervisor/manifest.ts + * - src/supervisor/spawn.ts + * - src/runner/config-stream.ts + * - src/runner/bin.ts + */ + +export interface IsolatedProcessMeta { + sensorId: string; + pkg: string; + skillId: string; + webhookUrl: string; + configHash: string; +} + +export interface IsolatedRunnerEnvInput { + pkg: string; + sensorId: string; + skillId: string; + ingestUrl: string; + hmacSecret: string; + statePath: string; + logLevel?: string; +} + +export function buildIsolatedRunnerEnv( + input: IsolatedRunnerEnvInput, +): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + env[key] = value; + } + } + + return { + ...env, + W2A_PACKAGE: input.pkg, + W2A_INGEST_URL: input.ingestUrl, + W2A_HMAC_SECRET: input.hmacSecret, + W2A_SENSOR_ID: input.sensorId, + W2A_SKILL_ID: input.skillId, + W2A_STATE_PATH: input.statePath, + W2A_LOG_LEVEL: input.logLevel ?? process.env.W2A_LOG_LEVEL ?? "info", + }; +} + +export function shouldRestartIsolatedHandle( + handle: IsolatedProcessMeta, + entry: SensorEntry, + ingestUrl: string, +): boolean { + return !( + handle.pkg === entry.pkg && + handle.skillId === entry.skill_id && + handle.webhookUrl === ingestUrl && + handle.configHash === hashConfig(entry.config) + ); +} + +export async function readJsonFromStdin( + stdin: AsyncIterable = process.stdin, +): Promise> { + let raw = ""; + for await (const chunk of stdin) { + raw += chunk.toString(); + } + + const text = raw.trim(); + if (!text) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error( + `Invalid sensor config JSON on stdin: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Sensor config JSON must be an object"); + } + + return parsed as Record; +} + +export function resolveImportTarget(pkg: string): string { + if (pkg.startsWith(".") || pkg.startsWith("/") || isAbsolute(pkg)) { + return pathToFileURL(resolve(pkg)).href; + } + return pkg; +} + +export function hashConfig(config: unknown): string { + return createHash("sha1").update(stableStringify(config)).digest("hex"); +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + const obj = value as Record; + return `{${Object.keys(obj) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`) + .join(",")}}`; +} diff --git a/openclaw-plugin/src/types.ts b/openclaw-plugin/src/types.ts new file mode 100644 index 0000000..ec8a10b --- /dev/null +++ b/openclaw-plugin/src/types.ts @@ -0,0 +1,104 @@ +import type { CleanupFn, W2ASignal } from "@world2agent/sdk"; +import type { + OpenClawConfig, + OpenClawPluginApi, + OpenClawPluginConfig, +} from "./openclaw/plugin-sdk/types.js"; + +export interface World2AgentPaths { + baseDir: string; + manifestFile: string; + stateDir: string; + sessionDir: string; + openclawHome: string; + openclawSkillsDir: string; + ingestHmacSecretFile: string; +} + +export interface SensorEntry { + sensor_id: string; + pkg: string; + skill_id: string; + enabled: boolean; + isolated?: boolean; + config: Record; +} + +export interface SensorManifest { + version: 1; + sensors: SensorEntry[]; +} + +export interface DispatcherDispatchInput { + sensorId: string; + skillId: string; + signal: W2ASignal; + sessionId?: string; +} + +export interface Dispatcher { + dispatch(input: DispatcherDispatchInput): Promise; +} + +export interface EmbeddedDispatcherOptions { + api: OpenClawPluginApi; + openclawConfigRef: { current: OpenClawConfig }; + pluginConfig: RequiredWorld2AgentPluginConfig; + paths: World2AgentPaths; +} + +export interface HttpIngestEnvelope { + sensor_id: string; + skill_id: string; + signal: W2ASignal; +} + +export interface HttpDispatcherOptions { + embeddedDispatcher: Dispatcher; + hmacSecret: string; + dedupTtlMs: number; +} + +export interface RuntimeHandle { + sensorId: string; + pkg: string; + skillId: string; + isolated: boolean; + configHash: string; + startedAt: number; + cleanup: CleanupFn; + flush?: () => Promise; +} + +export interface ApplyResult { + started: string[]; + restarted: string[]; + stopped: string[]; + failed: Array<{ sensor_id: string; error: string }>; +} + +export interface RequiredWorld2AgentPluginConfig { + sensorsManifestPath?: string; + stateDir?: string; + sessionDir?: string; + workspaceDir?: string; + ingestUrl?: string; + defaultAgentId: string; + provider?: string; + model?: string; + requestTimeoutMs: number; + ingestHmacSecretFile?: string; + ingestDedupTtlMs: number; +} + +export interface IsolatedRunnerHandle { + sensorId: string; + pkg: string; + skillId: string; + isolated: true; + configHash: string; + startedAt: number; + cleanup: CleanupFn; +} + +export type ParsedPluginConfig = OpenClawPluginConfig; diff --git a/openclaw-plugin/test/context-injection.test.ts b/openclaw-plugin/test/context-injection.test.ts new file mode 100644 index 0000000..1df44f1 --- /dev/null +++ b/openclaw-plugin/test/context-injection.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createWorld2AgentPlugin } from "../src/plugin.js"; + +const ORIGINAL_W2A_HOME = process.env.W2A_HOME; +const ORIGINAL_OPENCLAW_HOME = process.env.OPENCLAW_HOME; + +describe("contextInjection startup check", () => { + afterEach(() => { + process.env.W2A_HOME = ORIGINAL_W2A_HOME; + process.env.OPENCLAW_HOME = ORIGINAL_OPENCLAW_HOME; + }); + + it("fails register() synchronously when agents.defaults.contextInjection is not continuation-skip", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-register-")); + process.env.W2A_HOME = join(root, "w2a"); + process.env.OPENCLAW_HOME = join(root, "openclaw"); + + const plugin = createWorld2AgentPlugin(); + expect(() => + plugin.register({ + config: { + agents: { + defaults: { + contextInjection: "always", + }, + }, + }, + pluginConfig: {}, + }), + ).toThrow( + "OpenClaw config field `agents.defaults.contextInjection` must be set to \"continuation-skip\"", + ); + }); + + it("register() returns synchronously (not a promise) — OpenClaw drops async registers", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-sync-")); + process.env.W2A_HOME = join(root, "w2a"); + process.env.OPENCLAW_HOME = join(root, "openclaw"); + + const plugin = createWorld2AgentPlugin(); + const result = plugin.register({ + registrationMode: "cli-metadata", + pluginConfig: {}, + registerCli: () => {}, + }); + expect(result).toBeUndefined(); + }); +}); + diff --git a/openclaw-plugin/test/dispatch.test.ts b/openclaw-plugin/test/dispatch.test.ts new file mode 100644 index 0000000..5cfb446 --- /dev/null +++ b/openclaw-plugin/test/dispatch.test.ts @@ -0,0 +1,146 @@ +import { createHmac } from "node:crypto"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { EmbeddedDispatcher, HttpDispatcher } from "../src/dispatch.js"; +import type { EmbeddedAgentRunRequest } from "../src/openclaw/plugin-sdk/types.js"; +import type { HttpIngestEnvelope, World2AgentPaths } from "../src/types.js"; + +const TEST_SIGNAL = { + signal_id: "sig-1", + schema_version: "w2a/0.1" as const, + emitted_at: Date.now(), + source: { + sensor_id: "@world2agent/sensor-fake-tick", + sensor_version: "0.0.1", + source_type: "fake", + user_identity: "unknown", + package: "@world2agent/sensor-fake-tick", + }, + event: { + type: "news.item.created", + occurred_at: Date.now(), + summary: "A fake tick signal fired for dispatcher tests.", + }, +}; + +describe("EmbeddedDispatcher", () => { + it("serializes by sensor session and renders the prompt-prefix fallback", async () => { + const calls: EmbeddedAgentRunRequest[] = []; + const dispatcher = new EmbeddedDispatcher({ + api: { + runtime: { + agent: { + runEmbeddedAgent: vi.fn(async (request: EmbeddedAgentRunRequest) => { + calls.push(request); + return { ok: true }; + }), + }, + }, + }, + openclawConfigRef: { + current: { + agents: { + defaults: { + contextInjection: "continuation-skip", + }, + list: [], + }, + }, + }, + pluginConfig: { + defaultAgentId: "world2agent", + requestTimeoutMs: 12_345, + ingestDedupTtlMs: 3_600_000, + }, + paths: makePaths("/tmp/w2a-openclaw-dispatch"), + }); + + await dispatcher.dispatch({ + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.sessionId).toBe("w2a-fake-tick"); + expect(calls[0]?.sessionKey).toBe("w2a:fake-tick"); + expect(calls[0]?.timeoutMs).toBe(12_345); + expect(calls[0]?.prompt.startsWith("Use skill: world2agent-sensor-fake-tick")).toBe( + true, + ); + }); +}); + +describe("HttpDispatcher", () => { + it("validates HMAC and dedups X-Request-ID", async () => { + const dispatch = vi.fn(async () => ({ ok: true })); + const http = new HttpDispatcher({ + embeddedDispatcher: { dispatch }, + hmacSecret: "secret", + dedupTtlMs: 60_000, + }); + + const payload: HttpIngestEnvelope = { + sensor_id: "fake-tick", + skill_id: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + }; + const body = JSON.stringify(payload); + const signature = createHmac("sha256", "secret").update(body).digest("hex"); + + const first = await invokeRoute(http, body, "req-1", signature); + const second = await invokeRoute(http, body, "req-1", signature); + + expect(first.statusCode).toBe(202); + expect(second.statusCode).toBe(202); + expect(second.body).toContain("\"deduped\": true"); + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); + +async function invokeRoute( + http: HttpDispatcher, + body: string, + requestId: string, + signature: string, +): Promise<{ statusCode: number; body: string }> { + const req = new PassThrough() as PassThrough & { + headers: Record; + method: string; + }; + req.headers = { + "x-request-id": requestId, + "x-webhook-signature": signature, + }; + req.method = "POST"; + req.end(body); + + let responseBody = ""; + const res = { + statusCode: 200, + setHeader: vi.fn(), + end: vi.fn((value?: string) => { + responseBody = value ?? ""; + }), + }; + + await http.handle(req as any, res as any); + + return { + statusCode: res.statusCode, + body: responseBody, + }; +} + +function makePaths(root: string): World2AgentPaths { + return { + baseDir: root, + manifestFile: `${root}/sensors.json`, + stateDir: `${root}/state`, + sessionDir: `${root}/sessions`, + openclawHome: `${root}/.openclaw`, + openclawSkillsDir: `${root}/.openclaw/skills`, + ingestHmacSecretFile: `${root}/.secret`, + }; +} + diff --git a/openclaw-plugin/test/manifest.test.ts b/openclaw-plugin/test/manifest.test.ts new file mode 100644 index 0000000..52f9ed1 --- /dev/null +++ b/openclaw-plugin/test/manifest.test.ts @@ -0,0 +1,89 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + readManifest, + removeSensorEntry, + upsertSensorEntry, + writeManifest, +} from "../src/manifest.js"; +import type { World2AgentPaths } from "../src/types.js"; + +describe("manifest helpers", () => { + it("writes and reads a normalized manifest", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-manifest-")); + const paths = makePaths(root); + + await writeManifest(paths, { + version: 1, + sensors: [ + { + sensor_id: "hackernews", + pkg: "@world2agent/sensor-hackernews", + skill_id: "ignored-on-write", + enabled: true, + isolated: true, + config: { interval_ms: 30_000 }, + }, + ], + }); + + const manifest = await readManifest(paths); + expect(manifest).toEqual({ + version: 1, + sensors: [ + { + sensor_id: "hackernews", + pkg: "@world2agent/sensor-hackernews", + skill_id: "world2agent-sensor-hackernews", + enabled: true, + isolated: true, + config: { interval_ms: 30_000 }, + }, + ], + }); + }); + + it("upserts and removes entries by sensor id", () => { + const initial = { + version: 1 as const, + sensors: [], + }; + + const afterInsert = upsertSensorEntry(initial, { + sensor_id: "news", + pkg: "@world2agent/sensor-hackernews", + skill_id: "world2agent-sensor-hackernews", + enabled: true, + config: {}, + }); + const afterUpdate = upsertSensorEntry(afterInsert, { + sensor_id: "news", + pkg: "@world2agent/sensor-hackernews", + skill_id: "world2agent-sensor-hackernews", + enabled: true, + config: { interval_ms: 60_000 }, + }); + + expect(afterUpdate.sensors).toHaveLength(1); + expect(afterUpdate.sensors[0]?.config).toEqual({ interval_ms: 60_000 }); + + const removed = removeSensorEntry(afterUpdate, "news"); + expect(removed.removed?.sensor_id).toBe("news"); + expect(removed.manifest.sensors).toEqual([]); + }); +}); + +function makePaths(root: string): World2AgentPaths { + return { + baseDir: root, + manifestFile: join(root, "sensors.json"), + stateDir: join(root, "state"), + sessionDir: join(root, "sessions"), + openclawHome: join(root, ".openclaw"), + openclawSkillsDir: join(root, ".openclaw", "skills"), + ingestHmacSecretFile: join(root, ".secret"), + }; +} + diff --git a/openclaw-plugin/test/supervisor-shared.test.ts b/openclaw-plugin/test/supervisor-shared.test.ts new file mode 100644 index 0000000..a09adb4 --- /dev/null +++ b/openclaw-plugin/test/supervisor-shared.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + buildIsolatedRunnerEnv, + hashConfig, + readJsonFromStdin, + shouldRestartIsolatedHandle, +} from "../src/supervisor/shared.js"; + +describe("supervisor shared boundary", () => { + it("builds the isolated runner env expected by the reused runner contract", () => { + const env = buildIsolatedRunnerEnv({ + pkg: "@world2agent/sensor-fake-tick", + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + ingestUrl: "http://127.0.0.1:3333/w2a/ingest", + hmacSecret: "secret", + statePath: "/tmp/fake-tick.json", + }); + + expect(env.W2A_PACKAGE).toBe("@world2agent/sensor-fake-tick"); + expect(env.W2A_SENSOR_ID).toBe("fake-tick"); + expect(env.W2A_SKILL_ID).toBe("world2agent-sensor-fake-tick"); + expect(env.W2A_INGEST_URL).toBe("http://127.0.0.1:3333/w2a/ingest"); + }); + + it("detects whether an isolated handle needs restart", () => { + const same = shouldRestartIsolatedHandle( + { + sensorId: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skillId: "world2agent-sensor-fake-tick", + webhookUrl: "http://127.0.0.1:3333/w2a/ingest", + configHash: hashConfig({ + interval_ms: 60_000, + }), + }, + { + sensor_id: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skill_id: "world2agent-sensor-fake-tick", + enabled: true, + isolated: true, + config: { + interval_ms: 60_000, + }, + }, + "http://127.0.0.1:3333/w2a/ingest", + ); + + const changed = shouldRestartIsolatedHandle( + { + sensorId: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skillId: "world2agent-sensor-fake-tick", + webhookUrl: "http://127.0.0.1:3333/w2a/ingest", + configHash: "old", + }, + { + sensor_id: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skill_id: "world2agent-sensor-fake-tick", + enabled: true, + isolated: true, + config: { + interval_ms: 60_000, + }, + }, + "http://127.0.0.1:3333/w2a/ingest", + ); + + expect(same).toBe(false); + expect(changed).toBe(true); + }); + + it("parses config JSON from stdin-compatible streams", async () => { + async function* chunks() { + yield '{"interval_ms":60000}'; + } + + await expect(readJsonFromStdin(chunks())).resolves.toEqual({ + interval_ms: 60_000, + }); + }); +}); diff --git a/openclaw-plugin/tsconfig.json b/openclaw-plugin/tsconfig.json new file mode 100644 index 0000000..3205970 --- /dev/null +++ b/openclaw-plugin/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "declaration": true, + "outDir": "dist", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*" + ] +} + diff --git a/openclaw-plugin/vitest.config.ts b/openclaw-plugin/vitest.config.ts new file mode 100644 index 0000000..2233cef --- /dev/null +++ b/openclaw-plugin/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); + From cc85bd74af6474c98e57ea314fd1d83a0bad8031 Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 16:08:13 +0800 Subject: [PATCH 2/4] feat(openclaw-plugin): improve sensor onboarding and dispatching --- openclaw-plugin/README.md | 155 +++- openclaw-plugin/openclaw.plugin.json | 4 +- openclaw-plugin/pnpm-lock.yaml | 793 ++++++++++++++++++ .../skills/world2agent-manage/SKILL.md | 206 ++++- openclaw-plugin/src/cli.ts | 53 +- openclaw-plugin/src/config.ts | 32 +- openclaw-plugin/src/dispatch.ts | 222 ++++- openclaw-plugin/src/install.ts | 53 +- openclaw-plugin/src/manifest.ts | 2 +- .../src/openclaw/plugin-sdk/types.ts | 82 +- openclaw-plugin/src/runtime.ts | 22 +- openclaw-plugin/test/dispatch.test.ts | 22 +- openclaw-plugin/test/manifest.test.ts | 30 +- 13 files changed, 1552 insertions(+), 124 deletions(-) create mode 100644 openclaw-plugin/pnpm-lock.yaml diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 266168d..7fb578d 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -1,42 +1,141 @@ # @world2agent/openclaw-plugin -Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into embedded OpenClaw agent turns. +Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into OpenClaw agent turns as **system events** (not user messages). -The default path is in-process: enabled sensors are imported directly inside the plugin process and each signal is sent to `api.runtime.agent.runEmbeddedAgent(...)`. `isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. +The default path is in-process: enabled sensors are imported directly inside the plugin process, and each emitted signal is enqueued as a system event for a dedicated agent. OpenClaw drains queued system events at the start of the next agent turn and prepends them to the prompt as `System:` lines — matching the semantics `claude-code-channel` uses with MCP `notifications/claude/channel`. + +`isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. ## Install -1. Set the required OpenClaw agent config first: +> ⚠️ OpenClaw config is **JSON**, not YAML. All steps below assume `~/.openclaw/openclaw.json`. + +### 1. Set the contextInjection prerequisite + +The plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"` (otherwise OpenClaw's default `"always"` re-injects bootstrap on every signal and silently turns sensors into a token sink). + +```bash +# Edit in place (no `sponge` dependency — works on stock macOS / Linux) +jq '.agents.defaults.contextInjection = "continuation-skip"' \ + ~/.openclaw/openclaw.json > /tmp/openclaw.json.tmp && \ + mv /tmp/openclaw.json.tmp ~/.openclaw/openclaw.json + +# Verify +jq '.agents.defaults.contextInjection' ~/.openclaw/openclaw.json +# → "continuation-skip" +``` + +### 2. Install the plugin + +> ⚠️ The plugin uses `child_process` for sensor subprocess management (required for `isolated: true` mode and for npm install/uninstall of sensor packages). OpenClaw's built-in security scan **blocks** plugins with `child_process` by default. The output will show a wall of `WARNING` lines listing every `child_process` site — **that is expected**. The actual success markers are at the bottom: `Linked plugin path` (or `Installed plugin`) and `Restart the gateway to load plugins`. + +#### Standard (from npm) + +```bash +openclaw plugins install @world2agent/openclaw-plugin --dangerously-force-unsafe-install +openclaw gateway restart +``` + +#### Contributors / pre-release testing (from local source) + +If you're hacking on this plugin and haven't published to npm yet: + +```bash +cd world2agent-plugins/openclaw-plugin +pnpm install +pnpm build + +# Use an ABSOLUTE path. OpenClaw's `plugins install` also accepts hook packs +# (a different concept) — a relative or `~` path can be misclassified and +# yield a confusing "HOOK.md missing in ..." error. Absolute path tells +# OpenClaw "this is the plugin you just built." +openclaw plugins install -l --dangerously-force-unsafe-install \ + "$(pwd)" +openclaw gateway restart +``` + +Verify it loaded: + +```bash +openclaw plugins list | grep world2agent +# → │ World2Agent │ world2agent │ openclaw │ enabled │ ... │ 0.0.0-dev │ +openclaw world2agent --help +# → Commands: reload, sensor +``` + +### 4. Subscribe to your first source — by talking to OpenClaw + +> ℹ️ By default W2A signals lane through your **existing `main` agent** but on a different sessionKey (one per sensor), so they don't pollute your normal chat session. If you'd rather route them to a dedicated agent for full isolation, set `defaultAgentId: "world2agent"` (or any other id) in this plugin's config and `openclaw agents add ` first. - ```yaml - agents: - defaults: - contextInjection: continuation-skip - ``` +The preferred path is conversational. Just tell main agent what you want: -2. Install dependencies and build this package: +```bash +openclaw chat --agent main +``` - ```bash - cd world2agent-plugins/openclaw-plugin - pnpm install - pnpm build - ``` +``` +> 帮我订阅 Hacker News,我关心 AI 和安全话题 +``` -3. Add the plugin package to your OpenClaw plugin search/install path and enable `@world2agent/openclaw-plugin`. +The plugin ships a `world2agent-manage` skill that activates on this kind of intent. Main agent will: -4. Use the registered CLI: +1. Read the sensor's `SETUP.md` (e.g. `node_modules/@world2agent/sensor-hackernews/SETUP.md`) +2. Ask you 1–3 questions defined in that file (poll thresholds, your topics of interest, reply depth) +3. Fill the SKILL.md template with your answers and write it to `~/.openclaw/skills/world2agent-sensor-hackernews/SKILL.md` +4. Run `openclaw world2agent sensor add ... --skip-generate-skill` to register +5. Tell you when the first signal will arrive - ```bash - openclaw world2agent sensor list - openclaw world2agent sensor add @world2agent/sensor-hackernews --config-file ./hackernews.json - ``` +This personalized SKILL.md is what makes the agent reply meaningfully to relevant signals (instead of skipping every signal silently because it has no anchor for "what's relevant to this user"). + +#### CLI fallback (power users / scripting) + +If you want to script the install or skip the Q&A, you can still call the CLI directly: + +```bash +openclaw world2agent sensor add @world2agent/sensor-hackernews \ + --config-json '{"top_n":10,"min_score":50,"min_comments":0,"interval_seconds":60}' +``` + +Without `--skip-generate-skill`, the CLI will write a **generic** SKILL.md to `~/.openclaw/skills/world2agent-sensor-hackernews/SKILL.md` (only if no SKILL.md is already there). The generic skill makes the agent reply briefly to every signal — fine for testing, noisy for daily use. Edit that file later to add filtering rules. + +> ⚠️ Plugin config is cached at register time — newly-added sensors are visible to the running plugin only after a reload: + +```bash +openclaw world2agent reload +# falls back to `openclaw gateway restart` if reload times out +``` + +Within ~60 seconds the sensor will start polling. Each emitted signal triggers an agent turn under sessionKey `agent:main:w2a-`, with the signal framed as a `# System Event` block. + +## Where to view signal-driven agent runs + +Each sensor gets a stable session id `w2a-`, scoped to your default agent (`main` unless you overrode `defaultAgentId`). Signals are injected via OpenClaw's system-event queue + heartbeat — **the W2A signal arrives as a `System:` block in the agent turn, not as a user message**. You can view the resulting conversation in three ways: + +```bash +# CLI — list W2A sessions on the main agent +openclaw sessions --agent main --active 60 +# (or `--agent world2agent` if you set defaultAgentId to a dedicated agent) + +# Dashboard — open the OpenClaw control UI +open http://127.0.0.1:18789/ + +# Direct file access (for debugging) +ls ~/.openclaw/agents/main/sessions/ +# w2a-hackernews.jsonl ← session metadata +# w2a-hackernews.trajectory.jsonl ← full LLM tool-call trajectory +# sessions.json ← OpenClaw session index (includes +# `agent:main:w2a-` lanes +# alongside `agent:main:main` chat lane) +``` + +Your normal chat with `main` agent (sessionKey `agent:main:main`) is **untouched** — W2A signals only show up under `agent:main:w2a-` lanes. ## Scope - Reads and writes the W2A sensor manifest at `~/.world2agent/sensors.json` by default. - Runs sensors in-process unless a sensor entry sets `isolated: true`. - Reuses the Hermes runner/supervisor patterns instead of inventing a second isolation protocol. -- Uses a stable per-sensor embedded-agent session id: `w2a:`. +- Uses a stable per-sensor session id: `w2a-` (and session key `agent::w2a-`). - Requires plugin config `ingestUrl` only when `isolated: true` sensors are used. ## ContextInjection Prerequisite @@ -45,10 +144,16 @@ This plugin refuses to start unless `agents.defaults.contextInjection` is exactl That check also runs before `openclaw world2agent sensor add`. There is no warning mode, no fallback mode, and no override flag. The design requires a hard failure because OpenClaw's default `"always"` setting would re-inject bootstrap on every sensor signal and silently turn high-frequency sensors into a token sink. -## Relation To `hermes-sensor-bridge` +## Relation to `hermes-sensor-bridge` + +`hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `enqueueSystemEvent(...)` + `runEmbeddedAgent(...)`. + +## Troubleshooting + +**Plugin install blocked by safety scanner**: that's the security warning about `child_process`. Use `--dangerously-force-unsafe-install` (see step 3). -`hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `runEmbeddedAgent(...)`. +**`openclaw world2agent --help` says "unknown command"**: gateway hasn't reloaded the plugin yet. Run `openclaw gateway restart`. -## Known M0 Spike +**Sensors run but `openclaw sessions --agent world2agent` is empty**: you skipped step 4 (`openclaw agents add world2agent`) or step 5's `openclaw world2agent reload`. Each sensor's `dispatch failed` will be logged in `/tmp/openclaw/openclaw-*.log` — grep for the sensor id. -`api.runtime.agent.runEmbeddedAgent(...)` from a third-party external plugin remains a live-install verification point. This package guards it defensively and throws a clear error if the runtime helper is absent, but a real OpenClaw install still has to confirm the end-to-end external-plugin path. +**Wizards or interactive commands hang on the same terminal as the gateway**: sensor logs go to a namespaced logger, but very early gateway boot output still goes to stdout. Run interactive commands (`openclaw agents add ...`) from a terminal that isn't tailing gateway logs. diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json index 550e803..6b0054b 100644 --- a/openclaw-plugin/openclaw.plugin.json +++ b/openclaw-plugin/openclaw.plugin.json @@ -34,8 +34,8 @@ }, "defaultAgentId": { "type": "string", - "default": "world2agent", - "description": "Dedicated OpenClaw agent id whose skills allowlist should receive W2A skills." + "default": "main", + "description": "OpenClaw agent id W2A signals lane through. Defaults to 'main' so signals share the user's existing main agent (different sessionKey per sensor — no cross-contamination of the user's chat session). Set to a dedicated agent id (e.g. 'world2agent') if you want full isolation; that agent must exist via `openclaw agents add `." }, "provider": { "type": "string", diff --git a/openclaw-plugin/pnpm-lock.yaml b/openclaw-plugin/pnpm-lock.yaml new file mode 100644 index 0000000..ab172bb --- /dev/null +++ b/openclaw-plugin/pnpm-lock.yaml @@ -0,0 +1,793 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@world2agent/sdk': + specifier: file:../../world2agent-typescript-sdk + version: file:../../world2agent-typescript-sdk(zod@3.25.76) + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.5.0 + version: 25.6.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)) + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + '@world2agent/sdk@file:../../world2agent-typescript-sdk': + resolution: {directory: ../../world2agent-typescript-sdk, type: directory} + engines: {node: '>=20'} + peerDependencies: + zod: ^3.25.0 + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.127.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@25.6.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@world2agent/sdk@file:../../world2agent-typescript-sdk(zod@3.25.76)': + dependencies: + zod: 3.25.76 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.12: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.1.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + + vite@8.0.10(@types/node@25.6.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.12 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 + + vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@25.6.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@3.25.76: {} diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md index 69ca8a7..15b660d 100644 --- a/openclaw-plugin/skills/world2agent-manage/SKILL.md +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -6,74 +6,208 @@ user-invocable: false # World2Agent Sensor Management -You manage the user's World2Agent sensors on this OpenClaw machine. - -All mutations go through the `openclaw world2agent` CLI. The shell scripts in -`scripts/` are thin wrappers around those commands. +You manage the user's World2Agent sensors on this OpenClaw machine. Sensors +are long-running probes that watch the outside world (news, repos, markets, +pages, calendars) and dispatch structured signals back into the agent as +`# System Event` turns. ## Prerequisite -Before adding sensors, OpenClaw must be configured with: +OpenClaw must have `agents.defaults.contextInjection` set to +`"continuation-skip"` in `~/.openclaw/openclaw.json` (it's JSON, not YAML). +The plugin refuses to start otherwise. Verify before doing anything else: -```yaml -agents: - defaults: - contextInjection: continuation-skip +```bash +jq '.agents.defaults.contextInjection' ~/.openclaw/openclaw.json ``` -If that field is not set exactly, `openclaw world2agent sensor add` will fail on -purpose. Do not try to work around it. +If the value is anything other than `"continuation-skip"`, ask the user for +permission to fix it, then run: -## List sensors +```bash +jq '.agents.defaults.contextInjection = "continuation-skip"' \ + ~/.openclaw/openclaw.json > /tmp/oc.tmp && \ + mv /tmp/oc.tmp ~/.openclaw/openclaw.json +``` + +## Install a sensor (the conversational flow — preferred) + +When the user expresses interest in an outside-world source ("subscribe me to +Hacker News", "watch this GitHub repo", "ping me on calendar events"), drive +the install end-to-end through dialogue. Do NOT just shell out to the CLI +with default config — the auto-generated handler is generic and the agent +will not reply meaningfully to signals without the user's preferences baked +in. + +### Step 1 — Identify the package + +Map the user's phrase to an npm package name. Common ones: + +- "Hacker News" / "HN" → `@world2agent/sensor-hackernews` +- "GitHub releases" / "watch repo" → `@world2agent/sensor-github` +- generic feed → ask the user for the npm package name + +If unsure, look up what's available: + +- **Sensor hub (canonical catalog)**: — browse + every published sensor with its description, configuration parameters, + and the events it emits. Use the WebFetch tool on this URL to enumerate + available sensors when the user asks "what can I subscribe to?". +- **npm discovery**: `npm search @world2agent/sensor- --json | jq '.[] | {name, description}'` +- **Already installed locally**: read `~/.world2agent/sensors.json`. + +If the user describes something that doesn't match any sensor on the hub +(e.g. "subscribe to my Notion tasks" with no Notion sensor), say so plainly +and offer two paths: pick the closest existing sensor, or write a new +sensor following the W2A SDK template (linked from the hub). -Run: +Confirm the package name with the user before continuing. + +### Step 2 — Read the sensor's SETUP.md + +Every sensor package ships a `SETUP.md` that defines: +- the configuration parameters the sensor takes (with defaults) +- the questions YOU must ask the user, one at a time, in their language +- the SKILL.md template to fill from the user's answers + +To locate SETUP.md you first need the plugin's install directory. Use +`openclaw plugins list --json` (the canonical way — works in both link mode +and copy mode): ```bash -bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/list.sh" +PLUGIN_DIR=$(openclaw plugins list --json | \ + jq -r '.plugins[] | select(.id == "world2agent") | .rootDir') +echo "$PLUGIN_DIR" +# → e.g. /Users//Documents/.../openclaw-plugin ``` -## Install a sensor +Then check whether the sensor package is already installed there: + +```bash +SETUP="$PLUGIN_DIR/node_modules//SETUP.md" +ls "$SETUP" 2>/dev/null || echo "not installed yet" +``` -1. Confirm the npm package name with the user. -2. Inspect the sensor package's `SETUP.md` to determine the config fields it needs. -3. Write a temporary JSON file containing the sensor config object only. -4. Run: +If it isn't installed, install it in-place (no manifest mutation, just +populates `node_modules/`): ```bash -bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/add.sh" --config-file +( cd "$PLUGIN_DIR" && npm install --no-save ) ``` +Then read SETUP.md with the Read tool, passing the absolute path +`$PLUGIN_DIR/node_modules//SETUP.md`. + +(Reading SETUP.md upfront — before `sensor add` — is preferred so the user +can answer questions before any state mutation. The actual sensor +registration happens in step 6.) + +### Step 3 — Run the Q&A, one question at a time + +SETUP.md lists 1-3 questions under "Questions to Ask". Ask them ONE AT A TIME, +in the user's language, waiting for each answer before continuing. Do NOT +batch-ask. Do NOT invent your own questions. Do NOT skip questions even if +the user seems impatient — every placeholder in the SKILL.md template +corresponds to one of these answers. + +### Step 4 — Fill the SKILL.md template + +SETUP.md provides a template in its "Output" section, with placeholders like +`[USER_TOPICS]`, `[USER_NORMAL_STYLE]`, `[USER_DEEP_DIVE_THRESHOLD]`. Replace +each placeholder with the user's answer (or the default the user accepted). +Show the filled SKILL.md to the user for confirmation before writing. + +### Step 5 — Write the personalized SKILL.md + +Write to `~/.openclaw/skills//SKILL.md` (NOT to Claude Code's +`~/.claude/skills/...` — that's the channel-side path, irrelevant here). +Compute `` from the package name: strip leading `@`, replace `/` +with `-`. Example: `@world2agent/sensor-hackernews` → +`world2agent-sensor-hackernews`. + +```bash +mkdir -p ~/.openclaw/skills/ +# Then write SKILL.md via the Write tool, with the filled template. +``` + +### Step 6 — Register the sensor with the plugin + +Build the sensor's config JSON object (just the `config` block from SETUP.md's +"Configuration Parameters" table, with the user's answers). Then call: + +```bash +openclaw world2agent sensor add \ + --config-json '' \ + --skip-generate-skill +``` + +The `--skip-generate-skill` flag is critical: it tells the CLI to keep the +personalized SKILL.md you just wrote in step 5. Without it, the CLI's +fallback would overwrite your work with a generic template. + Optional flags: +- `--sensor-id ` for a non-default instance id (only if the user wants + multiple instances of the same sensor) +- `--isolated` to run the sensor out-of-process (for unstable third-party + sensors) -- `--sensor-id ` if the user wants a non-default instance id. -- `--isolated` if the sensor should run out-of-process. +### Step 7 — Confirm and reload -Never invent credentials or secrets. Ask the user when the config requires them. +If the CLI's `reload` field returns `ok: true`, the sensor is polling. If +not, ask the user to run `openclaw gateway restart` (the plugin process +caches its config at register time). -## Remove a sensor +Tell the user: +- which sensor id was created +- where the personalized SKILL.md lives +- when to expect the first signal (sensor's poll interval) -Run: +## List sensors + +```bash +openclaw world2agent sensor list +``` + +Returns the manifest plus the current `contextInjection` value. + +## Remove a sensor ```bash -bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/remove.sh" +openclaw world2agent sensor remove ``` -Pass `--purge` only if the user explicitly wants the generated OpenClaw skill -directory removed too. +Add `--purge` only if the user wants the generated handler skill directory +deleted too (this is destructive — confirm first). -## Reload sensors +## Reload after manual edits -Run: +If the user hand-edited `~/.world2agent/sensors.json` or a personalized +SKILL.md, run: ```bash -bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/reload.sh" +openclaw world2agent reload ``` -## Output style +If reload fails, fall back to `openclaw gateway restart`. -After each action, summarize: +## Common mistakes to avoid -- which sensor ids were affected -- whether the reload succeeded -- any warnings or errors returned by the CLI +- Do NOT skip the SETUP.md Q&A flow. Without `[USER_TOPICS]` / + `[USER_NORMAL_STYLE]` (or whatever the SETUP.md template defines) filled in, + the agent has no anchor for "what's relevant" and will skip most signals + silently — burning tokens on `NO_REPLY` turns. +- Do NOT write SKILL.md to `~/.claude/skills/...`. That's the channel-side + (Claude Code) path. OpenClaw reads from `~/.openclaw/skills/...`. +- Do NOT invent credentials. If SETUP.md asks for an API key, ask the user. +- Do NOT call `sensor add` before writing SKILL.md if you intend to + personalize. The CLI's fallback will skip when SKILL.md exists, but the + cleaner ordering is: write SKILL.md first, then `sensor add + --skip-generate-skill`. +## Output style + +After each action, summarize concisely: +- which sensor ids were affected +- whether reload succeeded (or instruct user to restart gateway) +- where the personalized SKILL.md lives, so the user knows what to edit + later if their preferences change diff --git a/openclaw-plugin/src/cli.ts b/openclaw-plugin/src/cli.ts index 32705d6..451bac3 100644 --- a/openclaw-plugin/src/cli.ts +++ b/openclaw-plugin/src/cli.ts @@ -43,8 +43,19 @@ export function registerWorld2AgentCli(services: World2AgentCliServices): void { .command("add ") .description("Install and configure a sensor") .option("--sensor-id ", "Override the default sensor id") - .option("--config-file ", "Path to the sensor config JSON file") + .option( + "--config-file ", + "Path to a sensor config JSON file", + ) + .option( + "--config-json ", + "Inline JSON config string (alternative to --config-file)", + ) .option("--isolated", "Run this sensor out-of-process") + .option( + "--skip-generate-skill", + "Do not auto-generate a fallback SKILL.md. Use this when the calling agent has already written a personalized handler skill via the SETUP.md Q&A flow.", + ) .action(async (pkg: string, options: Record) => { printJson(await runAddCommand(services, pkg, options)); }); @@ -100,10 +111,21 @@ async function runAddCommand( const installed = await ensurePackageInstalled(pkg); const sensorId = optionString(options, "sensorId") ?? defaultSensorId(pkg); const configFile = optionString(options, "configFile"); + const configJson = optionString(options, "configJson"); const isolated = optionBoolean(options, "isolated"); + const skipGenerateSkill = optionBoolean(options, "skipGenerateSkill"); const skillId = packageToSkillId(pkg); - const sensorConfig = await loadConfigFile(configFile, installed); - await writeGeneratedSkill(services.paths, pkg, installed); + const sensorConfig = await loadConfigFile(configFile, configJson, installed); + + // The agent-driven path (world2agent-manage skill running SETUP.md Q&A) + // writes a personalized SKILL.md before invoking this command and passes + // --skip-generate-skill. The fallback path (direct CLI use) lets the + // helper write a generic SKILL.md, but only when the file doesn't exist. + let skillGenerated: { written: boolean } = { written: false }; + if (!skipGenerateSkill) { + const result = await writeGeneratedSkill(services.paths, pkg, installed); + skillGenerated = { written: result.written }; + } const manifest = await readManifest(services.paths); const entry: SensorEntry = { @@ -129,6 +151,8 @@ async function runAddCommand( sensor_id: sensorId, skill_id: skillId, isolated, + skill_generated: skillGenerated.written, + skill_path: join(services.paths.openclawSkillsDir, skillId, "SKILL.md"), allowlist, reload, }; @@ -196,8 +220,27 @@ async function maybePersistAllowlist( skillId: string, ): Promise { const result = upsertDedicatedAgentSkillAllowlist(config, agentId, skillId); - if (result.changed && typeof api.runtime?.config?.writeConfigFile === "function") { - await api.runtime.config.writeConfigFile(result.nextConfig); + if (result.changed) { + const cfg = api.runtime?.config; + // OpenClaw's runtime APIs take a params object, not the config directly: + // replaceConfigFile({ nextConfig, ... }) + // mutateConfigFile({ mutate: (draft) => { ...mutate in place... } }) + // Passing the config bare causes OpenClaw to destructure it as params and + // see `params.nextConfig === undefined`, which fails schema validation. + if (typeof cfg?.replaceConfigFile === "function") { + await cfg.replaceConfigFile({ nextConfig: result.nextConfig }); + } else if (typeof cfg?.mutateConfigFile === "function") { + await cfg.mutateConfigFile({ + mutate: (draft) => { + const next = result.nextConfig as Record; + const target = draft as Record; + for (const key of Object.keys(target)) delete target[key]; + for (const [key, value] of Object.entries(next)) target[key] = value; + }, + }); + } else if (typeof cfg?.writeConfigFile === "function") { + await cfg.writeConfigFile(result.nextConfig); + } } return { changed: result.changed, diff --git a/openclaw-plugin/src/config.ts b/openclaw-plugin/src/config.ts index d22a810..655d2a0 100644 --- a/openclaw-plugin/src/config.ts +++ b/openclaw-plugin/src/config.ts @@ -11,6 +11,12 @@ export const REQUIRED_CONTEXT_INJECTION = "continuation-skip"; export async function loadEffectiveOpenClawConfig( api: OpenClawPluginApi, ): Promise { + // Prefer config.current() (newer API); fall back to deprecated loadConfig() + // for compat with older OpenClaw runtimes; final fallback is api.config + // (the static snapshot handed to register()). + if (typeof api.runtime?.config?.current === "function") { + return api.runtime.config.current(); + } if (typeof api.runtime?.config?.loadConfig === "function") { return api.runtime.config.loadConfig(); } @@ -42,7 +48,12 @@ export function normalizePluginConfig( sessionDir: asOptionalString(raw.sessionDir), workspaceDir: asOptionalString(raw.workspaceDir), ingestUrl: asOptionalString(raw.ingestUrl), - defaultAgentId: asOptionalString(raw.defaultAgentId) ?? "world2agent", + // Default to "main" so W2A signals lane through the user's existing main + // agent (different sessionKey, no cross-contamination of the user's + // chat session). Operators who want full isolation can set + // `defaultAgentId: "world2agent"` (or any other agent id) in plugin config + // — they then need `openclaw agents add ` to create that agent. + defaultAgentId: asOptionalString(raw.defaultAgentId) ?? "main", provider: asOptionalString((raw as Record).provider), model: asOptionalString((raw as Record).model), requestTimeoutMs: asPositiveInteger(raw.requestTimeoutMs) ?? 120_000, @@ -88,9 +99,22 @@ export function upsertDedicatedAgentSkillAllowlist( }; } - const currentSkills = Array.isArray(currentAgent.skills) - ? [...currentAgent.skills] - : []; + // Only extend an EXISTING allowlist. If the agent has no `skills` field, + // they have no allowlist (= every skill is accessible) and we must NOT + // create one out of nowhere — that would silently restrict the agent to + // just this one skill, breaking the conversational install flow for any + // future sensor (since `world2agent-manage` would no longer be reachable). + // The dispatcher's prompt-prefix fallback (`Use skill: `) covers the + // no-allowlist case correctly. + if (!Array.isArray(currentAgent.skills)) { + return { + changed: false, + nextConfig: config, + warning: null, + }; + } + + const currentSkills = [...currentAgent.skills]; if (currentSkills.includes(skillId)) { return { changed: false, diff --git a/openclaw-plugin/src/dispatch.ts b/openclaw-plugin/src/dispatch.ts index 7ebb112..ec0700f 100644 --- a/openclaw-plugin/src/dispatch.ts +++ b/openclaw-plugin/src/dispatch.ts @@ -1,9 +1,13 @@ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import { copyFile, mkdir, writeFile } from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { hasDedicatedAgentSkillsAllowlist } from "./config.js"; import { renderSignalPrompt } from "./prompt.js"; -import type { OpenClawConfig } from "./openclaw/plugin-sdk/types.js"; +import type { + OpenClawConfig, + OpenClawPluginApi, +} from "./openclaw/plugin-sdk/types.js"; import type { Dispatcher, DispatcherDispatchInput, @@ -13,7 +17,8 @@ import type { } from "./types.js"; const RUN_EMBEDDED_AGENT_ERROR = - "M0 spike unverified: api.runtime.agent.runEmbeddedAgent not found — verify against a live OpenClaw install"; + "OpenClaw runtime is missing api.runtime.agent.runEmbeddedAgent. Verify the " + + "plugin is running inside a live OpenClaw gateway."; export function assertEmbeddedAgentRuntime(options: EmbeddedDispatcherOptions): void { if (typeof options.api.runtime?.agent?.runEmbeddedAgent !== "function") { @@ -54,7 +59,7 @@ export class EmbeddedDispatcher implements Dispatcher { input: DispatcherDispatchInput, sessionId: string, ): Promise { - const prompt = renderSignalPrompt(input.signal, { + const signalText = renderSignalPrompt(input.signal, { skillId: input.skillId, useSkillPrefix: !hasDedicatedAgentSkillsAllowlist( this.options.openclawConfigRef.current, @@ -63,33 +68,84 @@ export class EmbeddedDispatcher implements Dispatcher { }); const config = this.options.openclawConfigRef.current; - const runtimeAgent = this.options.api.runtime!.agent!; + const api = this.options.api; + const runtimeAgent = api.runtime?.agent; const agentId = this.options.pluginConfig.defaultAgentId ?? "main"; - const sessionKey = `w2a:${input.sensorId}`; + // sessionKey is the colon-namespaced lane OpenClaw uses for system-event + // routing and heartbeat targeting. `agent::` matches + // OpenClaw's standard shape (e.g. main agent's chat is `agent:main:main`). + const sessionKey = `agent:${agentId}:${sessionId}`; const openclawHome = this.options.paths.openclawHome; + const agentDir = + tryCall(() => runtimeAgent?.resolveAgentDir?.(config, agentId)) ?? + join(openclawHome, "agents", agentId); + + const sessionsApi = runtimeAgent?.session; + const sessionFile = + tryCall(() => + sessionsApi?.resolveSessionFilePath?.(sessionId, undefined, { agentId }), + ) ?? join(agentDir, "sessions", `${sessionId}.jsonl`); + + const { provider, model } = resolveProviderAndModel( + config, + this.options.pluginConfig, + ); + + // ───────────────────────────────────────────────────────────────── + // Dispatch via runEmbeddedAgent + `# System Event` prompt prefix. + // + // Originally we tried OpenClaw's enqueueSystemEvent + requestHeartbeatNow + // to inject the signal as a true system message (matching the spirit of + // claude-code-channel's `notifications/claude/channel`). In OpenClaw + // 2026.4.26 that path *does* spawn a turn and *does* drain the queued + // event — but `drainFormattedSystemEvents` materializes the event as a + // text block prefixed with `System:` lines and **injects it into the + // user-role prompt**, not into a real system message. Net result: the + // signal still occupies user-role position in the transcript, just + // prefixed with the literal characters "System:". + // + // Until OpenClaw exposes a plugin API that writes a true system-role + // message, we use runEmbeddedAgent and frame the prompt inline. The + // agent treats the `# System Event` block as an external notification + // thanks to the framing, even though it lives in user-role position. + // ───────────────────────────────────────────────────────────────── + if (typeof runtimeAgent?.runEmbeddedAgent !== "function") { + throw new Error( + "OpenClaw runtime does not expose runEmbeddedAgent — this plugin cannot dispatch signals against this OpenClaw version.", + ); + } + const workspaceDir = this.options.pluginConfig.workspaceDir ?? tryCall(() => runtimeAgent.resolveAgentWorkspaceDir?.(config, agentId)) ?? join(openclawHome, "workspace"); - const agentDir = - tryCall(() => runtimeAgent.resolveAgentDir?.(config, agentId)) ?? - join(openclawHome, "agents", agentId); - const sessionFile = join(agentDir, "sessions", `${sessionId}.jsonl`); const timeoutMs = this.options.pluginConfig.requestTimeoutMs ?? tryCall(() => runtimeAgent.resolveAgentTimeoutMs?.(config)) ?? 120_000; - // OpenClaw's runEmbeddedAgent silently defaults to "openai/gpt-5.4" when - // provider/model are absent — it does NOT read agents.defaults.model.primary. - // Resolve the effective default ourselves so signal-driven runs follow the - // operator's configured model. - const { provider, model } = resolveProviderAndModel( - config, - this.options.pluginConfig, - ); + await ensureSessionRegistered({ + api, + agentId, + sessionId, + sessionKey, + sessionFile, + sensorId: input.sensorId, + provider, + model, + }); + + const promptForTurn = + "# System Event\n\n" + + "The following is an external event delivered by a World2Agent sensor. " + + "It is NOT a user request — do not address the user as if they typed " + + "this message. Load the referenced skill and apply its rules: the skill " + + "owns the policy for when to reply, how to format, and when to stay " + + "quiet. Defer to the skill, not to your own judgment about relevance.\n\n" + + "---\n\n" + + signalText; - return runtimeAgent.runEmbeddedAgent!({ + const result = await runtimeAgent.runEmbeddedAgent!({ sessionId, sessionKey, agentId, @@ -98,11 +154,137 @@ export class EmbeddedDispatcher implements Dispatcher { workspaceDir, agentDir, config, - prompt, + prompt: promptForTurn, timeoutMs, ...(provider ? { provider } : {}), ...(model ? { model } : {}), } as Parameters>[0]); + + await mirrorIsolatedSessionFiles(agentDir, sessionId).catch(() => undefined); + await ensureSessionRegistered({ + api, + agentId, + sessionId, + sessionKey, + sessionFile, + sensorId: input.sensorId, + provider, + model, + }).catch(() => undefined); + + return { ok: true, path: "embedded", result }; + } +} + +async function ensureSessionRegistered(params: { + api: OpenClawPluginApi; + agentId: string; + sessionId: string; + sessionKey: string; + sessionFile: string; + sensorId: string; + provider?: string; + model?: string; +}): Promise { + const now = Date.now(); + + // Build the entry once, used by both paths below. + const entryFor = (existing?: Record): Record => + existing + ? { ...existing, updatedAt: now, lastInteractionAt: now } + : { + sessionId: params.sessionId, + sessionFile: params.sessionFile, + sessionStartedAt: now, + startedAt: now, + updatedAt: now, + lastInteractionAt: now, + endedAt: null, + status: "idle", + origin: "world2agent", + chatType: "embedded", + lastChannel: "world2agent", + agentHarnessId: "claude-cli", + ...(params.model ? { model: params.model } : {}), + ...(params.provider ? { modelProvider: params.provider } : {}), + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheRead: 0, + cacheWrite: 0, + contextTokens: 0, + runtimeMs: 0, + }; + + // Try OpenClaw's session-store API first (preferred — it integrates with + // OpenClaw's in-memory caches). Best-effort; if it silently no-ops or + // throws we still get the file via the unconditional direct write below. + const sessionsApi = params.api.runtime?.agent?.session; + const load = sessionsApi?.loadSessionStore; + const save = sessionsApi?.saveSessionStore; + if (typeof load === "function" && typeof save === "function") { + try { + const store = (await load(params.agentId)) as Record; + store[params.sessionKey] = entryFor( + store[params.sessionKey] as Record | undefined, + ); + await save(params.agentId, store); + } catch { + // ignore — direct write below is the source of truth + } + } + + // ALWAYS write sessions.json directly. OpenClaw's `openclaw sessions + // --agent ` and the dashboard read this file; a plugin-side + // saveSessionStore call is opaque and can no-op silently in some + // OpenClaw versions, so we don't trust it as the only mechanism. + const sessionFileDir = dirname(params.sessionFile); + const storePath = join(sessionFileDir, "sessions.json"); + let raw: Record = {}; + try { + const fs = await import("node:fs/promises"); + const txt = await fs.readFile(storePath, "utf8"); + raw = JSON.parse(txt) as Record; + } catch { + raw = {}; + } + raw[params.sessionKey] = entryFor( + raw[params.sessionKey] as Record | undefined, + ); + await mkdir(sessionFileDir, { recursive: true }); + await writeFile(storePath, JSON.stringify(raw, null, 2) + "\n", "utf8"); +} + +async function mirrorIsolatedSessionFiles( + agentDir: string, + sessionId: string, +): Promise { + // runEmbeddedAgent writes to `/agent/sessions/.{jsonl,trajectory.jsonl,trajectory-path.json}`. + // OpenClaw's user-facing session viewer reads from `/sessions/.jsonl`. + // Mirror the three files so the dashboard can render the conversation. + const isolatedDir = join(agentDir, "agent", "sessions"); + const standardDir = join(agentDir, "sessions"); + await mkdir(standardDir, { recursive: true }); + for (const suffix of [".jsonl", ".trajectory.jsonl", ".trajectory-path.json"] as const) { + const src = join(isolatedDir, `${sessionId}${suffix}`); + const dst = join(standardDir, `${sessionId}${suffix}`); + try { + await copyFile(src, dst); + } catch { + // best-effort; missing files are fine for sessions that haven't been + // written yet (e.g. early failure in runEmbeddedAgent). + } + } + // Rewrite the trajectory pointer so it references the canonical path. + try { + const ptrPath = join(standardDir, `${sessionId}.trajectory-path.json`); + const fs = await import("node:fs/promises"); + const ptrText = await fs.readFile(ptrPath, "utf8"); + const ptr = JSON.parse(ptrText) as Record; + ptr.runtimeFile = join(standardDir, `${sessionId}.trajectory.jsonl`); + await fs.writeFile(ptrPath, JSON.stringify(ptr, null, 2), "utf8"); + } catch { + // ignore — pointer is non-critical } } diff --git a/openclaw-plugin/src/install.ts b/openclaw-plugin/src/install.ts index 2a007bf..833d854 100644 --- a/openclaw-plugin/src/install.ts +++ b/openclaw-plugin/src/install.ts @@ -76,8 +76,19 @@ export async function writeGeneratedSkill( paths: World2AgentPaths, pkg: string, installed: InstalledPackageInfo, -): Promise { +): Promise<{ skillId: string; written: boolean }> { const skillId = packageToSkillId(pkg); + const skillDir = join(paths.openclawSkillsDir, skillId); + const skillFile = join(skillDir, "SKILL.md"); + + // Never clobber a SKILL.md the user (or the world2agent-manage skill running + // the SETUP.md Q&A flow) already wrote — that personalized version is + // strictly better than a generic fallback. The CLI also accepts + // --skip-generate-skill for the same purpose; this is the safety net. + if (await pathExists(skillFile)) { + return { skillId, written: false }; + } + const sourceType = String( (installed.packageJson.w2a as Record | undefined)?.source_type ?? pkg, ); @@ -87,7 +98,6 @@ export async function writeGeneratedSkill( | undefined )?.join(", "); - const skillDir = join(paths.openclawSkillsDir, skillId); await mkdir(skillDir, { recursive: true }); const skillMd = [ "---", @@ -106,31 +116,50 @@ export async function writeGeneratedSkill( "", "## Behavior", "- Parse the JSON when you need structured fields.", - "- If the signal is irrelevant or obviously low-value, skip silently.", - "- If it is actionable, reply briefly with the key fact, why it matters, and any obvious next step.", + "- Default: reply with one short line — the key fact and why it might matter.", + "- The user has not personalized this handler yet, so do NOT silently skip on subjective relevance grounds. Reply briefly even if the signal seems mundane.", + "- The user can replace this file at `~/.openclaw/skills/" + skillId + "/SKILL.md` to add filtering rules (e.g. topics they care about), depth preferences, or output format.", "", "## Notes", - "- This skill was generated by @world2agent/openclaw-plugin because the sensor package does not ship a richer OpenClaw-specific handler yet.", - "- If the dedicated World2Agent agent does not expose a skills allowlist, the plugin falls back to `Use skill: ` prompt routing.", + "- This skill is the auto-generated fallback. The richer path is to let `world2agent-manage` walk the user through the sensor's `SETUP.md` Q&A — that produces a personalized SKILL.md in this exact location.", "", ].join("\n"); - await writeFile(join(skillDir, "SKILL.md"), skillMd, "utf8"); - return skillId; + await writeFile(skillFile, skillMd, "utf8"); + return { skillId, written: true }; } export async function loadConfigFile( configFile: string | undefined, + configJson: string | undefined, installed: InstalledPackageInfo, ): Promise> { + // Inline --config-json takes precedence over --config-file. Lets users + // provide config without managing a temp file: + // sensor add @pkg --config-json '{"top_n":10,"min_score":50}' + if (configJson !== undefined && configJson !== "") { + let parsed: unknown; + try { + parsed = JSON.parse(configJson); + } catch (error) { + throw new Error( + `--config-json is not valid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`--config-json must parse as a JSON object`); + } + return parsed as Record; + } + if (!configFile) { const setupPath = String( (installed.packageJson.w2a as Record | undefined)?.setup ?? "SETUP.md", ); throw new Error( - `Interactive setup is not implemented; use --config-file . Sensor guidance: ${join( - installed.packageRoot, - setupPath, - )}`, + `Provide either --config-json '' inline or --config-file . ` + + `Sensor guidance: ${join(installed.packageRoot, setupPath)}`, ); } diff --git a/openclaw-plugin/src/manifest.ts b/openclaw-plugin/src/manifest.ts index 6109759..7ae585b 100644 --- a/openclaw-plugin/src/manifest.ts +++ b/openclaw-plugin/src/manifest.ts @@ -68,7 +68,7 @@ export function normalizeSensorEntry(entry: SensorEntry): SensorEntry { return { sensor_id: entry.sensor_id, pkg: entry.pkg, - skill_id: packageToSkillId(entry.pkg), + skill_id: entry.skill_id?.trim() ? entry.skill_id : packageToSkillId(entry.pkg), enabled: entry.enabled !== false, isolated: entry.isolated === true, config: entry.config ?? {}, diff --git a/openclaw-plugin/src/openclaw/plugin-sdk/types.ts b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts index e96b32a..473a0b0 100644 --- a/openclaw-plugin/src/openclaw/plugin-sdk/types.ts +++ b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts @@ -59,7 +59,53 @@ export interface OpenClawPluginLogger { } export interface OpenClawRuntimeAgentSessionApi { - resolveSessionFilePath?(config: OpenClawConfig, sessionId: string): string; + resolveSessionFilePath?( + sessionId: string, + entry?: { sessionFile?: string }, + opts?: { agentId?: string; sessionsDir?: string }, + ): string; + resolveStorePath?( + store?: string, + opts?: { agentId?: string }, + ): string; + loadSessionStore?(agentId: string): Promise>; + saveSessionStore?( + agentId: string, + store: Record, + ): Promise; +} + +export interface OpenClawRuntimeSystemApi { + /** + * Enqueue a system event for a given session. OpenClaw drains queued + * system events at the start of the next agent turn and prepends them to + * the prompt as `System:` lines — this is the canonical way for plugins + * to inject context as a system notification rather than as user input. + */ + enqueueSystemEvent?( + text: string, + options: { + sessionKey: string; + contextKey?: string | null; + trusted?: boolean; + }, + ): boolean; + /** + * Wake the agent for a specific session/lane. OpenClaw's heartbeat + * handler will spin up a turn for that sessionKey, automatically draining + * the queued system events into the turn's prompt as `System:` lines. + * This is the same mechanism the bundled cron plugin uses to inject + * scheduled events into the agent. + * + * Fire-and-forget — does NOT await turn completion. + */ + requestHeartbeatNow?(opts?: { + reason?: string; + coalesceMs?: number; + agentId?: string; + sessionKey?: string; + heartbeat?: { target?: string }; + }): void; } export interface OpenClawRuntimeAgentApi { @@ -70,9 +116,40 @@ export interface OpenClawRuntimeAgentApi { session?: OpenClawRuntimeAgentSessionApi; } +export interface OpenClawConfigWriteOptions { + afterWrite?: { mode?: "auto" | "skip" | "refresh" } & Record; + [key: string]: unknown; +} + +export interface OpenClawReplaceConfigFileParams { + nextConfig: OpenClawConfig; + baseHash?: string; + afterWrite?: OpenClawConfigWriteOptions["afterWrite"]; + writeOptions?: OpenClawConfigWriteOptions; +} + +export interface OpenClawMutateConfigFileParams { + mutate: ( + draft: OpenClawConfig, + ctx: { snapshot: unknown; previousHash: string }, + ) => unknown | Promise; + base?: "runtime" | "source"; + baseHash?: string; + afterWrite?: OpenClawConfigWriteOptions["afterWrite"]; + writeOptions?: OpenClawConfigWriteOptions; +} + export interface OpenClawRuntimeConfigApi { + /** @deprecated Use current() instead. */ loadConfig?(): Promise; - writeConfigFile?(config: OpenClawConfig): Promise; + current?(): OpenClawConfig; + /** @deprecated Use mutateConfigFile / replaceConfigFile instead. */ + writeConfigFile?( + config: OpenClawConfig, + options?: OpenClawConfigWriteOptions, + ): Promise; + mutateConfigFile?(params: OpenClawMutateConfigFileParams): Promise; + replaceConfigFile?(params: OpenClawReplaceConfigFileParams): Promise; } export interface CliCommandBuilder { @@ -126,6 +203,7 @@ export interface OpenClawPluginApi { runtime?: { agent?: OpenClawRuntimeAgentApi; config?: OpenClawRuntimeConfigApi; + system?: OpenClawRuntimeSystemApi; }; } diff --git a/openclaw-plugin/src/runtime.ts b/openclaw-plugin/src/runtime.ts index dd9785b..fe9368e 100644 --- a/openclaw-plugin/src/runtime.ts +++ b/openclaw-plugin/src/runtime.ts @@ -87,10 +87,25 @@ export class SensorRuntime { const store = new FileSensorStore({ path: join(this.paths.stateDir, `${entry.sensor_id}.json`), }); + // Sensor logger writes to stderr (via this.log → OpenClaw's logger), NEVER stdout. + // stdout in the gateway process is shared with the user's terminal during + // interactive commands like `openclaw agents add`, and noisy sensor logs + // would corrupt those interactive prompts. + const sensorLog = (line: string) => this.log(`[w2a/${entry.sensor_id}] ${line}`); + const sensorLogger = { + info: (msg: string, ...args: unknown[]) => + sensorLog(args.length > 0 ? `${msg} ${args.map(String).join(" ")}` : msg), + warn: (msg: string, ...args: unknown[]) => + sensorLog(args.length > 0 ? `WARN ${msg} ${args.map(String).join(" ")}` : `WARN ${msg}`), + error: (msg: string, ...args: unknown[]) => + sensorLog(args.length > 0 ? `ERROR ${msg} ${args.map(String).join(" ")}` : `ERROR ${msg}`), + debug: (msg: string, ...args: unknown[]) => + sensorLog(args.length > 0 ? `DEBUG ${msg} ${args.map(String).join(" ")}` : `DEBUG ${msg}`), + }; const cleanup = await startSensor(spec, { config: entry.config, store, - logger: console, + logger: sensorLogger, logEmits: true, onSignal: async (signal) => { try { @@ -99,9 +114,12 @@ export class SensorRuntime { skillId: entry.skill_id, signal, }); + this.log( + `[w2a/${entry.sensor_id}] dispatched ${signal.signal_id} [${signal.event?.type ?? "unknown"}]`, + ); } catch (error) { this.log( - `[w2a/${entry.sensor_id}] dispatch failed: ${errorMessage(error)}`, + `[w2a/${entry.sensor_id}] dispatch failed for ${signal.signal_id}: ${errorMessage(error)}`, ); } }, diff --git a/openclaw-plugin/test/dispatch.test.ts b/openclaw-plugin/test/dispatch.test.ts index 5cfb446..2c11083 100644 --- a/openclaw-plugin/test/dispatch.test.ts +++ b/openclaw-plugin/test/dispatch.test.ts @@ -24,7 +24,7 @@ const TEST_SIGNAL = { }; describe("EmbeddedDispatcher", () => { - it("serializes by sensor session and renders the prompt-prefix fallback", async () => { + it("dispatches via runEmbeddedAgent with `# System Event` framed prompt", async () => { const calls: EmbeddedAgentRunRequest[] = []; const dispatcher = new EmbeddedDispatcher({ api: { @@ -35,14 +35,13 @@ describe("EmbeddedDispatcher", () => { return { ok: true }; }), }, + // NO `system` namespace at all → must fall back to embedded path }, }, openclawConfigRef: { current: { agents: { - defaults: { - contextInjection: "continuation-skip", - }, + defaults: { contextInjection: "continuation-skip" }, list: [], }, }, @@ -52,22 +51,21 @@ describe("EmbeddedDispatcher", () => { requestTimeoutMs: 12_345, ingestDedupTtlMs: 3_600_000, }, - paths: makePaths("/tmp/w2a-openclaw-dispatch"), + paths: makePaths("/tmp/w2a-openclaw-dispatch-fallback"), }); - await dispatcher.dispatch({ + const result = (await dispatcher.dispatch({ sensorId: "fake-tick", skillId: "world2agent-sensor-fake-tick", signal: TEST_SIGNAL, - }); + })) as { path: string }; + expect(result.path).toBe("embedded"); expect(calls).toHaveLength(1); expect(calls[0]?.sessionId).toBe("w2a-fake-tick"); - expect(calls[0]?.sessionKey).toBe("w2a:fake-tick"); - expect(calls[0]?.timeoutMs).toBe(12_345); - expect(calls[0]?.prompt.startsWith("Use skill: world2agent-sensor-fake-tick")).toBe( - true, - ); + expect(calls[0]?.sessionKey).toBe("agent:world2agent:w2a-fake-tick"); + expect(calls[0]?.prompt.startsWith("# System Event")).toBe(true); + expect(calls[0]?.prompt).toContain("Use skill: world2agent-sensor-fake-tick"); }); }); diff --git a/openclaw-plugin/test/manifest.test.ts b/openclaw-plugin/test/manifest.test.ts index 52f9ed1..836b42e 100644 --- a/openclaw-plugin/test/manifest.test.ts +++ b/openclaw-plugin/test/manifest.test.ts @@ -11,17 +11,19 @@ import { import type { World2AgentPaths } from "../src/types.js"; describe("manifest helpers", () => { - it("writes and reads a normalized manifest", async () => { + it("writes and reads a normalized manifest, preserving custom skill_id", async () => { const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-manifest-")); const paths = makePaths(root); + // Custom skill_id must be preserved end-to-end (matches hermes-bridge + // behavior; previously this PR overrode it with packageToSkillId). await writeManifest(paths, { version: 1, sensors: [ { sensor_id: "hackernews", pkg: "@world2agent/sensor-hackernews", - skill_id: "ignored-on-write", + skill_id: "my-custom-handler", enabled: true, isolated: true, config: { interval_ms: 30_000 }, @@ -36,7 +38,7 @@ describe("manifest helpers", () => { { sensor_id: "hackernews", pkg: "@world2agent/sensor-hackernews", - skill_id: "world2agent-sensor-hackernews", + skill_id: "my-custom-handler", enabled: true, isolated: true, config: { interval_ms: 30_000 }, @@ -45,6 +47,28 @@ describe("manifest helpers", () => { }); }); + it("falls back to packageToSkillId when skill_id is empty", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-manifest-")); + const paths = makePaths(root); + + await writeManifest(paths, { + version: 1, + sensors: [ + { + sensor_id: "hackernews", + pkg: "@world2agent/sensor-hackernews", + skill_id: "", + enabled: true, + isolated: false, + config: {}, + }, + ], + }); + + const manifest = await readManifest(paths); + expect(manifest.sensors[0]?.skill_id).toBe("world2agent-sensor-hackernews"); + }); + it("upserts and removes entries by sensor id", () => { const initial = { version: 1 as const, From 44bba07cc476c77b28e6882eea7de53743c6a34c Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 16:52:29 +0800 Subject: [PATCH 3/4] docs(openclaw-plugin): document in root README and refine onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Root README: new openclaw-plugin row in the package table, a full "Quick start — OpenClaw" section modeled on the Claude / Hermes ones (contextInjection prereq, conversational install, gateway restart caveat, w2a-* session lane pointer), Repository layout entry, and an Updating section for the npm publish flow. - openclaw-plugin SKILL.md: Step 7 now hands `openclaw gateway restart` back to the user instead of running it from inside the chat — running it there kills the gateway and truncates the agent's reply. - openclaw-plugin README: "Where to view" section rewritten to match B-mode dispatch (runEmbeddedAgent + `# System Event` framing) and to tell users explicitly to switch to the `w2a-` session lane in dashboard; main chat lane is intentionally untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 65 +++++++++++++++++-- openclaw-plugin/README.md | 43 ++++++++---- .../skills/world2agent-manage/SKILL.md | 40 +++++++++--- 3 files changed, 121 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5eeb2ab..ea0f424 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Channel adapters that connect [World2Agent](https://github.com/machinepulse-ai/w | --- | --- | --- | | [`claude-code-channel`](./claude-code-channel) | [Claude Code](https://docs.claude.com/en/docs/claude-code) | MCP channel adapter + Claude Code plugin bundle. Signals arrive as in-session MCP notifications. | | [`hermes-sensor-bridge`](./hermes-sensor-bridge) | [Hermes Agent](https://hermes-agent.nousresearch.com/) | Out-of-process supervisor + webhook bridge. Each signal triggers a fresh `AIAgent.run_conversation()` with the generated handler skill auto-loaded. | +| [`openclaw-plugin`](./openclaw-plugin) | [OpenClaw](https://docs.openclaw.ai/) | Native OpenClaw plugin. Conversational install via chat (Q&A driven by the sensor's `SETUP.md`), in-process polling, dispatch via `runEmbeddedAgent` into a per-sensor session lane keyed `agent:main:w2a-` (main chat untouched). | --- @@ -51,6 +52,37 @@ Each signal triggers a fresh agent run against the generated handler skill. See --- +## Quick start — OpenClaw + +Prereq — the plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"` in `~/.openclaw/openclaw.json`. This is hard-fail by design (the default `"always"` re-injects bootstrap on every signal and silently turns sensors into a token sink): + +```bash +jq '.agents.defaults.contextInjection = "continuation-skip"' \ + ~/.openclaw/openclaw.json > /tmp/openclaw.json.tmp && \ + mv /tmp/openclaw.json.tmp ~/.openclaw/openclaw.json +``` + +Install the plugin (`--dangerously-force-unsafe-install` is required because the plugin uses `child_process` to npm-install sensor packages on demand — OpenClaw's security scan blocks it otherwise): + +```bash +openclaw plugins install @world2agent/openclaw-plugin --dangerously-force-unsafe-install +openclaw gateway restart +``` + +Then in a chat session with your `main` agent, just describe what you want to subscribe to: + +``` +> 帮我订阅 Hacker News,我关心 AI 和安全话题 +``` + +The bundled `world2agent-manage` skill takes over: reads the sensor's `SETUP.md`, asks you 1–3 questions to personalize the handler, writes both the config and the personalized SKILL.md, and registers the sensor — without any manual CLI work. + +> First time only: the agent will ask **you** to run `openclaw gateway restart` once after registration. It intentionally doesn't run that command itself — restarting the gateway from inside the chat would kill the gateway process and truncate the agent's reply mid-sentence. After the restart, the sensor starts polling within ~60 seconds. + +Signals route to a per-sensor session lane (`agent:main:w2a-`) — your `main` chat is untouched. Open the `w2a-` lane in the OpenClaw dashboard () to see how the agent reacts to each signal. See [`openclaw-plugin/README.md`](./openclaw-plugin/README.md) for the full install reference, dispatcher internals, and CLI fallback. + +--- + ## Repository layout ``` @@ -63,14 +95,24 @@ Each signal triggers a fresh agent run against the generated handler skill. See │ ├── skills/ # MCP-side handler skills │ ├── src/ │ └── package.json -└── hermes-sensor-bridge/ # @world2agent/hermes-sensor-bridge +├── hermes-sensor-bridge/ # @world2agent/hermes-sensor-bridge +│ ├── src/ +│ │ ├── runner/ # per-sensor subprocess +│ │ └── supervisor/ # daemon (signal → HMAC → POST → Hermes) +│ ├── skills/world2agent-manage/ +│ │ ├── SKILL.md # agent-facing skill +│ │ └── scripts/ # all host-side work (install, remove, list, …) +│ ├── e2e/ +│ └── package.json +└── openclaw-plugin/ # @world2agent/openclaw-plugin ├── src/ - │ ├── runner/ # per-sensor subprocess - │ └── supervisor/ # daemon (signal → HMAC → POST → Hermes) + │ ├── dispatch.ts # runEmbeddedAgent + `# System Event` framing + │ ├── runtime.ts # in-process sensor lifecycle + │ ├── isolated.ts # opt-in subprocess mode (reuses Hermes runner) + │ └── cli.ts # `openclaw world2agent sensor add | list | remove` ├── skills/world2agent-manage/ - │ ├── SKILL.md # agent-facing skill - │ └── scripts/ # all host-side work (install, remove, list, …) - ├── e2e/ + │ └── SKILL.md # conversational install + management skill + ├── test/ └── package.json ``` @@ -93,6 +135,17 @@ Users pull updates with: Bump `version` in `hermes-sensor-bridge/package.json`, then `pnpm publish --access public --tag alpha` (alpha) or `latest` (stable). Users pull the runtime with `npm install -g @world2agent/hermes-sensor-bridge@`. The skill is installed separately via `hermes skills install …`; re-run that command with `--force` to refresh to the latest skill content. +### OpenClaw plugin (`openclaw-plugin`) + +Bump `version` in `openclaw-plugin/package.json`, then `pnpm publish --access public --tag alpha` (alpha) or `latest` (stable). Users pull updates with: + +```bash +openclaw plugins install @world2agent/openclaw-plugin@ --dangerously-force-unsafe-install +openclaw gateway restart +``` + +The bundled `world2agent-manage` skill ships inside the package, so it updates atomically with the plugin — no separate install step. + --- ## License diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 7fb578d..32dd585 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -83,7 +83,13 @@ The plugin ships a `world2agent-manage` skill that activates on this kind of int 2. Ask you 1–3 questions defined in that file (poll thresholds, your topics of interest, reply depth) 3. Fill the SKILL.md template with your answers and write it to `~/.openclaw/skills/world2agent-sensor-hackernews/SKILL.md` 4. Run `openclaw world2agent sensor add ... --skip-generate-skill` to register -5. Tell you when the first signal will arrive +5. Ask **you** to run `openclaw gateway restart` in your terminal (the agent + intentionally does NOT run this itself — restarting the gateway from + inside the chat would kill this very chat session mid-reply) +6. Tell you when the first signal will arrive **and which session lane to + open in dashboard** to see the agent's replies (signals route to a + separate `w2a-` session, not your main chat — see + ["Where to view signal-driven agent runs"](#where-to-view-signal-driven-agent-runs) below) This personalized SKILL.md is what makes the agent reply meaningfully to relevant signals (instead of skipping every signal silently because it has no anchor for "what's relevant to this user"). @@ -105,30 +111,45 @@ openclaw world2agent reload # falls back to `openclaw gateway restart` if reload times out ``` -Within ~60 seconds the sensor will start polling. Each emitted signal triggers an agent turn under sessionKey `agent:main:w2a-`, with the signal framed as a `# System Event` block. +> ⚠️ **Run the restart in your own terminal — never inside an OpenClaw chat session.** `openclaw gateway restart` kills the gateway process, which terminates any in-flight chat reply mid-sentence. The conversational install path explicitly hands the restart back to the user for this reason. + +Within ~60 seconds of the restart, the sensor will start polling. Each emitted signal triggers an agent turn under sessionKey `agent:main:w2a-`, with the signal framed as a `# System Event` block. ## Where to view signal-driven agent runs -Each sensor gets a stable session id `w2a-`, scoped to your default agent (`main` unless you overrode `defaultAgentId`). Signals are injected via OpenClaw's system-event queue + heartbeat — **the W2A signal arrives as a `System:` block in the agent turn, not as a user message**. You can view the resulting conversation in three ways: +Each sensor gets its own session lane, separate from your main chat. The +lane is keyed `agent::w2a-` (e.g. +`agent:main:w2a-hackernews`), with stable session id `w2a-`. The +plugin dispatches signals via `runEmbeddedAgent` with a `# System Event` +markdown frame in the prompt, so the signal lives in user-role position +within the W2A session — but **never in your main chat session**. + +Concrete: if you ran the conversational install for Hacker News, look for +the `w2a-hackernews` session, not `main`. Your `main` chat is untouched. ```bash -# CLI — list W2A sessions on the main agent +# CLI — list W2A sessions on the main agent (with last-active filter) openclaw sessions --agent main --active 60 -# (or `--agent world2agent` if you set defaultAgentId to a dedicated agent) +# expected to include: w2a-hackernews -# Dashboard — open the OpenClaw control UI +# Dashboard — open OpenClaw's control UI, then switch to the +# `w2a-hackernews` (or w2a-) session in the sidebar open http://127.0.0.1:18789/ # Direct file access (for debugging) ls ~/.openclaw/agents/main/sessions/ -# w2a-hackernews.jsonl ← session metadata +# w2a-hackernews.jsonl ← signal-handling transcript # w2a-hackernews.trajectory.jsonl ← full LLM tool-call trajectory -# sessions.json ← OpenClaw session index (includes -# `agent:main:w2a-` lanes -# alongside `agent:main:main` chat lane) +# sessions.json ← OpenClaw session index (lists both +# `agent:main:main` chat lane AND +# `agent:main:w2a-` lanes) ``` -Your normal chat with `main` agent (sessionKey `agent:main:main`) is **untouched** — W2A signals only show up under `agent:main:w2a-` lanes. +Your normal chat with the `main` agent (sessionKey `agent:main:main`) is +**untouched** — W2A signals only show up under `agent:main:w2a-` +lanes. Open one of those lanes to see how the agent is reacting to +incoming signals; that's where you'll spot whether your handler SKILL.md +needs tuning. ## Scope diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md index 15b660d..e7b8dc5 100644 --- a/openclaw-plugin/skills/world2agent-manage/SKILL.md +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -151,16 +151,36 @@ Optional flags: - `--isolated` to run the sensor out-of-process (for unstable third-party sensors) -### Step 7 — Confirm and reload - -If the CLI's `reload` field returns `ok: true`, the sensor is polling. If -not, ask the user to run `openclaw gateway restart` (the plugin process -caches its config at register time). - -Tell the user: -- which sensor id was created -- where the personalized SKILL.md lives -- when to expect the first signal (sensor's poll interval) +### Step 7 — Confirm and tell the user how to activate + +If the CLI's `reload` field returns `ok: true`, the sensor is already +polling — done. + +If reload failed (timeout is the common case), **DO NOT run +`openclaw gateway restart` yourself**. Restarting the gateway from inside +this chat would kill the gateway process — including this very chat +session — and the user would see your reply truncated mid-sentence. Always +hand the restart back to the user. Tell them, in their language, something +like: + +> 装好了。新 sensor 需要 gateway 重启才会开始 polling。请你在终端跑 +> `openclaw gateway restart`(我没法自己跑这条命令——它会把当前这个 +> chat session 一起重启,对话会被截断)。重启后 ~60 秒内会拉到第一条信号。 + +Then summarize for the user: + +- **sensor id** that was created (e.g. `hackernews`) +- **where the personalized SKILL.md lives** (`~/.openclaw/skills//SKILL.md`) + so they know what to edit later if their preferences change +- **where signal-driven runs will appear**: signals do NOT pollute their + main chat lane. Each sensor gets its own session lane keyed + `agent:main:w2a-` (sessionId `w2a-`). Tell the + user to switch to that lane in dashboard + () — or run + `openclaw sessions --agent main --active 60` from CLI — to see + the agent's responses to incoming signals. The SKILL.md they just + configured drives those replies. +- **when to expect the first signal** based on the sensor's poll interval ## List sensors From 5568c1202cbcff2c3f6bf9bbbd3a0fb2fdcb648a Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 17:32:50 +0800 Subject: [PATCH 4/4] docs(openclaw-plugin): replace Chinese examples with English equivalents The conversational-install docs (root README, openclaw-plugin README, and the world2agent-manage SKILL.md) had a Chinese sample utterance and a Chinese gateway-restart prompt that the agent was supposed to repeat verbatim. Replace both with English so the docs stay consistent in one language and so the SKILL.md sample reads as a template the agent delivers in the user's actual language, not a prescribed Chinese reply. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- openclaw-plugin/README.md | 2 +- openclaw-plugin/skills/world2agent-manage/SKILL.md | 14 ++++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ea0f424..2c9b5d6 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ openclaw gateway restart Then in a chat session with your `main` agent, just describe what you want to subscribe to: ``` -> 帮我订阅 Hacker News,我关心 AI 和安全话题 +> subscribe me to Hacker News — I care about AI and security stories ``` The bundled `world2agent-manage` skill takes over: reads the sensor's `SETUP.md`, asks you 1–3 questions to personalize the handler, writes both the config and the personalized SKILL.md, and registers the sensor — without any manual CLI work. diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 32dd585..42dacf7 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -74,7 +74,7 @@ openclaw chat --agent main ``` ``` -> 帮我订阅 Hacker News,我关心 AI 和安全话题 +> subscribe me to Hacker News — I care about AI and security stories ``` The plugin ships a `world2agent-manage` skill that activates on this kind of intent. Main agent will: diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md index e7b8dc5..8873181 100644 --- a/openclaw-plugin/skills/world2agent-manage/SKILL.md +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -160,12 +160,14 @@ If reload failed (timeout is the common case), **DO NOT run `openclaw gateway restart` yourself**. Restarting the gateway from inside this chat would kill the gateway process — including this very chat session — and the user would see your reply truncated mid-sentence. Always -hand the restart back to the user. Tell them, in their language, something -like: - -> 装好了。新 sensor 需要 gateway 重启才会开始 polling。请你在终端跑 -> `openclaw gateway restart`(我没法自己跑这条命令——它会把当前这个 -> chat session 一起重启,对话会被截断)。重启后 ~60 秒内会拉到第一条信号。 +hand the restart back to the user. Tell them — in **their** language, not +necessarily English — something equivalent to: + +> The sensor is registered, but it needs a gateway restart before it +> starts polling. Please run `openclaw gateway restart` in your terminal +> (I can't run it myself — that command would kill this chat session +> mid-reply). The first signal will arrive within ~60 seconds after the +> restart. Then summarize for the user: