diff --git a/README.md b/README.md index 5eeb2ab..8f97056 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 OpenClaw's embedded-agent runtime into a per-sensor session lane keyed `agent:main:w2a-` (main chat untouched). | --- @@ -51,6 +52,29 @@ Each signal triggers a fresh agent run against the generated handler skill. See --- +## Quick start — OpenClaw + +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: + +``` +> 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, registers the sensor, and reloads the running plugin — without any manual CLI work. + +> If reload returns `ok: false` (for example a gateway-call timeout), the agent will ask **you** to run `openclaw gateway restart` in your own terminal. 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 reload or restart, the sensor starts polling within seconds; subsequent polls follow the sensor's configured `interval_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 +87,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 +127,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/.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..313220a --- /dev/null +++ b/openclaw-plugin/README.md @@ -0,0 +1,216 @@ +# @world2agent/openclaw-plugin + +Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into dedicated agent session lanes — **never into your main chat session**. + +The default path is in-process: enabled sensors are imported directly inside the plugin process. Each emitted signal is wrapped in a `# System Event` markdown frame and dispatched via OpenClaw's `runtime.subagent.run` (or `runtime.agent.runEmbeddedAgent` for older runtimes). The framed signal lives in user-role position within a per-sensor lane keyed `agent::w2a-`, but the framing makes the agent treat it as an external notification rather than a user message. + +When [`deliver`](#pushing-replies-to-a-chat-platform-lark--whatsapp--telegram--) is configured, the same path also pushes the assistant reply back to a chat platform (lark / feishu / whatsapp / telegram / …) via the `subagent.run({ deliver: true })` flag — OpenClaw resolves the channel target from the session entry's `deliveryContext` we wrote. + +`isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. (See [caveat](#pushing-replies-to-a-chat-platform-lark--whatsapp--telegram--) — `deliver` currently does not work for isolated sensors.) + +## Install + +> ⚠️ OpenClaw config is **JSON**, not YAML. All steps below assume `~/.openclaw/openclaw.json`. + +### 1. 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 +``` + +Verify it loaded: + +```bash +openclaw plugins list | grep world2agent +# → │ World2Agent │ world2agent │ openclaw │ enabled │ ... │ 0.1.0-alpha.0 │ +openclaw world2agent --help +# → Commands: reload, sensor +``` + +### 2. 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. + +The preferred path is conversational. Just tell main agent what you want: + +```bash +openclaw chat --agent main +``` + +``` +> 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: + +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. Run `openclaw world2agent reload` so the running plugin picks up the new + sensor (this is the normal path — adding a sensor only mutates + `~/.world2agent/sensors.json`, the plugin's own config in + `~/.openclaw/openclaw.json` is untouched) +6. If reload returns `ok: false` (e.g. the gateway-call socket times out), + ask **you** to run `openclaw gateway restart` in **your own terminal** + as a fallback. The agent never runs `gateway restart` itself — + restarting from inside the chat would terminate the reply mid-sentence +7. 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"). + +#### 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 +``` + +> ⚠️ **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 seconds of the reload (or restart), the sensor's first poll fires; subsequent polls follow the sensor's `interval_seconds` (e.g. 300 for `@world2agent/sensor-hackernews`). 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 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 OpenClaw's embedded-agent runtime +(`runtime.subagent.run` when `deliver` is configured, otherwise +`runtime.agent.runEmbeddedAgent`) with a `# System Event` markdown frame +in the prompt, so the signal lives in user-role position within the W2A +session lane — but **never in your main chat session** (`agent:main:main` +is untouched). + +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 (with last-active filter) +openclaw sessions --agent main --active 60 +# expected to include: w2a-hackernews + +# 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 ← signal-handling transcript +# w2a-hackernews.trajectory.jsonl ← full LLM tool-call trajectory +# sessions.json ← OpenClaw session index (lists both +# `agent:main:main` chat lane AND +# `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 + +- 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 session id: `w2a-` (and session key `agent::w2a-`). +- Requires plugin config `ingestUrl` only when `isolated: true` sensors are used. + +## Pushing replies to a chat platform (Lark / WhatsApp / Telegram / …) + +By default a sensor-driven turn stays inside the W2A session lane — the agent's +reply is only visible in `openclaw sessions --agent main` / the dashboard. + +If you've already paired a chat platform (any plugin in `openclaw plugins list +--json` with a non-empty `channelIds` array — feishu, lark, whatsapp, telegram, +discord, slack, signal, imessage, line, msteams, matrix, …), you can have the +plugin route the assistant reply back to that chat. There are two grains: + +**Plugin-wide default** — every sensor's reply lands in the same chat. Set in +this plugin's config block in `~/.openclaw/openclaw.json`: + +```jsonc +{ + "plugins": { + "world2agent": { + "deliver": { + "channel": "feishu", + "to": "oc_chat_xxxxxxxxxxxxxxxx" + } + } + } +} +``` + +**Per-sensor override** — different sensors push to different chats. Pass at +install time: + +```bash +openclaw world2agent sensor add @world2agent/sensor-hackernews \ + --config-json '{"top_n":10,"min_score":50}' \ + --deliver-channel feishu \ + --deliver-to oc_chat_xxxxxxxxxxxxxxxx +``` + +Optional flags: `--deliver-account ` for multi-account channels, +`--deliver-thread ` to post into a specific thread/topic. + +When `deliver` is set, the plugin (a) writes `lastChannel` / `lastTo` / +`deliveryContext` onto the W2A session entry and (b) dispatches via +`runtime.subagent.run({ sessionKey, message, deliver: true })` instead of +the lower-level `runEmbeddedAgent`. The subagent path internally pairs +`runEmbeddedAgent` with `deliverAgentCommandResult` — that second step is +what actually reads `sessionEntry.deliveryContext` and invokes the channel +plugin's send. `runEmbeddedAgent` alone would produce a transcript reply +but never push it outbound. No second LLM call, no plugin-side IM client. + +If `deliver` is not set, the reply stays in the W2A session lane (visible +in the dashboard, no IM push). If the named channel plugin isn't loaded, +the run still completes but OpenClaw's outbound resolver refuses to send +— check `openclaw plugins list --json` to confirm the channel id matches +an enabled plugin. + +> ⚠️ **Known limitation — `deliver` does not currently apply to +> `isolated: true` sensors.** Subprocess sensors route signals through +> the plugin-local `/w2a/ingest` HTTP route, and that handler's call to +> `runtime.subagent.run` is rejected with `missing scope: operator.write` +> (in-process plugin calls have the scope; HTTP-route plugin calls do +> not). Those runs fall back to plain `runEmbeddedAgent` and the reply +> stays in the session lane only. In-process sensors (the default) push +> to the channel correctly. + +## 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 `runtime.subagent.run` (which internally pairs `runEmbeddedAgent` with OpenClaw's outbound delivery resolver) — no separate gateway, no HMAC ingest, no platform bootstrap. + +## Troubleshooting + +**Plugin install blocked by safety scanner**: that's the security warning about `child_process`. Use `--dangerously-force-unsafe-install` (see [§ Install the plugin](#1-install-the-plugin)). + +**`openclaw world2agent --help` says "unknown command"**: gateway hasn't reloaded the plugin yet. Run `openclaw gateway restart`. + +**Sensors run but `openclaw sessions --agent main` shows no `w2a-` lane**: the plugin manifest reload may have timed out. Verify with `openclaw world2agent sensor list` that the sensor is in the manifest, and grep `/tmp/openclaw/openclaw-*.log` for `[w2a/]` to see emit / dispatch / dispatch failed lines. If you set `defaultAgentId: "world2agent"` in this plugin's config, replace `--agent main` with `--agent world2agent` and make sure that agent exists in `agents.list` (`openclaw agents add world2agent` once before reload). + +**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. + +**Replies don't reach the configured chat platform (`deliver` set but nothing arrives)**: first confirm the relevant in-process emit produced a non-empty assistant reply (NO_REPLY / empty content correctly suppresses delivery). If the reply is non-empty but no message lands, check that `openclaw plugins list --json` shows the named channel as enabled, and that `~/.openclaw/agents//sessions/sessions.json` for your sensor's lane has `lastChannel` / `lastTo` / `deliveryContext` matching your config — the plugin writes those fields on every dispatch. If they're missing, the plugin loaded an older dist (before this feature) — rebuild and re-link. diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json new file mode 100644 index 0000000..91091f5 --- /dev/null +++ b/openclaw-plugin/openclaw.plugin.json @@ -0,0 +1,90 @@ +{ + "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": "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", + "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." + }, + "deliver": { + "type": "object", + "additionalProperties": false, + "description": "Default delivery target for sensor-driven agent replies. When set, OpenClaw routes the assistant reply through the named channel plugin (e.g. feishu, lark, whatsapp, telegram) to the recipient. Per-sensor manifest entries can override this. Leave unset to keep replies inside the W2A session lane only.", + "required": ["channel", "to"], + "properties": { + "channel": { + "type": "string", + "description": "Channel id matching an enabled OpenClaw channel plugin (run `openclaw plugins list --json` and pick a plugin that exposes a non-empty channelIds array)." + }, + "to": { + "type": "string", + "description": "Recipient identifier on the channel: chat id, user id, or platform-specific target." + }, + "accountId": { + "type": "string", + "description": "Optional account id for multi-account channels." + }, + "threadId": { + "type": ["string", "number"], + "description": "Optional thread/topic id for routing inside a thread." + } + } + } + } + } +} diff --git a/openclaw-plugin/package-lock.json b/openclaw-plugin/package-lock.json new file mode 100644 index 0000000..73bfca5 --- /dev/null +++ b/openclaw-plugin/package-lock.json @@ -0,0 +1,1323 @@ +{ + "name": "@world2agent/openclaw-plugin", + "version": "0.1.0-alpha.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@world2agent/openclaw-plugin", + "version": "0.1.0-alpha.0", + "license": "Apache-2.0", + "dependencies": { + "@world2agent/sdk": "0.1.0-alpha.1", + "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": "https://registry.npmjs.org/@world2agent/sdk/-/sdk-0.1.0-alpha.1.tgz", + "integrity": "sha512-YfCdXPyX9Zm811fsT0kiTfCRW7iOZ4ByYZCwlqeKZbXRy8/RxJrse6KGzexfZWAXv0L8Gl8ZvOJTs4WesfIiaQ==", + "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..644e181 --- /dev/null +++ b/openclaw-plugin/package.json @@ -0,0 +1,68 @@ +{ + "name": "@world2agent/openclaw-plugin", + "version": "0.1.0-alpha.0", + "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": "0.1.0-alpha.1", + "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/pnpm-lock.yaml b/openclaw-plugin/pnpm-lock.yaml new file mode 100644 index 0000000..0e2fb53 --- /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: 0.1.0-alpha.1 + version: 0.1.0-alpha.1(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@0.1.0-alpha.1': + resolution: {integrity: sha512-YfCdXPyX9Zm811fsT0kiTfCRW7iOZ4ByYZCwlqeKZbXRy8/RxJrse6KGzexfZWAXv0L8Gl8ZvOJTs4WesfIiaQ==} + 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@0.1.0-alpha.1(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 new file mode 100644 index 0000000..71971e4 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -0,0 +1,273 @@ +--- +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. 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 + +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: + +```bash +jq '.agents.defaults.contextInjection' ~/.openclaw/openclaw.json +``` + +If the value is anything other than `"continuation-skip"`, ask the user for +permission to fix it, then run: + +```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). + +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 +PLUGIN_DIR=$(openclaw plugins list --json | \ + jq -r '.plugins[] | select(.id == "world2agent") | .rootDir') +echo "$PLUGIN_DIR" +# → e.g. /Users//Documents/.../openclaw-plugin +``` + +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" +``` + +If it isn't installed, install it in-place (no manifest mutation, just +populates `node_modules/`): + +```bash +( 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) +- `--deliver-channel --deliver-to ` to push the agent's reply to a + chat platform (see step 6b). + +### Step 6b — Offer to push replies to a chat platform + +Check whether the user has any inbound chat-platform plugin enabled: + +```bash +openclaw plugins list --json | jq -r \ + '.plugins[] | select(.enabled == true) | select(.channelIds | length > 0) | .id' +``` + +If the output is **empty**, skip this step — there's no IM to push to. The +sensor will still run; replies stay in the OpenClaw session lane (visible via +`openclaw sessions --agent main` and the dashboard). + +If one or more channels are listed (e.g. `feishu`, `lark`, `whatsapp`, +`telegram`, `discord`, `slack`, `signal`, `imessage`, `line`, `msteams`), +ask the user once — in their language — whether they want this sensor's +replies pushed to one of those chats. If yes, also ask for the recipient +target id (chat id, user id, or platform-specific target — the user knows +this from when they paired the channel; never invent it). + +Append to the install command: + +```bash + --deliver-channel \ + --deliver-to +``` + +Optional: `--deliver-account ` for multi-account channels, +`--deliver-thread ` to post into a specific thread/topic. + +The user can also set this once globally under +`plugins.world2agent.deliver` in `~/.openclaw/openclaw.json`; per-sensor +flags override that default. If the user wants the same target for +everything they're about to install, suggest they set the global default +instead of repeating the flags every time. + +### 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, 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: + +- **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 + +```bash +openclaw world2agent sensor list +``` + +Returns the manifest plus the current `contextInjection` value. + +## Remove a sensor + +```bash +openclaw world2agent sensor remove +``` + +Add `--purge` only if the user wants the generated handler skill directory +deleted too (this is destructive — confirm first). + +## Reload after manual edits + +If the user hand-edited `~/.world2agent/sensors.json` or a personalized +SKILL.md, run: + +```bash +openclaw world2agent reload +``` + +If reload fails, fall back to `openclaw gateway restart`. + +## Common mistakes to avoid + +- 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/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..099a475 --- /dev/null +++ b/openclaw-plugin/src/cli.ts @@ -0,0 +1,293 @@ +import { join } from "node:path"; +import { packageToSkillId } from "@world2agent/sdk"; +import { + assertContextInjectionCompatible, + loadEffectiveOpenClawConfig, + normalizeDeliver, + 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 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.", + ) + .option( + "--deliver-channel ", + "Channel id to push assistant replies through (e.g. feishu, lark, whatsapp, telegram). Must match an enabled OpenClaw channel plugin.", + ) + .option( + "--deliver-to ", + "Recipient on the deliver channel (chat id, user id, or platform-specific target). Required when --deliver-channel is set.", + ) + .option("--deliver-account ", "Optional account id for multi-account channels") + .option("--deliver-thread ", "Optional thread/topic id for routing replies inside a thread") + .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 configJson = optionString(options, "configJson"); + const isolated = optionBoolean(options, "isolated"); + const skipGenerateSkill = optionBoolean(options, "skipGenerateSkill"); + const skillId = packageToSkillId(pkg); + const sensorConfig = await loadConfigFile(configFile, configJson, installed); + + const deliverChannel = optionString(options, "deliverChannel"); + const deliverTo = optionString(options, "deliverTo"); + if ((deliverChannel && !deliverTo) || (deliverTo && !deliverChannel)) { + throw new Error( + "--deliver-channel and --deliver-to must be set together (channel without recipient — or vice versa — has no routing target).", + ); + } + const deliver = + deliverChannel && deliverTo + ? normalizeDeliver({ + channel: deliverChannel, + to: deliverTo, + accountId: optionString(options, "deliverAccount"), + threadId: optionString(options, "deliverThread"), + }) + : undefined; + + // 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 = { + sensor_id: sensorId, + pkg, + skill_id: skillId, + enabled: true, + isolated, + config: sensorConfig, + ...(deliver ? { deliver } : {}), + }; + 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, + skill_generated: skillGenerated.written, + skill_path: join(services.paths.openclawSkillsDir, skillId, "SKILL.md"), + deliver: deliver ?? null, + 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) { + 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, + 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..3ca4539 --- /dev/null +++ b/openclaw-plugin/src/config.ts @@ -0,0 +1,182 @@ +import type { + OpenClawAgentConfig, + OpenClawConfig, + OpenClawPluginApi, + OpenClawPluginConfig, +} from "./openclaw/plugin-sdk/types.js"; +import type { DeliverConfig, RequiredWorld2AgentPluginConfig } from "./types.js"; + +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(); + } + 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), + // 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, + ingestHmacSecretFile: asOptionalString(raw.ingestHmacSecretFile), + ingestDedupTtlMs: asPositiveInteger(raw.ingestDedupTtlMs) ?? 3_600_000, + deliver: normalizeDeliver((raw as Record).deliver), + }; +} + +export function normalizeDeliver(value: unknown): DeliverConfig | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + const channel = asOptionalString(raw.channel); + const to = asOptionalString(raw.to); + // channel + to are both required to actually route a reply: lastChannel + // alone wouldn't tell OpenClaw who to send to. Bail silently otherwise so + // a half-filled config doesn't break dispatch. + if (!channel || !to) return undefined; + + const accountId = asOptionalString(raw.accountId); + const threadId = + typeof raw.threadId === "number" && Number.isFinite(raw.threadId) + ? Math.trunc(raw.threadId) + : asOptionalString(raw.threadId); + + return { + channel, + to, + ...(accountId !== undefined ? { accountId } : {}), + ...(threadId !== undefined ? { threadId } : {}), + }; +} + +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.", + }; + } + + // 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, + 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..adc235f --- /dev/null +++ b/openclaw-plugin/src/dispatch.ts @@ -0,0 +1,551 @@ +import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import { copyFile, mkdir, writeFile } from "node:fs/promises"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { dirname, join } from "node:path"; +import { hasDedicatedAgentSkillsAllowlist } from "./config.js"; +import { renderSignalPrompt } from "./prompt.js"; +import type { + OpenClawConfig, + OpenClawPluginApi, +} from "./openclaw/plugin-sdk/types.js"; +import type { + DeliverConfig, + Dispatcher, + DispatcherDispatchInput, + EmbeddedDispatcherOptions, + HttpDispatcherOptions, + HttpIngestEnvelope, +} from "./types.js"; + +export class EmbeddedDispatcher implements Dispatcher { + private readonly options: EmbeddedDispatcherOptions; + private readonly queues = new Map>(); + + constructor(options: EmbeddedDispatcherOptions) { + // Don't validate runtime APIs at construction time — either + // runtime.subagent.run (preferred when deliver is configured) OR + // runtime.agent.runEmbeddedAgent (fallback) is enough. The actual + // check happens per-dispatch in `dispatchNow` because it depends on + // whether the caller asked for delivery. + 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 signalText = renderSignalPrompt(input.signal, { + skillId: input.skillId, + useSkillPrefix: !hasDedicatedAgentSkillsAllowlist( + this.options.openclawConfigRef.current, + this.options.pluginConfig.defaultAgentId, + ), + }); + + const config = this.options.openclawConfigRef.current; + const api = this.options.api; + const runtimeAgent = api.runtime?.agent; + const agentId = this.options.pluginConfig.defaultAgentId ?? "main"; + // 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, + ); + + // Per-sensor deliver overrides plugin default. Both are optional — when + // neither is set, the session entry stays untargeted and OpenClaw keeps + // the reply inside the W2A session lane (current behavior). + const deliver = input.deliver ?? this.options.pluginConfig.deliver; + + // ───────────────────────────────────────────────────────────────── + // 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. + // ───────────────────────────────────────────────────────────────── + const runtimeSubagent = api.runtime?.subagent; + const canDeliverViaSubagent = + Boolean(deliver) && typeof runtimeSubagent?.run === "function"; + + if (!canDeliverViaSubagent && typeof runtimeAgent?.runEmbeddedAgent !== "function") { + throw new Error( + "OpenClaw runtime exposes neither runtime.subagent.run nor runtime.agent.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 timeoutMs = + this.options.pluginConfig.requestTimeoutMs ?? + tryCall(() => runtimeAgent?.resolveAgentTimeoutMs?.(config)) ?? + 120_000; + + await ensureSessionRegistered({ + api, + agentId, + sessionId, + sessionKey, + sessionFile, + sensorId: input.sensorId, + provider, + model, + deliver, + }); + + 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; + + // ───────────────────────────────────────────────────────────────── + // Delivery path selection. + // + // When deliver is configured AND OpenClaw exposes runtime.subagent.run, + // use the high-level subagent path: it wraps runEmbeddedAgent and ALSO + // calls deliverAgentCommandResult after the run, which is what actually + // pushes the assistant reply to the channel plugin (feishu/lark/...). + // + // runEmbeddedAgent alone does NOT deliver — it just produces an + // assistant message in the session transcript. Only deliverAgentCommandResult + // reads sessionEntry.deliveryContext / messageChannel and invokes + // channel.send. We wrote deliveryContext to the session entry above, + // but that's load-bearing only when something downstream reads it. + // + // Fallback path (no subagent / no deliver): keep the original + // runEmbeddedAgent call so behavior is unchanged for users who haven't + // configured deliver — and to support OpenClaw versions that predate + // the subagent runtime. + // ───────────────────────────────────────────────────────────────── + let result: unknown; + if (canDeliverViaSubagent) { + // Don't pass provider/model — runtime.subagent.run rejects per-call + // overrides from plugins ("provider/model override is not authorized + // for this plugin subagent run."). Let OpenClaw resolve from agent + // defaults (resolved upstream and persisted on the session entry). + const { runId } = await runtimeSubagent!.run!({ + sessionKey, + message: promptForTurn, + deliver: true, + }); + // Wait so the dispatcher's per-sensor queue stays meaningful (next + // signal for the same sensor doesn't kick off until this reply has + // been delivered). If waitForRun isn't available, fall through with + // just the runId — fire-and-forget. + if (typeof runtimeSubagent!.waitForRun === "function") { + const wait = await runtimeSubagent!.waitForRun({ runId, timeoutMs }); + result = { runId, wait }; + } else { + result = { runId }; + } + } else { + result = await runtimeAgent!.runEmbeddedAgent!({ + sessionId, + sessionKey, + agentId, + runId: randomUUID(), + sessionFile, + workspaceDir, + agentDir, + config, + prompt: promptForTurn, + timeoutMs, + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + // Pass turn-source delivery hints. Only useful in old runtimes that + // already auto-deliver from runEmbeddedAgent — modern runtimes need + // the subagent.run path above for actual outbound. Kept for forward + // compat / future runtime versions that wire this through. + ...(deliver + ? { + messageChannel: deliver.channel, + messageTo: deliver.to, + ...(deliver.threadId !== undefined ? { messageThreadId: deliver.threadId } : {}), + ...(deliver.accountId ? { agentAccountId: deliver.accountId } : {}), + } + : {}), + } as Parameters["runEmbeddedAgent"]>>[0]); + } + + await mirrorIsolatedSessionFiles(agentDir, sessionId).catch(() => undefined); + await ensureSessionRegistered({ + api, + agentId, + sessionId, + sessionKey, + sessionFile, + sensorId: input.sensorId, + provider, + model, + deliver, + }).catch(() => undefined); + + return { + ok: true, + path: canDeliverViaSubagent ? "subagent" : "embedded", + result, + }; + } +} + +async function ensureSessionRegistered(params: { + api: OpenClawPluginApi; + agentId: string; + sessionId: string; + sessionKey: string; + sessionFile: string; + sensorId: string; + provider?: string; + model?: string; + deliver?: DeliverConfig; +}): Promise { + const now = Date.now(); + const deliver = params.deliver; + + // When deliver is configured, mark the session with channel + recipient so + // OpenClaw's resolveAgentDeliveryPlan picks it up and routes the assistant + // reply through the corresponding channel plugin (lark/feishu/whatsapp/...). + // Without this, lastChannel="world2agent" keeps the reply inside the W2A + // session lane only. + const deliverFields = deliver + ? { + lastChannel: deliver.channel, + lastTo: deliver.to, + ...(deliver.accountId ? { lastAccountId: deliver.accountId } : {}), + ...(deliver.threadId !== undefined ? { lastThreadId: deliver.threadId } : {}), + deliveryContext: { + channel: deliver.channel, + to: deliver.to, + ...(deliver.accountId ? { accountId: deliver.accountId } : {}), + ...(deliver.threadId !== undefined ? { threadId: deliver.threadId } : {}), + }, + } + : { lastChannel: "world2agent" }; + + // Build the entry once, used by both paths below. On existing entries we + // also re-assert deliverFields so a config change (e.g. user re-paired + // their channel) takes effect on the very next signal without needing a + // session reset. We also strip a stale `agentHarnessId` because earlier + // versions of this plugin pinned `agentHarnessId: "claude-cli"` and that + // value, once persisted, makes OpenClaw refuse the run on hosts that + // don't have that harness registered. + const entryFor = (existing?: Record): Record => { + if (existing) { + const merged: Record = { + ...existing, + ...deliverFields, + updatedAt: now, + lastInteractionAt: now, + }; + delete merged.agentHarnessId; + return merged; + } + return { + sessionId: params.sessionId, + sessionFile: params.sessionFile, + sessionStartedAt: now, + startedAt: now, + updatedAt: now, + lastInteractionAt: now, + endedAt: null, + status: "idle", + origin: "world2agent", + chatType: "embedded", + ...deliverFields, + // No agentHarnessId — let OpenClaw resolve from agent defaults. + // Pinning a specific harness here used to break setups that don't + // have that harness registered (e.g. claude-cli unavailable on a + // host that runs openrouter/auto). Once written into the session + // entry, OpenClaw refuses to switch harness for the session id. + ...(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 + } +} + +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, + ...(payload.deliver ? { deliver: payload.deliver } : {}), + }); + + 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..833d854 --- /dev/null +++ b/openclaw-plugin/src/install.ts @@ -0,0 +1,252 @@ +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<{ 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, + ); + const signals = ( + (installed.packageJson.w2a as Record | undefined)?.signals as + | string[] + | undefined + )?.join(", "); + + 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.", + "- 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 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(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( + `Provide either --config-json '' inline or --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..6990272 --- /dev/null +++ b/openclaw-plugin/src/manifest.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; +import { access, readFile, rm } from "node:fs/promises"; +import { packageToSkillId } from "@world2agent/sdk"; +import { normalizeDeliver } from "./config.js"; +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 { + const deliver = normalizeDeliver(entry.deliver); + return { + sensor_id: entry.sensor_id, + pkg: 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 ?? {}, + ...(deliver ? { deliver } : {}), + }; +} + +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`); + } + + const deliver = normalizeDeliver(entry.deliver); + 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, + ...(deliver ? { deliver } : {}), + }; +} + +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..72bcc9f --- /dev/null +++ b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts @@ -0,0 +1,252 @@ +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?( + 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 { + runEmbeddedAgent?(request: EmbeddedAgentRunRequest): Promise; + resolveAgentDir?(config: OpenClawConfig, agentId?: string): string; + resolveAgentWorkspaceDir?(config: OpenClawConfig, agentId?: string): string; + resolveAgentTimeoutMs?(config: OpenClawConfig): number; + session?: OpenClawRuntimeAgentSessionApi; +} + +export interface OpenClawSubagentRunParams { + sessionKey: string; + message: string; + provider?: string; + model?: string; + /** + * When true, OpenClaw runs the embedded agent AND calls + * deliverAgentCommandResult — the assistant reply is routed to the + * channel/recipient stored on the session entry's deliveryContext. + * Without `deliver: true`, the run produces an assistant message that + * stays inside the session lane (no IM push). + */ + deliver?: boolean; + extraSystemPrompt?: string; + lane?: string; + lightContext?: boolean; + idempotencyKey?: string; +} + +export interface OpenClawSubagentRunResult { + runId: string; +} + +export interface OpenClawSubagentWaitParams { + runId: string; + timeoutMs?: number; +} + +export interface OpenClawSubagentWaitResult { + status: "ok" | "error" | "timeout"; + error?: string; +} + +export interface OpenClawRuntimeSubagentApi { + run?(params: OpenClawSubagentRunParams): Promise; + waitForRun?(params: OpenClawSubagentWaitParams): Promise; +} + +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; + current?(): OpenClawConfig; + /** @deprecated Use mutateConfigFile / replaceConfigFile instead. */ + writeConfigFile?( + config: OpenClawConfig, + options?: OpenClawConfigWriteOptions, + ): Promise; + mutateConfigFile?(params: OpenClawMutateConfigFileParams): Promise; + replaceConfigFile?(params: OpenClawReplaceConfigFileParams): 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; + system?: OpenClawRuntimeSystemApi; + subagent?: OpenClawRuntimeSubagentApi; + }; +} + +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..c038c9a --- /dev/null +++ b/openclaw-plugin/src/plugin.ts @@ -0,0 +1,141 @@ +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"; + +// Module-level state. OpenClaw can call register() multiple times within +// a single gateway process (e.g. config-driven plugin reload, or some +// startup sequences that load the plugin twice). Without this state, each +// register() would create a fresh SensorRuntime + start a fresh sensor, +// leaving the previous SensorRuntime's sensor running as an orphan. The +// orphan keeps its own setInterval poll loop and FileSensorStore mirror, +// causing duplicate emits and dedup chaos. Storing state at module scope +// lets a re-register stop the previous runtime before creating a new one. +let activeRuntime: SensorRuntime | null = null; + +// 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 }; + + // If a previous register() left a runtime running in this same + // process, stop its sensors before swapping in the new one. Fire and + // forget — register() must stay sync — but the stopAll runs to + // completion in the background and the old SensorRuntime is then + // garbage-collected once nothing references it. + const previousRuntime = activeRuntime; + if (previousRuntime) { + log(api, "[w2a] re-register detected; stopping previous runtime's sensors"); + void previousRuntime.stopAll().catch((err) => { + log(api, `[w2a] previous runtime stopAll failed: ${err instanceof Error ? err.message : String(err)}`); + }); + } + + 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), + }); + activeRuntime = runtime; + + 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..646f482 --- /dev/null +++ b/openclaw-plugin/src/runtime.ts @@ -0,0 +1,208 @@ +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(); + // Serialize applyManifest calls. Without this, two concurrent invocations + // (e.g. plugin startup race with `world2agent.reload`, or two reloads in + // quick succession) can both observe an empty handle map, both call + // `startHandle`, and orphan one of the resulting in-process sensor + // instances — the orphan keeps a private `setInterval` poll loop and a + // private `FileSensorStore` mirror, defeating dedup and creating an emit + // storm. Same pattern as hermes-sensor-bridge's supervisor. + private applyLock: Promise = Promise.resolve(); + + 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 callId = Math.random().toString(36).slice(2, 8); + let release!: () => void; + const previous = this.applyLock; + this.applyLock = new Promise((resolve) => { + release = resolve; + }); + this.log(`[w2a/lock] ${callId} queued (handles=${[...this.handles.keys()].join(",") || "none"})`); + await previous.catch(() => undefined); + this.log(`[w2a/lock] ${callId} acquired (handles=${[...this.handles.keys()].join(",") || "none"})`); + try { + const result = await this.applyManifestUnlocked(entries); + this.log( + `[w2a/lock] ${callId} done started=${result.started.length} restarted=${result.restarted.length} stopped=${result.stopped.length} failed=${result.failed.length} (handles=${[...this.handles.keys()].join(",") || "none"})`, + ); + return result; + } finally { + release(); + } + } + + private async applyManifestUnlocked(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`), + }); + // 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: sensorLogger, + logEmits: true, + onSignal: async (signal) => { + try { + await this.dispatcher.dispatch({ + sensorId: entry.sensor_id, + skillId: entry.skill_id, + signal, + ...(entry.deliver ? { deliver: entry.deliver } : {}), + }); + this.log( + `[w2a/${entry.sensor_id}] dispatched ${signal.signal_id} [${signal.event?.type ?? "unknown"}]`, + ); + } catch (error) { + this.log( + `[w2a/${entry.sensor_id}] dispatch failed for ${signal.signal_id}: ${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..ec04833 --- /dev/null +++ b/openclaw-plugin/src/types.ts @@ -0,0 +1,115 @@ +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 DeliverConfig { + channel: string; + to: string; + accountId?: string; + threadId?: string | number; +} + +export interface SensorEntry { + sensor_id: string; + pkg: string; + skill_id: string; + enabled: boolean; + isolated?: boolean; + config: Record; + deliver?: DeliverConfig; +} + +export interface SensorManifest { + version: 1; + sensors: SensorEntry[]; +} + +export interface DispatcherDispatchInput { + sensorId: string; + skillId: string; + signal: W2ASignal; + sessionId?: string; + deliver?: DeliverConfig; +} + +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; + deliver?: DeliverConfig; +} + +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; + deliver?: DeliverConfig; +} + +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..f371f62 --- /dev/null +++ b/openclaw-plugin/test/dispatch.test.ts @@ -0,0 +1,320 @@ +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("dispatches via runEmbeddedAgent with `# System Event` framed prompt", async () => { + const calls: EmbeddedAgentRunRequest[] = []; + const dispatcher = new EmbeddedDispatcher({ + api: { + runtime: { + agent: { + runEmbeddedAgent: vi.fn(async (request: EmbeddedAgentRunRequest) => { + calls.push(request); + return { ok: true }; + }), + }, + // NO `system` namespace at all → must fall back to embedded path + }, + }, + openclawConfigRef: { + current: { + agents: { + defaults: { contextInjection: "continuation-skip" }, + list: [], + }, + }, + }, + pluginConfig: { + defaultAgentId: "world2agent", + requestTimeoutMs: 12_345, + ingestDedupTtlMs: 3_600_000, + }, + paths: makePaths("/tmp/w2a-openclaw-dispatch-fallback"), + }); + + 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("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"); + }); + + it("propagates deliver config to runEmbeddedAgent and persists it on the session entry", async () => { + const calls: EmbeddedAgentRunRequest[] = []; + const root = `/tmp/w2a-openclaw-dispatch-deliver-${Date.now()}`; + 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, + deliver: { + channel: "feishu", + to: "oc_chat_abc123", + accountId: "acct-1", + }, + }, + paths: makePaths(root), + }); + + await dispatcher.dispatch({ + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + }); + + expect(calls).toHaveLength(1); + const req = calls[0] as Record; + expect(req.messageChannel).toBe("feishu"); + expect(req.messageTo).toBe("oc_chat_abc123"); + expect(req.agentAccountId).toBe("acct-1"); + + const fs = await import("node:fs/promises"); + const storePath = `${root}/.openclaw/agents/world2agent/sessions/sessions.json`; + const raw = JSON.parse(await fs.readFile(storePath, "utf8")) as Record; + const entry = raw["agent:world2agent:w2a-fake-tick"] as Record; + expect(entry.lastChannel).toBe("feishu"); + expect(entry.lastTo).toBe("oc_chat_abc123"); + expect(entry.lastAccountId).toBe("acct-1"); + expect(entry.deliveryContext).toMatchObject({ + channel: "feishu", + to: "oc_chat_abc123", + accountId: "acct-1", + }); + }); + + it("uses runtime.subagent.run with deliver:true when deliver is configured AND subagent is exposed", async () => { + const subagentCalls: Array> = []; + const waitCalls: Array> = []; + const runEmbedded = vi.fn(async () => ({ ok: true })); + const root = `/tmp/w2a-openclaw-dispatch-subagent-${Date.now()}`; + const dispatcher = new EmbeddedDispatcher({ + api: { + runtime: { + agent: { + runEmbeddedAgent: runEmbedded, + }, + subagent: { + run: vi.fn(async (params: Record) => { + subagentCalls.push(params); + return { runId: "run-abc" }; + }), + waitForRun: vi.fn(async (params: Record) => { + waitCalls.push(params); + return { status: "ok" }; + }), + }, + }, + }, + openclawConfigRef: { + current: { + agents: { + defaults: { contextInjection: "continuation-skip" }, + list: [], + }, + }, + }, + pluginConfig: { + defaultAgentId: "world2agent", + requestTimeoutMs: 12_345, + ingestDedupTtlMs: 3_600_000, + deliver: { channel: "feishu", to: "oc_chat_xxx" }, + }, + paths: makePaths(root), + }); + + const result = (await dispatcher.dispatch({ + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + })) as { path: string; result: { runId: string } }; + + expect(result.path).toBe("subagent"); + expect(result.result.runId).toBe("run-abc"); + expect(subagentCalls).toHaveLength(1); + expect(subagentCalls[0]?.sessionKey).toBe("agent:world2agent:w2a-fake-tick"); + expect(subagentCalls[0]?.deliver).toBe(true); + expect((subagentCalls[0]?.message as string).startsWith("# System Event")).toBe(true); + // Per-call provider/model overrides are rejected by OpenClaw's plugin + // subagent runtime — we MUST NOT pass them; the runtime resolves the + // provider/model from agent defaults instead. + expect(subagentCalls[0]).not.toHaveProperty("provider"); + expect(subagentCalls[0]).not.toHaveProperty("model"); + expect(waitCalls).toHaveLength(1); + expect(waitCalls[0]?.runId).toBe("run-abc"); + // runEmbeddedAgent must NOT be invoked when subagent path is taken + expect(runEmbedded).not.toHaveBeenCalled(); + + // Session entry still gets deliveryContext written so the runtime can + // resolve the channel target inside subagent.run. + const fs = await import("node:fs/promises"); + const storePath = `${root}/.openclaw/agents/world2agent/sessions/sessions.json`; + const raw = JSON.parse(await fs.readFile(storePath, "utf8")) as Record; + const entry = raw["agent:world2agent:w2a-fake-tick"] as Record; + expect(entry.lastChannel).toBe("feishu"); + expect(entry.lastTo).toBe("oc_chat_xxx"); + }); + + it("per-sensor deliver in dispatch input overrides plugin default", 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, + deliver: { channel: "feishu", to: "default-chat" }, + }, + paths: makePaths(`/tmp/w2a-openclaw-dispatch-override-${Date.now()}`), + }); + + await dispatcher.dispatch({ + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + deliver: { channel: "telegram", to: "user-42" }, + }); + + expect(calls).toHaveLength(1); + const req = calls[0] as Record; + expect(req.messageChannel).toBe("telegram"); + expect(req.messageTo).toBe("user-42"); + }); +}); + +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..836b42e --- /dev/null +++ b/openclaw-plugin/test/manifest.test.ts @@ -0,0 +1,113 @@ +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, 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: "my-custom-handler", + 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: "my-custom-handler", + enabled: true, + isolated: true, + config: { interval_ms: 30_000 }, + }, + ], + }); + }); + + 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, + 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/runtime.test.ts b/openclaw-plugin/test/runtime.test.ts new file mode 100644 index 0000000..7ed8329 --- /dev/null +++ b/openclaw-plugin/test/runtime.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; + +// Stub `startSensor` so SensorRuntime doesn't try to import the real +// hackernews sensor or hit the HN API. The mock returns a no-op cleanup. +const startSensorMock = vi.fn(); +vi.mock("@world2agent/sdk", () => ({ + startSensor: (...args: unknown[]) => startSensorMock(...args), + FileSensorStore: class { + constructor(_opts: unknown) {} + async flush() {} + }, +})); + +// Avoid the dynamic-import inside `loadSensorSpec` — we replace it via the +// resolveImportTarget shim. Easier: stub `runtime.ts`'s loadSensorSpec by +// providing a fake package that resolves cleanly. We do this by mocking +// `./supervisor/shared.js`'s `resolveImportTarget` to point at a tiny +// in-memory module. +vi.mock("../src/supervisor/shared.js", async () => { + const actual = await vi.importActual>( + "../src/supervisor/shared.js", + ); + return { + ...actual, + resolveImportTarget: () => "data:text/javascript;base64," + + Buffer.from("export default { start: () => () => undefined };").toString("base64"), + }; +}); + +import { SensorRuntime } from "../src/runtime.js"; +import type { Dispatcher, SensorEntry, World2AgentPaths } from "../src/types.js"; + +const PATHS: World2AgentPaths = { + baseDir: "/tmp/w2a-runtime-test", + manifestFile: "/tmp/w2a-runtime-test/sensors.json", + stateDir: "/tmp/w2a-runtime-test/state", + sessionDir: "/tmp/w2a-runtime-test/sessions", + openclawHome: "/tmp/w2a-runtime-test/.openclaw", + openclawSkillsDir: "/tmp/w2a-runtime-test/.openclaw/skills", + ingestHmacSecretFile: "/tmp/w2a-runtime-test/.secret", +}; + +const ISOLATED_NOOP = { + apply: vi.fn(async () => ({ started: [], restarted: [], stopped: [], failed: [] })), + terminateAll: vi.fn(async () => undefined), +}; + +const DISPATCHER: Dispatcher = { dispatch: vi.fn(async () => ({ ok: true })) }; + +const ENTRY: SensorEntry = { + sensor_id: "hackernews", + pkg: "@fake/sensor-hackernews", + skill_id: "fake", + enabled: true, + isolated: false, + config: {}, +}; + +describe("SensorRuntime.applyManifest concurrency lock", () => { + it("serializes concurrent applyManifest calls so a single sensor is only started once", async () => { + let inFlight = 0; + let maxInFlight = 0; + + startSensorMock.mockImplementation(async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + // Simulate slow async start. Without the lock, three concurrent + // applyManifest calls would all enter this window and inFlight + // would reach 3. + await new Promise((r) => setTimeout(r, 30)); + inFlight--; + return async () => undefined; // cleanup + }); + + const runtime = new SensorRuntime({ + dispatcher: DISPATCHER, + isolatedRunnerManager: ISOLATED_NOOP as never, + paths: PATHS, + log: () => undefined, + }); + + const results = await Promise.all([ + runtime.applyManifest([ENTRY]), + runtime.applyManifest([ENTRY]), + runtime.applyManifest([ENTRY]), + ]); + + expect(maxInFlight).toBe(1); + expect(startSensorMock).toHaveBeenCalledTimes(1); + expect(results[0]?.started).toEqual(["hackernews"]); + expect(results[1]?.started).toEqual([]); + expect(results[2]?.started).toEqual([]); + }); + + it("queued applyManifest calls execute strictly in FIFO order", async () => { + const order: string[] = []; + startSensorMock.mockImplementation(async (...args: unknown[]) => { + const ctx = (args[1] ?? {}) as Record; + const sensorId = String(((ctx as { spec?: { id?: string } }).spec?.id) ?? "?"); + order.push(`start`); + await new Promise((r) => setTimeout(r, 20)); + order.push(`done`); + void sensorId; + return async () => undefined; + }); + + const runtime = new SensorRuntime({ + dispatcher: DISPATCHER, + isolatedRunnerManager: ISOLATED_NOOP as never, + paths: PATHS, + log: () => undefined, + }); + + const entryA: SensorEntry = { ...ENTRY, sensor_id: "a", pkg: "@fake/a", skill_id: "a" }; + const entryB: SensorEntry = { ...ENTRY, sensor_id: "b", pkg: "@fake/b", skill_id: "b" }; + + await Promise.all([runtime.applyManifest([entryA]), runtime.applyManifest([entryB])]); + + // The lock guarantees no interleaving — entry B's start never overlaps + // with entry A's start. With concurrent (unlocked) execution we'd see + // ["start", "start", "done", "done"]. Locked execution gives strictly + // alternating start→done pairs. + expect(order).toEqual(["start", "done", "start", "done"]); + }); +}); 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"], + }, +}); +