diff --git a/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md b/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md index 35dbbf0..8952b2c 100644 --- a/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md +++ b/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md @@ -376,7 +376,8 @@ error.detected | MVP joinpoint | Emitted today | Bridge source | Sync veto at host call site | | --- | --- | --- | --- | | `input.received` | yes | `message_received`, `inbound_claim`, `before_dispatch` | no (observe / buffer) | -| `queue.before_enqueue` | yes (observe) | queue depth poll (`getFollowupQueueDepth`) | no — needs native hook at `enqueueFollowupRun` | +| `queue.before_enqueue` | yes | **`queue_before_enqueue` plugin hook** (fork); queue depth poll fallback | **yes** — `block` / `queue.prompt` / `queue.summary_line` rewrite via bridge `queue_guard` | +| `queue.after_enqueue` | yes (observe) | **`queue_after_enqueue` plugin hook** (fork); poll snapshot sync | no | | `queue.before_collect` | yes (observe) | queue depth poll (depth decrease) | no | | `reply_run.before_begin` | yes (observe) | `onAgentEvent` lifecycle `start` | no | | `reply_run.phase.running` | yes (observe) | first `assistant` / `tool` / `item` after start | no | @@ -391,7 +392,7 @@ error.detected | `response.before_final` | partial | `message_sending` cancel path | cancel outbound only | | `verification.after_fail` | no | — | — | | `heartbeat.before_run` | no | OpenCOAT `runtime_tick` / future hook | — | -| `error.detected` | partial | `on_error` lifecycle alias via agent error events | no | +| `error.detected` | yes (observe) | `onAgentEvent` lifecycle `error` | no | Default: `runtimeObservers: true`, `observerPollMs: 500`. Full hook table: [bridge README](../../integrations/openclaw-opencoat-bridge/README.md). @@ -413,7 +414,7 @@ C. Not direct — needs OpenClaw middleware/hook PR, or is OpenCOAT-internal **Weakest / internal-only:** `token.*`, `span.*`, `prompt.section.*` (until host passes structured sections), implicit planner steps, true memory read/write on every path, unified `response.before_final` verifier (partial via `message_sending` only). -**Strong control gap (upstream):** synchronous veto at `enqueueFollowupRun`, full `reply_run.phase.*`, and any path where only post-hoc agent events exist today. +**Strong control gap (upstream):** full `reply_run.phase.*`, `tool.result.before_emit`, unified `memory.before_write`, and paths where only post-hoc agent events exist today. **`queue.before_enqueue` sync veto shipped on OpenClaw fork** (`queue_before_enqueue` hook + bridge `queue_guard`); poll remains observe-only fallback. ### 5.2 Tier A — usable now @@ -429,11 +430,13 @@ C. Not direct — needs OpenClaw middleware/hook PR, or is OpenCOAT-internal | Agent event stream | `stream: compaction` start/end | memory JPs | observer | observe | | Agent event stream | `stream: lifecycle` start | `reply_run.before_begin` | observer | observe | | Agent event stream | `stream: tool` / `item` / `assistant` after start | `reply_run.phase.running` | observer | observe | -| Agent event stream | `stream: command_output` | `command.output_stream` | not wired | observe (future) | -| Agent event stream | `stream: patch` | `patch.summary_created` | not wired | observe (future) | +| Agent event stream | `stream: command_output` | `command.output_stream` | yes (observe) | no | +| Agent event stream | `stream: patch` | `patch.summary_created` | yes (observe) | no | | Task API | `runtime.tasks.runs.bindSession().list()` | `task.*` | poll diff | observe | | Task hooks | `subagent_*` | `task.after_create`, `task.before_terminal` | yes | observe / spawn veto | | Input | `message_received`, `inbound_claim`, `before_dispatch` | `input.received` | yes | observe / extract | +| Plugin hooks | `queue_before_enqueue` / `queue_after_enqueue` | `queue.before_enqueue` / `queue.after_enqueue` | yes (fork) | **block** / prompt & summaryLine **rewrite** | +| Queue poll | `getFollowupQueueDepth` | `queue.before_enqueue`, `queue.before_collect` | fallback | observe only (no veto) | **Important correction:** OpenClaw’s plugin hook `before_tool_call` **is** a pre-execute guard — do not confuse it with `onAgentEvent` `stream: tool`, which fires around tool **start** and is observe-only. Catalog name `tool.before_call` maps to the plugin hook, not `tool.started`. @@ -441,7 +444,6 @@ C. Not direct — needs OpenClaw middleware/hook PR, or is OpenCOAT-internal | Joinpoint (design) | How to attach | Bridge / OpenCOAT today | | --- | --- | --- | -| `queue.before_enqueue` / `after_enqueue` | wrap `enqueueFollowupRun` or depth poll | poll only (late) | | `queue.before_drain` | wrap `scheduleFollowupDrain` | not wired | | `input.before_enqueue` | adapter before enqueue | partial (`on_user_input` buffer) | | `prompt.before_send_to_model` | `FollowupRun.extraSystemPrompt`, hook fold | plugin + discovery | @@ -458,7 +460,6 @@ OpenCOAT applies **weak modulation** here: extra system prompt, queue/task *poli ```text tool.before_execute # if stricter than plugin before_tool_call (args rewrite mid-flight) -queue.before_enqueue # sync veto at enqueueFollowupRun (bridge poll is too late) reply_run.phase.* # per-phase hooks on ReplyOperation memory.before_read / before_write # unified memory middleware (compaction hooks are partial) response.before_final # unified verifier before channel delivery (message_sending is partial) @@ -498,11 +499,13 @@ Upstream “neurosurgery” (recommended order): | Wave | Joinpoints | Mechanism | | --- | --- | --- | | **Shipped (bridge)** | §4.1 MVP rows marked “yes” | plugin hooks + `runtime-observers.ts` | -| **Next (observe)** | `command.output_stream`, `patch.summary_created`, streaming deltas | extend `onAgentEvent` mapping in bridge (use catalog names, not `command.output`) | -| **Next (upstream)** | sync `queue.before_enqueue`, `reply_run.phase.*`, `response.before_final` | OpenClaw plugin hooks at call sites | +| **Shipped (observe)** | `command.output_stream`, `patch.summary_created`, `error.detected` | `onAgentEvent` mapping in bridge `runtime-observers.ts` | +| **Next (observe)** | streaming deltas | extend `onAgentEvent` / outbound callbacks | +| **Shipped (fork + bridge)** | `queue.before_enqueue` / `queue.after_enqueue` sync veto + observe | OpenClaw `queue_before_enqueue` / `queue_after_enqueue` + bridge `queue_guard` | +| **Next (upstream)** | `reply_run.phase.*`, `response.before_final`, `tool.result.before_emit` | OpenClaw plugin hooks at call sites | | **OpenCOAT-only** | `span.*`, `token.*`, message children | discovery on prompt payload | -Design catalog lists **17 MVP names**; bridge **strong loop** today is smaller: input → prompt fold → tool guard → optional outbound cancel → task/subagent edges, plus observe-only queue/run/task/event stream for DCN. +Design catalog lists **17 MVP names**; bridge **strong loop** today: input → prompt fold → tool guard → optional outbound cancel → **queue enqueue veto/rewrite (fork)** → task/subagent edges, plus observe-only run/task/event stream for DCN. Dogfood: [`examples/09_queue_hook_dogfood`](../../examples/09_queue_hook_dogfood/README.md). --- @@ -656,7 +659,7 @@ Bridge module `runtime-observers.ts` — uses host APIs already available to plu | Source | Joinpoints | Mechanism | | --- | --- | --- | -| `api.runtime.events.onAgentEvent` | `reply_run.before_begin`, `reply_run.phase.running`, `planning.plan_updated`, `approval.requested`, compaction → `before_memory_write` / `after_memory_write` | event stream | +| `api.runtime.events.onAgentEvent` | `reply_run.before_begin`, `reply_run.phase.running`, `planning.plan_updated`, `approval.requested`, `command.output_stream`, `patch.summary_created`, `error.detected` (lifecycle `error`), compaction → `before_memory_write` / `after_memory_write` | event stream | | `api.registerHook` | `session:compact:before` / `after` → memory JPs | internal gateway hooks | | Host `getFollowupQueueDepth` | `queue.before_enqueue`, `queue.before_collect` | `registerService` poll per tracked `sessionKey` | | `api.runtime.tasks.runs.bindSession().list()` | `task.before_create`, `task.after_create`, `task.before_terminal` | task registry diff poll | diff --git a/docs/guides/concern-authoring-aop.md b/docs/guides/concern-authoring-aop.md index 173bd72..255cf7e 100644 --- a/docs/guides/concern-authoring-aop.md +++ b/docs/guides/concern-authoring-aop.md @@ -37,9 +37,22 @@ legacy `pointcut` / `advice` / `weaving_policy` are optional and sync automatica ## Message-level guard (OpenClaw bridge) -The gateway bridge registers **26** plugin hooks plus **runtime observers** (`onAgentEvent`, -queue/task poll) for MVP joinpoints such as `queue.before_enqueue` and `reply_run.before_begin` +The gateway bridge registers **29** plugin hooks plus **runtime observers** (`onAgentEvent`, +queue/task poll) for MVP joinpoints such as `queue.before_enqueue` (sync **block/rewrite** +on OpenClaw fork via `queue_before_enqueue`) and `reply_run.before_begin` (observe-only — see [joinpoint model §4.1](../design/opencoat-openclaw-joinpoint-model-v0.1.md#41-mvp-emit-status-bridge-integrationsopenclaw-opencoat-bridge)). +Dogfood concerns: [`examples/09_queue_hook_dogfood`](../examples/09_queue_hook_dogfood/README.md). + +### Decision vs observe (OpenClaw fork + bridge) + +Use the **HyperdustLabs fork** (`opencoat/hooks-v0.1`, see [openclaw-fork-dev.md](openclaw-fork-dev.md)) — not npm registry OpenClaw — for native queue hooks and dogfood. + +| Class | Joinpoints (examples) | Host effect today | +| --- | --- | --- | +| **Decision** | `queue.before_enqueue`, `tool.before_call`, `subagent_spawning` → `task.before_create` | block, rewrite, spawn veto, prompt prepend, outbound cancel | +| **Observe** | `reply_run.*`, `planning.*`, `approval.requested`, `command.output_stream`, `patch.summary_created`, `error.detected`, queue poll fallback | DCN / activation only; no sync veto | + +**Next decision hooks** ship on the **same fork branch** (`tool_result_persist`, `reply_run.phase.*`, `response.before_final`, …) — not upstream `openclaw/openclaw`. See [fork hook backlog](openclaw-fork-dev.md#fork-hook-backlog-post-queue). Prefer `user_message()` over flat `before_response` when the bridge sends `messages[]`: @@ -62,6 +75,46 @@ Prefer `user_message()` over flat `before_response` when the bridge sends `messa } ``` +## Queue guard (OpenClaw fork + bridge) + +Target `queue.before_enqueue` with explicit `joinpoints` (dotted names are not parsed +from `expression()` today). Bridge maps woven advice to OpenClaw `queue_before_enqueue`: + +| `effect.target` | `effect.mode` | OpenClaw result | +| --- | --- | --- | +| `queue.prompt` | `block` | skip enqueue | +| `queue.prompt` | `rewrite` | replace queued prompt | +| `queue.summary_line` | `rewrite` | replace summary line | + +```json +{ + "id": "oc.dogfood.queue-block", + "pointcuts": [ + { + "id": "pc-queue", + "joinpoints": ["queue.before_enqueue"], + "match": { "any_keywords": ["QUEUE_DOGFOOD_BLOCK"] } + } + ], + "advices": [ + { + "kind": "before", + "pointcut_ref": "pc-queue", + "template": "memory_write_guard", + "content": "Follow-up queue blocked by policy.", + "effect": { + "mode": "block", + "level": "memory_level", + "target": "queue.prompt", + "priority": 0.95 + } + } + ] +} +``` + +Full dogfood set: [`examples/09_queue_hook_dogfood`](../examples/09_queue_hook_dogfood/README.md). + ## Declare precedence ```json diff --git a/docs/guides/openclaw-fork-dev.md b/docs/guides/openclaw-fork-dev.md new file mode 100644 index 0000000..ed7bbeb --- /dev/null +++ b/docs/guides/openclaw-fork-dev.md @@ -0,0 +1,94 @@ +# OpenClaw fork development (1:1 with global CLI) + +OpenCOAT + OpenClaw bridge development uses the **HyperdustLabs fork**, not npm +registry OpenClaw or the upstream `openclaw/openclaw` main tree. + +| Item | Canonical path | +| --- | --- | +| Fork repo | `~/openclaw-fork` | +| Remote | `https://github.com/HyperdustLabs/openclaw.git` | +| Branch | `opencoat/hooks-v0.1` | +| Lock file | `~/.openclaw/openclaw-fork.json` | + +## Policy + +1. **Global `openclaw` must be 1:1 with `~/openclaw-fork`** — same commit, same + `openclaw.mjs`, same `dist/index.js`. No separate npm registry install. +2. **Gateway LaunchAgent** must run `~/openclaw-fork/dist/index.js` (port 18789). +3. **Do not use** `~/openclaw` (upstream clone) for OpenCOAT dogfood — it lacks + queue hooks and fork-specific plugin SDK surfaces. +4. After every `git pull` in the fork, re-run bind + rebuild. + +## One-time setup + +From OpenCOAT repo root: + +```bash +./scripts/use-openclaw-fork.sh --clone # if ~/openclaw-fork missing +# or, if fork already exists: +./scripts/use-openclaw-fork.sh +``` + +This: + +- `npm install -g .` from `~/openclaw-fork` (symlink, not registry copy) +- installs `~/.local/bin/openclaw` → `openclaw-fork` shim +- reinstalls LaunchAgent gateway on fork `dist/` +- writes `~/.openclaw/openclaw-fork.json` + +## Daily workflow + +```bash +cd ~/openclaw-fork +git pull origin opencoat/hooks-v0.1 +# ... edit, commit, push to HyperdustLabs/openclaw ... + +cd /path/to/OpenCOAT +./scripts/use-openclaw-fork.sh --update # pull + pnpm install + build + rebind +./scripts/check-openclaw-fork.sh # verify 1:1 +``` + +Bridge rebuild after TS changes: + +```bash +cd integrations/openclaw-opencoat-bridge && npm run build +openclaw daemon restart +``` + +## Verify + +```bash +./scripts/check-openclaw-fork.sh +openclaw --version # e.g. 2026.5.19 (593c5de) +openclaw gateway status # CLI version == Gateway version +# Command line should include: ~/openclaw-fork/dist/index.js +``` + +## What breaks 1:1 alignment + +| Action | Fix | +| --- | --- | +| `npm install -g openclaw@latest` (registry) | `./scripts/use-openclaw-fork.sh` | +| Running gateway from `/tmp/...` without updating LaunchAgent | `openclaw gateway install --force` | +| Fork pulled but not rebuilt | `cd ~/openclaw-fork && pnpm build` then `./scripts/use-openclaw-fork.sh` | +| Old BAIclaw shim on PATH | removed by `use-openclaw-fork.sh`; use fork shim only | + +## Fork hook backlog (post-queue) + +After [PR #77](https://github.com/HyperdustLabs/OpenCOAT/pull/77) (queue `queue_before_enqueue` / `queue_after_enqueue` + bridge `queue_guard`) lands on `main`, plan **paired fork + OpenCOAT PRs** on `opencoat/hooks-v0.1`: + +| Priority | Fork hook / joinpoint | Notes | +| --- | --- | --- | +| 1 | `tool_result_persist` | Fork hook is **sync-only** today; needs async or local policy cache before bridge can weave | +| 2 | `reply_run.phase.*` | Native hooks at `ReplyOperation` phase edges (not lifecycle approx) | +| 3 | `response.before_final` | Unified verifier before channel delivery (beyond `message_sending` cancel) | +| 4 | `memory.before_write` | Unified memory middleware (compaction hooks are observe-only today) | +| 5 | `queue.before_drain` | Wrap `scheduleFollowupDrain` | + +Bridge skipped (fork has hook, hot path): `before_message_write`, `tool_result_persist` until sync/async contract is extended. + +## Related + +- [OpenClaw bridge README](../../integrations/openclaw-opencoat-bridge/README.md) +- [Joinpoint model §4.1 queue hooks](../design/opencoat-openclaw-joinpoint-model-v0.1.md) +- HyperdustLabs PR: `opencoat/hooks-v0.1` on `HyperdustLabs/openclaw` diff --git a/examples/09_queue_hook_dogfood/README.md b/examples/09_queue_hook_dogfood/README.md new file mode 100644 index 0000000..bb817c7 --- /dev/null +++ b/examples/09_queue_hook_dogfood/README.md @@ -0,0 +1,152 @@ +# 09 — Queue hook dogfood (OpenClaw fork + bridge) + +End-to-end dogfood for **native** `queue_before_enqueue` / `queue_after_enqueue` +on the OpenClaw fork (`opencoat/hooks-v0.1`). OpenCOAT matches +`queue.before_enqueue` and returns advice the bridge maps to OpenClaw +`{ block, prompt, summaryLine }`. + +Import **one concern at a time** — all three target the same joinpoint. + +## Layout + +```text +examples/09_queue_hook_dogfood/ +├── README.md +├── concerns/ +│ ├── oc.dogfood.queue-block.json +│ ├── oc.dogfood.queue-prompt-rewrite.json +│ └── oc.dogfood.queue-summary-rewrite.json +└── scripts/ + ├── smoke-rpc.sh # daemon-only smoke (no gateway) + ├── live-queue-block-test.sh # live block via gateway chat.send + └── live-queue-rewrite-test.sh # live prompt/summary rewrite (chat.send) +``` + +## Prerequisites + +1. OpenCOAT daemon: `opencoat runtime up` +2. OpenClaw **fork** 1:1: `./scripts/use-openclaw-fork.sh` then `./scripts/check-openclaw-fork.sh` +3. Bridge linked: `openclaw plugins install -l integrations/openclaw-opencoat-bridge` +4. Gateway restarted on fork build (≥ 2026.5.19) + +## 1. RPC smoke (fast, no Telegram) + +From repo root: + +```bash +chmod +x examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh +./examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh all +``` + +**Pass criteria** (per case, in `result.injections[]`): + +| Case | Trigger keyword | Expected `mode` | Expected `target` | +| --- | --- | --- | --- | +| block | `QUEUE_DOGFOOD_BLOCK` | `block` | `queue.prompt` | +| prompt rewrite | `QUEUE_DOGFOOD_REWRITE_PROMPT` | `rewrite` | `queue.prompt` | +| summary rewrite | `QUEUE_DOGFOOD_REWRITE_SUMMARY` | `rewrite` | `queue.summary_line` | + +## 2. Import concerns + +```bash +opencoat concern import examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-block.json +# or prompt / summary variant — disable others first if they share keywords +opencoat concern list | grep oc.dogfood.queue +``` + +## 3. Live gateway dogfood + +Enable bridge logging (optional): + +```json +"plugins": { + "entries": { + "@hyperdustlabs/opencoat-bridge": { + "config": { "logActivations": true } + } + } +} +``` + +**Block** (automated script — preferred) + +```bash +chmod +x examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh +./examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh +``` + +The script resolves `sessionKey` from `OPENCLAW_SESSION_ID` (default dogfood session), +starts a long run with `gateway call chat.send`, waits `WAIT_ACTIVE_SEC` (default 8), +then sends `QUEUE_DOGFOOD_BLOCK` while the first run is still active. It reads today's +gateway log under `/tmp/openclaw/openclaw-YYYY-MM-DD.log` unless `OPENCLAW_GATEWAY_LOG` +is set. + +**Pass:** gateway log contains +`queue_before_enqueue→queue.before_enqueue: oc.dogfood.queue-block` and (usually) a +fresh DCN row for `oc.dogfood.queue-block`. + +**Block** (manual / Telegram) + +1. Import `oc.dogfood.queue-block.json` +2. Start a long reply (e.g. ask for a multi-step plan) +3. While the run is active, send: `QUEUE_DOGFOOD_BLOCK — also do X` +4. **Pass:** second message is **not** queued; gateway log may show the line above +5. **Pass:** `opencoat dcn activation-log --concern-id oc.dogfood.queue-block` + +Do **not** use two sequential `openclaw agent` calls for overlap — each call blocks +until the turn finishes, so the queue hook never fires. + +**Prompt / summary rewrite** (automated) + +Import **one** rewrite concern (disable block first if it shares the joinpoint): + +```bash +chmod +x examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh +./examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh prompt +# or: +./examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh summary +``` + +**Pass:** gateway log shows `queue_before_enqueue→…: oc.dogfood.queue-prompt-rewrite` +(or `…-summary-rewrite`) and DCN activation for that concern. Confirm rewritten +prompt/summary in queue drain behaviour manually if needed. + +**Prompt rewrite** (manual) + +1. Remove/disable block concern; import `oc.dogfood.queue-prompt-rewrite.json` +2. Active run + send: `QUEUE_DOGFOOD_REWRITE_PROMPT — tighten scope to Y` +3. **Pass:** follow-up is queued with rewritten prompt (model should see rewritten text) + +**Summary line rewrite** (manual) + +1. Import `oc.dogfood.queue-summary-rewrite.json` +2. Active run + send: `QUEUE_DOGFOOD_REWRITE_SUMMARY — minor tweak` +3. **Pass:** queued item summary line becomes + `OpenCOAT: queued follow-up (summary rewritten)` + +## Architecture reminder + +```text +OpenClaw queue_before_enqueue (sync, fork) + │ + ▼ +Bridge queue_guard → joinpoint.submit(queue.before_enqueue) + │ + ▼ +OpenCOAT weave → ConcernInjection + │ + ▼ +Bridge queueBeforeEnqueueDecision → { block | prompt | summaryLine } + │ + ▼ +OpenClaw applies before enqueue / steering +``` + +Poll-based `queue.before_enqueue` in `runtime-observers.ts` remains a +**fallback observe path** when the host lacks native hooks; it cannot veto. + +## Related + +- [Bridge README](../../integrations/openclaw-opencoat-bridge/README.md) +- [Joinpoint model §4.1](../../docs/design/opencoat-openclaw-joinpoint-model-v0.1.md#41-mvp-emit-status-bridge-integrationsopenclaw-opencoat-bridge) +- OpenCOAT PR #77 / OpenClaw fork PR #1 (queue hooks) diff --git a/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-block.json b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-block.json new file mode 100644 index 0000000..0d5db29 --- /dev/null +++ b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-block.json @@ -0,0 +1,29 @@ +{ + "id": "oc.dogfood.queue-block", + "kind": "concern", + "name": "Dogfood — block follow-up queue enqueue", + "description": "Blocks queue_before_enqueue when the queued prompt contains QUEUE_DOGFOOD_BLOCK.", + "schema_version": "0.1.0", + "pointcuts": [ + { + "id": "pc-queue-block", + "joinpoints": ["queue.before_enqueue"], + "match": { "any_keywords": ["QUEUE_DOGFOOD_BLOCK"] } + } + ], + "advices": [ + { + "id": "adv-block", + "kind": "before", + "pointcut_ref": "pc-queue-block", + "template": "memory_write_guard", + "content": "Follow-up queue blocked by OpenCOAT dogfood concern (oc.dogfood.queue-block).", + "effect": { + "mode": "block", + "level": "memory_level", + "target": "queue.prompt", + "priority": 0.95 + } + } + ] +} diff --git a/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-prompt-rewrite.json b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-prompt-rewrite.json new file mode 100644 index 0000000..f580e0b --- /dev/null +++ b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-prompt-rewrite.json @@ -0,0 +1,29 @@ +{ + "id": "oc.dogfood.queue-prompt-rewrite", + "kind": "concern", + "name": "Dogfood — rewrite queued follow-up prompt", + "description": "Rewrites queue.prompt when the incoming prompt contains QUEUE_DOGFOOD_REWRITE_PROMPT.", + "schema_version": "0.1.0", + "pointcuts": [ + { + "id": "pc-queue-prompt", + "joinpoints": ["queue.before_enqueue"], + "match": { "any_keywords": ["QUEUE_DOGFOOD_REWRITE_PROMPT"] } + } + ], + "advices": [ + { + "id": "adv-rewrite-prompt", + "kind": "around", + "pointcut_ref": "pc-queue-prompt", + "template": "rewrite_guidance", + "content": "[OpenCOAT rewrite] Summarized follow-up: continue the prior task with the new constraint only.", + "effect": { + "mode": "rewrite", + "level": "output_level", + "target": "queue.prompt", + "priority": 0.8 + } + } + ] +} diff --git a/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-summary-rewrite.json b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-summary-rewrite.json new file mode 100644 index 0000000..c73d92a --- /dev/null +++ b/examples/09_queue_hook_dogfood/concerns/oc.dogfood.queue-summary-rewrite.json @@ -0,0 +1,29 @@ +{ + "id": "oc.dogfood.queue-summary-rewrite", + "kind": "concern", + "name": "Dogfood — rewrite queued follow-up summary line", + "description": "Rewrites queue.summary_line when the incoming prompt contains QUEUE_DOGFOOD_REWRITE_SUMMARY.", + "schema_version": "0.1.0", + "pointcuts": [ + { + "id": "pc-queue-summary", + "joinpoints": ["queue.before_enqueue"], + "match": { "any_keywords": ["QUEUE_DOGFOOD_REWRITE_SUMMARY"] } + } + ], + "advices": [ + { + "id": "adv-rewrite-summary", + "kind": "around", + "pointcut_ref": "pc-queue-summary", + "template": "rewrite_guidance", + "content": "OpenCOAT: queued follow-up (summary rewritten)", + "effect": { + "mode": "rewrite", + "level": "output_level", + "target": "queue.summary_line", + "priority": 0.8 + } + } + ] +} diff --git a/examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh b/examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh new file mode 100755 index 0000000..41a313e --- /dev/null +++ b/examples/09_queue_hook_dogfood/scripts/live-queue-block-test.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Live dogfood: active run + QUEUE_DOGFOOD_BLOCK via gateway chat.send (fork + bridge). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +CONCERN_BLOCK="$SCRIPT_DIR/../concerns/oc.dogfood.queue-block.json" +RPC="${OPENCOAT_RPC:-http://127.0.0.1:7878/rpc}" + +SESSION_ID="${OPENCLAW_SESSION_ID:-ad9fe8a0-c144-4553-83d2-6868821ad452}" +WAIT_ACTIVE_SEC="${WAIT_ACTIVE_SEC:-8}" +NONCE="${DOGFOOD_NONCE:-$(date +%s)}" +MSG1="${MSG1:-DOGFOOD_RUN_${NONCE}: Read /Users/moss/OpenCOAT/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md in five chunks (lines 1-150, 151-300, 301-450, 451-600, 601-end) using read tools only. After EACH chunk output exactly 5 bullets tagged CHUNK-N-${NONCE}. Do not finish until all five chunks are done.}" +MSG2="${MSG2:-QUEUE_DOGFOOD_BLOCK — also add: keep the answer under 200 words.}" + +default_gateway_log() { + local today + today="$(date +%F)" + if [[ -f "/tmp/openclaw/openclaw-${today}.log" ]]; then + echo "/tmp/openclaw/openclaw-${today}.log" + elif [[ -f "${HOME}/.openclaw/logs/gateway.log" ]]; then + echo "${HOME}/.openclaw/logs/gateway.log" + else + echo "/tmp/openclaw/openclaw-${today}.log" + fi +} + +LOG="${OPENCLAW_GATEWAY_LOG:-$(default_gateway_log)}" +TEST_START_ISO="$(date -u +%Y-%m-%dT%H:%M:%S)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" +} + +resolve_session_key() { + local session_id="$1" + openclaw sessions list --json 2>/dev/null | python3 -c ' +import json +import sys + +target = sys.argv[1] +raw = sys.stdin.read().strip() +if not raw: + sys.exit(1) +data = json.loads(raw) +rows = data if isinstance(data, list) else data.get("sessions", []) +for row in rows: + if not isinstance(row, dict): + continue + sid = row.get("sessionId") or row.get("id") + if sid == target: + key = row.get("key") or row.get("sessionKey") + if key: + print(key) + sys.exit(0) +sys.exit(1) +' "$session_id" +} + +require_cmd openclaw +require_cmd curl +require_cmd python3 +require_cmd opencoat + +if [[ ! -f "$LOG" ]]; then + fail "gateway log not found: $LOG (set OPENCLAW_GATEWAY_LOG)" +fi + +SESSION_KEY="${OPENCLAW_SESSION_KEY:-}" +if [[ -z "$SESSION_KEY" ]]; then + SESSION_KEY="$(resolve_session_key "$SESSION_ID")" || fail "no sessionKey for sessionId=$SESSION_ID (openclaw sessions list --json)" +fi + +log_start="$(wc -l < "$LOG" | tr -d ' ')" + +echo "== live queue block dogfood ==" +echo "session_id=$SESSION_ID" +echo "session_key=$SESSION_KEY" +echo "gateway_log=$LOG (baseline lines=$log_start)" +echo "test_start_utc=$TEST_START_ISO" + +echo "== import oc.dogfood.queue-block ==" +opencoat concern import "$CONCERN_BLOCK" + +PARAMS_DIR="${TMPDIR:-/tmp}/opencoat-queue-dogfood-$$" +mkdir -p "$PARAMS_DIR" +trap 'rm -rf "$PARAMS_DIR"' EXIT + +python3 - "$PARAMS_DIR" "$SESSION_KEY" "$SESSION_ID" "$MSG1" "$MSG2" "$NONCE" <<'PY' +import json +import sys +from pathlib import Path + +out_dir = Path(sys.argv[1]) +session_key, session_id = sys.argv[2], sys.argv[3] +msg1, msg2, nonce = sys.argv[4], sys.argv[5], sys.argv[6] + +def write(name, payload): + (out_dir / name).write_text(json.dumps(payload), encoding="utf-8") + +write( + "msg1.json", + { + "sessionKey": session_key, + "sessionId": session_id, + "message": msg1, + "deliver": False, + "idempotencyKey": f"dogfood-run-{nonce}", + }, +) +write( + "msg2.json", + { + "sessionKey": session_key, + "sessionId": session_id, + "message": msg2, + "deliver": False, + "idempotencyKey": f"dogfood-block-{nonce}", + }, +) +PY + +echo "== message 1 (chat.send, long run) ==" +openclaw gateway call chat.send --json --timeout 60000 \ + --params "$(cat "$PARAMS_DIR/msg1.json")" \ + | tee "$PARAMS_DIR/msg1-response.json" + +echo "== wait ${WAIT_ACTIVE_SEC}s then message 2 (block trigger) ==" +sleep "$WAIT_ACTIVE_SEC" + +openclaw gateway call chat.send --json --timeout 60000 \ + --params "$(cat "$PARAMS_DIR/msg2.json")" \ + | tee "$PARAMS_DIR/msg2-response.json" + +sleep 2 + +echo "== gateway log (queue / opencoat-bridge) ==" +log_slice="$(mktemp)" +tail -n +"$((log_start + 1))" "$LOG" >"$log_slice" || true +if grep -E 'queue_before_enqueue→queue\.before_enqueue: oc\.dogfood\.queue-block|queue_before_enqueue.*oc\.dogfood\.queue-block' "$log_slice" | tail -5; then + hook_ok=true +else + hook_ok=false + echo "(no queue_before_enqueue activation line for oc.dogfood.queue-block)" + grep -iE 'queue_before_enqueue|oc\.dogfood\.queue-block' "$log_slice" | tail -10 || true +fi +rm -f "$log_slice" + +echo "== DCN activation (oc.dogfood.queue-block) ==" +dcn_json="$(curl -sS "$RPC" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"dcn.activation_log","params":{"concern_id":"oc.dogfood.queue-block","limit":8},"id":1}')" +echo "$dcn_json" | python3 -m json.tool 2>/dev/null | head -50 || echo "$dcn_json" + +dcn_ok=false +if printf '%s' "$dcn_json" | python3 -c ' +import json +import sys +from datetime import datetime, timezone + +threshold = sys.argv[1] +cutoff = datetime.fromisoformat(threshold.replace("Z", "+00:00")) +if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=timezone.utc) +cutoff = cutoff.timestamp() - 30 + +payload = json.load(sys.stdin) +for row in payload.get("result") or []: + ts = row.get("ts", "") + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + if dt.timestamp() >= cutoff: + print(row.get("joinpoint_id", "")) + sys.exit(0) + except ValueError: + continue +sys.exit(1) +' "$TEST_START_ISO"; then + dcn_ok=true +fi + +if [[ "$hook_ok" == true ]]; then + echo "PASS: gateway saw queue_before_enqueue for oc.dogfood.queue-block" +else + fail "gateway log missing queue_before_enqueue→oc.dogfood.queue-block (enable logActivations on bridge?)" +fi + +if [[ "$dcn_ok" == true ]]; then + echo "PASS: DCN activation logged during this run" +else + echo "WARN: no DCN activation at or after $TEST_START_ISO (hook path may still be OK)" +fi + +echo "== done ==" diff --git a/examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh b/examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh new file mode 100755 index 0000000..fb70699 --- /dev/null +++ b/examples/09_queue_hook_dogfood/scripts/live-queue-rewrite-test.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Live dogfood: active run + queue prompt/summary rewrite via gateway chat.send. +set -euo pipefail + +MODE="${1:-}" +if [[ "$MODE" != "prompt" && "$MODE" != "summary" ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RPC="${OPENCOAT_RPC:-http://127.0.0.1:7878/rpc}" + +SESSION_ID="${OPENCLAW_SESSION_ID:-ad9fe8a0-c144-4553-83d2-6868821ad452}" +WAIT_ACTIVE_SEC="${WAIT_ACTIVE_SEC:-8}" +NONCE="${DOGFOOD_NONCE:-$(date +%s)}" +MSG1="${MSG1:-DOGFOOD_RUN_${NONCE}: Read /Users/moss/OpenCOAT/docs/design/opencoat-openclaw-joinpoint-model-v0.1.md in five chunks (lines 1-150, 151-300, 301-450, 451-600, 601-end) using read tools only. After EACH chunk output exactly 5 bullets tagged CHUNK-N-${NONCE}. Do not finish until all five chunks are done.}" + +if [[ "$MODE" == "prompt" ]]; then + CONCERN_FILE="$SCRIPT_DIR/../concerns/oc.dogfood.queue-prompt-rewrite.json" + CONCERN_ID="oc.dogfood.queue-prompt-rewrite" + MSG2="${MSG2:-QUEUE_DOGFOOD_REWRITE_PROMPT — tighten scope: keep the answer under 200 words.}" +else + CONCERN_FILE="$SCRIPT_DIR/../concerns/oc.dogfood.queue-summary-rewrite.json" + CONCERN_ID="oc.dogfood.queue-summary-rewrite" + MSG2="${MSG2:-QUEUE_DOGFOOD_REWRITE_SUMMARY — minor tweak only.}" +fi + +default_gateway_log() { + local today + today="$(date +%F)" + if [[ -f "/tmp/openclaw/openclaw-${today}.log" ]]; then + echo "/tmp/openclaw/openclaw-${today}.log" + elif [[ -f "${HOME}/.openclaw/logs/gateway.log" ]]; then + echo "${HOME}/.openclaw/logs/gateway.log" + else + echo "/tmp/openclaw/openclaw-${today}.log" + fi +} + +LOG="${OPENCLAW_GATEWAY_LOG:-$(default_gateway_log)}" +TEST_START_ISO="$(date -u +%Y-%m-%dT%H:%M:%S)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +resolve_session_key() { + local session_id="$1" + openclaw sessions list --json 2>/dev/null | python3 -c ' +import json +import sys + +target = sys.argv[1] +raw = sys.stdin.read().strip() +if not raw: + sys.exit(1) +data = json.loads(raw) +rows = data if isinstance(data, list) else data.get("sessions", []) +for row in rows: + if not isinstance(row, dict): + continue + sid = row.get("sessionId") or row.get("id") + if sid == target: + key = row.get("key") or row.get("sessionKey") + if key: + print(key) + sys.exit(0) +sys.exit(1) +' "$session_id" +} + +command -v openclaw >/dev/null || fail "missing openclaw" +command -v curl >/dev/null || fail "missing curl" +command -v python3 >/dev/null || fail "missing python3" +command -v opencoat >/dev/null || fail "missing opencoat" +[[ -f "$LOG" ]] || fail "gateway log not found: $LOG" + +SESSION_KEY="${OPENCLAW_SESSION_KEY:-}" +if [[ -z "$SESSION_KEY" ]]; then + SESSION_KEY="$(resolve_session_key "$SESSION_ID")" || fail "no sessionKey for sessionId=$SESSION_ID" +fi + +log_start="$(wc -l < "$LOG" | tr -d ' ')" + +echo "== live queue ${MODE} rewrite dogfood ==" +echo "concern_id=$CONCERN_ID" +echo "session_key=$SESSION_KEY" +echo "gateway_log=$LOG (baseline lines=$log_start)" + +echo "== import ${CONCERN_ID} ==" +opencoat concern import "$CONCERN_FILE" + +PARAMS_DIR="${TMPDIR:-/tmp}/opencoat-queue-dogfood-$$" +mkdir -p "$PARAMS_DIR" +trap 'rm -rf "$PARAMS_DIR"' EXIT + +python3 - "$PARAMS_DIR" "$SESSION_KEY" "$SESSION_ID" "$MSG1" "$MSG2" "$NONCE" "$MODE" <<'PY' +import json +import sys +from pathlib import Path + +out_dir = Path(sys.argv[1]) +session_key, session_id = sys.argv[2], sys.argv[3] +msg1, msg2, nonce, mode = sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7] + +def write(name, payload): + (out_dir / name).write_text(json.dumps(payload), encoding="utf-8") + +write( + "msg1.json", + { + "sessionKey": session_key, + "sessionId": session_id, + "message": msg1, + "deliver": False, + "idempotencyKey": f"dogfood-run-{nonce}", + }, +) +write( + "msg2.json", + { + "sessionKey": session_key, + "sessionId": session_id, + "message": msg2, + "deliver": False, + "idempotencyKey": f"dogfood-{mode}-{nonce}", + }, +) +PY + +openclaw gateway call chat.send --json --timeout 60000 \ + --params "$(cat "$PARAMS_DIR/msg1.json")" | tee "$PARAMS_DIR/msg1-response.json" + +echo "== wait ${WAIT_ACTIVE_SEC}s then rewrite trigger ==" +sleep "$WAIT_ACTIVE_SEC" + +openclaw gateway call chat.send --json --timeout 60000 \ + --params "$(cat "$PARAMS_DIR/msg2.json")" | tee "$PARAMS_DIR/msg2-response.json" + +sleep 2 + +log_slice="$(mktemp)" +tail -n +"$((log_start + 1))" "$LOG" >"$log_slice" || true +hook_ok=false +if grep -E "queue_before_enqueue→queue\\.before_enqueue: ${CONCERN_ID}|queue_before_enqueue.*${CONCERN_ID}" "$log_slice" | tail -5; then + hook_ok=true +else + echo "(no queue_before_enqueue line for ${CONCERN_ID})" + grep -iE 'queue_before_enqueue' "$log_slice" | tail -10 || true +fi +rm -f "$log_slice" + +dcn_json="$(curl -sS "$RPC" -H 'Content-Type: application/json' \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"dcn.activation_log\",\"params\":{\"concern_id\":\"${CONCERN_ID}\",\"limit\":8},\"id\":1}")" + +dcn_ok=false +if printf '%s' "$dcn_json" | python3 -c ' +import json, sys +from datetime import datetime, timezone +cutoff = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")) +if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=timezone.utc) +cutoff = cutoff.timestamp() - 30 +for row in json.load(sys.stdin).get("result") or []: + try: + dt = datetime.fromisoformat(row["ts"].replace("Z", "+00:00")) + if dt.timestamp() >= cutoff: + sys.exit(0) + except (KeyError, ValueError): + pass +sys.exit(1) +' "$TEST_START_ISO"; then + dcn_ok=true +fi + +[[ "$hook_ok" == true ]] || fail "gateway log missing queue_before_enqueue→${CONCERN_ID}" +echo "PASS: gateway saw queue_before_enqueue for ${CONCERN_ID}" +[[ "$dcn_ok" == true ]] && echo "PASS: DCN activation during run" || echo "WARN: no fresh DCN row (check daemon)" diff --git a/examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh b/examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh new file mode 100755 index 0000000..4e1ccdc --- /dev/null +++ b/examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Smoke-test queue.before_enqueue via daemon RPC (no OpenClaw gateway required). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONCERNS_DIR="$SCRIPT_DIR/../concerns" +RPC="${OPENCOAT_RPC:-http://127.0.0.1:7878/rpc}" + +usage() { + cat <<'EOF' +Usage: smoke-rpc.sh + +Imports the matching dogfood concern (if daemon is up), submits a synthetic +queue.before_enqueue joinpoint, and prints the ConcernInjection JSON. + +Examples: + ./examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh block + ./examples/09_queue_hook_dogfood/scripts/smoke-rpc.sh all +EOF +} + +mode="${1:-}" +if [[ -z "$mode" ]]; then + usage + exit 2 +fi + +rpc() { + curl -sS "$RPC" -H 'Content-Type: application/json' -d "$1" +} + +import_concern() { + local file="$1" + if ! command -v opencoat >/dev/null 2>&1; then + echo "skip import ($file): opencoat CLI not on PATH" >&2 + return 0 + fi + opencoat concern import "$file" || true +} + +submit_queue() { + local prompt="$1" + local summary_line="$2" + local id="$3" + # Keyword matcher reads text/raw_text (see pointcut/_text.py); mirror bridge queuePayload JSON. + local prompt_json + prompt_json=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$prompt") + local summary_json + summary_json=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$summary_line") + rpc "$(cat < { - it("registers all async-safe plugin hooks (29 total minus 3 skipped)", () => { - assert.equal(HOOK_BINDINGS.length, 26); + it("registers async-safe plugin hooks plus native queue hooks", () => { + assert.equal(HOOK_BINDINGS.length, 29); assert.equal(SKIPPED_HOOKS.length, 3); }); @@ -19,6 +19,8 @@ describe("hook-bindings", () => { "before_prompt_build", "before_tool_call", "after_tool_call", + "queue_before_enqueue", + "queue_after_enqueue", "subagent_spawning", "before_compaction", ]) { diff --git a/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts b/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts index 5a0bf99..56d3e07 100644 --- a/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts +++ b/integrations/openclaw-opencoat-bridge/src/hook-bindings.ts @@ -1,8 +1,8 @@ /** * OpenClaw plugin hook → OpenCOAT joinpoint bindings (ADR-0011). * - * Queue / reply_run / non-subagent task moments use runtime-observers.ts - * (onAgentEvent + poll), not entries here. + * Legacy OpenClaw queue observations still use runtime-observers.ts + * (queue-depth poll). Native queue hooks are registered here when present. * * Skipped (sync hot path — cannot await daemon RPC): * before_message_write, tool_result_persist @@ -16,6 +16,7 @@ export type HookKind = | "tool_guard" | "message_out" | "subagent_spawn" + | "queue_guard" | "buffer_input"; export type HookBinding = { @@ -24,7 +25,7 @@ export type HookBinding = { kind: HookKind; }; -/** All hooks the bridge registers (30 OpenClaw plugin hooks − 3 skipped). */ +/** All hooks the bridge registers. */ export const HOOK_BINDINGS: HookBinding[] = [ // --- already wired (kept for documentation order) --- { hook: "session_start", joinpoint: "runtime_start", kind: "observe" }, @@ -47,6 +48,7 @@ export const HOOK_BINDINGS: HookBinding[] = [ { hook: "before_reset", joinpoint: "runtime_recovery", kind: "observe" }, // --- messages / dispatch --- + { hook: "before_agent_run", joinpoint: "input.received", kind: "observe" }, { hook: "inbound_claim", joinpoint: "on_user_input", kind: "observe" }, { hook: "before_dispatch", joinpoint: "on_user_input", kind: "observe" }, { hook: "reply_dispatch", joinpoint: "before_response", kind: "observe" }, @@ -56,6 +58,10 @@ export const HOOK_BINDINGS: HookBinding[] = [ // --- tools --- { hook: "after_tool_call", joinpoint: "after_tool_call", kind: "observe" }, + // --- queue --- + { hook: "queue_before_enqueue", joinpoint: "queue.before_enqueue", kind: "queue_guard" }, + { hook: "queue_after_enqueue", joinpoint: "queue.after_enqueue", kind: "observe" }, + // --- memory / compaction --- { hook: "before_compaction", joinpoint: "before_memory_write", kind: "observe" }, { hook: "after_compaction", joinpoint: "after_memory_write", kind: "observe" }, diff --git a/integrations/openclaw-opencoat-bridge/src/index.ts b/integrations/openclaw-opencoat-bridge/src/index.ts index 044769d..41f99aa 100644 --- a/integrations/openclaw-opencoat-bridge/src/index.ts +++ b/integrations/openclaw-opencoat-bridge/src/index.ts @@ -21,6 +21,7 @@ import { guardToolCall, mergeInjections, messageSendingDecision, + queueBeforeEnqueueDecision, subagentSpawnDecision, } from "./injector.js"; import { promptPayload } from "./messages.js"; @@ -28,6 +29,7 @@ import { compactionPayload, llmPayload, passThroughPayload, + queuePayload, subagentPayload, toolCallPayload, toolResultPayload, @@ -35,6 +37,7 @@ import { import { createObserveEmitter } from "./emit-joinpoint.js"; import { installRuntimeObservers, + recordQueueDepthSnapshot, trackSessionKey, } from "./runtime-observers.js"; import type { @@ -133,6 +136,10 @@ function buildPayloadForHook( error: typeof e.error === "string" ? e.error : undefined, durationMs: typeof e.durationMs === "number" ? e.durationMs : undefined, }); + case "queue_before_enqueue": + return queuePayload(e, "before_enqueue"); + case "queue_after_enqueue": + return queuePayload(e, "after_enqueue"); case "message_sending": { const content = typeof e.content === "string" ? e.content : ""; return promptPayload({ @@ -237,8 +244,19 @@ async function handleHook( return { status: "ok" }; } + case "queue_guard": { + const inj = await emit(cfg, api, binding.hook, binding.joinpoint, payload, c); + return queueBeforeEnqueueDecision(inj); + } + case "observe": default: { + if (binding.hook === "queue_after_enqueue") { + const ev = asRecord(event); + const sessionKey = + typeof ev.sessionKey === "string" ? ev.sessionKey : c.sessionKey; + recordQueueDepthSnapshot(sessionKey, ev.depthAfter); + } const level = binding.hook === "gateway_start" || binding.hook === "gateway_stop" ? 0 diff --git a/integrations/openclaw-opencoat-bridge/src/injector.test.ts b/integrations/openclaw-opencoat-bridge/src/injector.test.ts new file mode 100644 index 0000000..f0938e1 --- /dev/null +++ b/integrations/openclaw-opencoat-bridge/src/injector.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { queueBeforeEnqueueDecision } from "./injector.js"; +import type { ConcernInjection } from "./types.js"; + +function injection(rows: ConcernInjection["injections"]): ConcernInjection { + return { + weave_id: "weave-1", + agent_session_id: "session-1", + injections: rows, + }; +} + +describe("queueBeforeEnqueueDecision", () => { + it("blocks queue enqueue from blocking advice", () => { + const decision = queueBeforeEnqueueDecision( + injection([ + { + concern_id: "queue-policy", + advice_type: "memory_write_guard", + target: "queue.prompt", + mode: "block", + content: "Queue is full for this policy.", + }, + ]), + ); + + assert.deepEqual(decision, { + block: true, + blockReason: "Queue is full for this policy.", + }); + }); + + it("rewrites prompt and summary line from queue targets", () => { + const decision = queueBeforeEnqueueDecision( + injection([ + { + concern_id: "rewrite", + advice_type: "rewrite_guidance", + target: "queue.prompt", + mode: "rewrite", + content: "Summarized follow-up prompt", + }, + { + concern_id: "summary", + advice_type: "rewrite_guidance", + target: "queue.summary_line", + mode: "rewrite", + content: "Short summary", + }, + ]), + ); + + assert.deepEqual(decision, { + prompt: "Summarized follow-up prompt", + summaryLine: "Short summary", + }); + }); + + it("ignores non-queue append advice", () => { + const decision = queueBeforeEnqueueDecision( + injection([ + { + concern_id: "prompt-note", + advice_type: "response_requirement", + target: "runtime_prompt.output_format", + mode: "insert", + content: "Answer in JSON.", + }, + ]), + ); + + assert.deepEqual(decision, {}); + }); +}); diff --git a/integrations/openclaw-opencoat-bridge/src/injector.ts b/integrations/openclaw-opencoat-bridge/src/injector.ts index f851712..3e5a369 100644 --- a/integrations/openclaw-opencoat-bridge/src/injector.ts +++ b/integrations/openclaw-opencoat-bridge/src/injector.ts @@ -11,6 +11,10 @@ function isToolTarget(target: string): boolean { return target === "tool_call" || target.startsWith("tool_call."); } +function isQueueTarget(target: string): boolean { + return target === "queue" || target.startsWith("queue."); +} + /** Fold prompt-level INSERT (etc.) rows into OpenClaw prependSystemContext. */ export function foldPromptInjection(injection: ConcernInjection | null): string { if (!injection?.injections?.length) return ""; @@ -123,6 +127,48 @@ export function subagentSpawnDecision( return { status: "error", error: reason }; } +export type QueueBeforeEnqueueDecision = { + block?: boolean; + blockReason?: string; + prompt?: string; + summaryLine?: string; +}; + +/** Interpret queue-scoped advice for OpenClaw queue_before_enqueue. */ +export function queueBeforeEnqueueDecision( + injection: ConcernInjection | null, +): QueueBeforeEnqueueDecision { + if (!injection?.injections?.length) return {}; + + const reasons: string[] = []; + const decision: QueueBeforeEnqueueDecision = {}; + + for (const row of injection.injections) { + if (!isQueueTarget(row.target) && !BLOCK_MODES.has(row.mode)) continue; + + if (BLOCK_MODES.has(row.mode)) { + decision.block = true; + if (row.content.trim()) reasons.push(row.content.trim()); + continue; + } + + if (!row.content.trim()) continue; + if (row.target === "queue.prompt" && row.mode === "rewrite") { + decision.prompt = row.content.trim(); + } else if ( + (row.target === "queue.summary_line" || row.target === "queue.summaryLine") && + row.mode === "rewrite" + ) { + decision.summaryLine = row.content.trim(); + } + } + + if (reasons.length) { + decision.blockReason = reasons.join("\n"); + } + return decision; +} + export function mergeInjections( ...injections: Array ): ConcernInjection | null { diff --git a/integrations/openclaw-opencoat-bridge/src/payloads.ts b/integrations/openclaw-opencoat-bridge/src/payloads.ts index 3deb141..ec1db07 100644 --- a/integrations/openclaw-opencoat-bridge/src/payloads.ts +++ b/integrations/openclaw-opencoat-bridge/src/payloads.ts @@ -59,6 +59,34 @@ export function toolResultPayload(event: { }; } +export function queuePayload( + event: Record, + stage: "before_enqueue" | "after_enqueue", +): Record { + const text = safeJson(event); + return { + text, + raw_text: text, + content: text, + stage, + queue_key: event.queueKey, + queue_mode: event.queueMode, + drop_policy: event.dropPolicy, + depth_before: event.depthBefore, + depth_after: event.depthAfter, + enqueued: event.enqueued, + prompt: event.prompt, + summary_line: event.summaryLine, + message_id: event.messageId, + originating_channel: event.originatingChannel, + originating_to: event.originatingTo, + originating_account_id: event.originatingAccountId, + originating_thread_id: event.originatingThreadId, + session_id: event.sessionId, + session_key: event.sessionKey, + }; +} + export function messageContentPayload( content: string, extra?: Record, diff --git a/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts b/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts index 8592623..11504a5 100644 --- a/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts +++ b/integrations/openclaw-opencoat-bridge/src/runtime-observers.test.ts @@ -5,7 +5,10 @@ import { agentEventRunningJoinpoint, diffQueueDepth, diffTaskSnapshots, + installRuntimeObservers, + recordQueueDepthSnapshot, } from "./runtime-observers.js"; +import type { BridgeConfig, BridgePluginApi } from "./types.js"; describe("agentEventJoinpoint", () => { it("maps lifecycle start to reply_run.before_begin", () => { @@ -25,6 +28,33 @@ describe("agentEventJoinpoint", () => { }); assert.equal(mapped?.name, "planning.plan_updated"); }); + + it("maps lifecycle error to error.detected", () => { + const mapped = agentEventJoinpoint({ + runId: "r1", + stream: "lifecycle", + data: { phase: "error", error: "boom" }, + }); + assert.equal(mapped?.name, "error.detected"); + }); + + it("maps command_output stream to command.output_stream", () => { + const mapped = agentEventJoinpoint({ + runId: "r1", + stream: "command_output", + data: { phase: "delta", output: "line 1" }, + }); + assert.equal(mapped?.name, "command.output_stream"); + }); + + it("maps patch stream to patch.summary_created", () => { + const mapped = agentEventJoinpoint({ + runId: "r1", + stream: "patch", + data: { phase: "end", summary: "2 files changed" }, + }); + assert.equal(mapped?.name, "patch.summary_created"); + }); }); describe("agentEventRunningJoinpoint", () => { @@ -58,6 +88,13 @@ describe("diffQueueDepth", () => { ["queue.before_collect"], ); }); + + it("uses native queue hook snapshots as the next poll baseline", () => { + const key = `sess-${Math.random()}`; + recordQueueDepthSnapshot(key, 2); + const events = diffQueueDepth(key, 2); + assert.deepEqual(events, []); + }); }); describe("diffTaskSnapshots", () => { @@ -87,3 +124,35 @@ describe("diffTaskSnapshots", () => { assert.deepEqual(terminal.map((e) => e.name), ["task.before_terminal"]); }); }); + +describe("installRuntimeObservers", () => { + it("names the internal compaction hook registration for OpenClaw", () => { + let registered: + | { events: string | string[]; opts?: { name?: string; description?: string } } + | undefined; + const api: BridgePluginApi = { + on: () => {}, + registerHook: (events, _handler, opts) => { + registered = { events, opts }; + }, + }; + const cfg: BridgeConfig = { + daemonUrl: "http://127.0.0.1:7878/rpc", + enabled: false, + logActivations: false, + extractOnUserMessage: false, + runtimeObservers: true, + observerPollMs: 500, + }; + + installRuntimeObservers(api, cfg, { + observe: async () => null, + }); + + assert.deepEqual(registered?.events, [ + "session:compact:before", + "session:compact:after", + ]); + assert.equal(registered?.opts?.name, "opencoat-bridge-session-compact"); + }); +}); diff --git a/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts b/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts index 0270ca6..c79f0a9 100644 --- a/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts +++ b/integrations/openclaw-opencoat-bridge/src/runtime-observers.ts @@ -33,6 +33,17 @@ export function trackSessionKey(sessionKey: string | undefined): void { if (sessionKey?.trim()) trackedSessionKeys.add(sessionKey.trim()); } +export function recordQueueDepthSnapshot( + sessionKey: string | undefined, + depth: unknown, +): void { + if (!sessionKey?.trim() || typeof depth !== "number" || !Number.isFinite(depth)) { + return; + } + trackedSessionKeys.add(sessionKey.trim()); + queueDepthByKey.set(sessionKey.trim(), Math.max(0, Math.floor(depth))); +} + export function agentEventJoinpoint( evt: AgentEventPayload, ): { name: string; payload: Record } | null { @@ -46,8 +57,24 @@ export function agentEventJoinpoint( payload: { phase, run_id: evt.runId, ...data }, }; } + if (phase === "error") { + return { + name: "error.detected", + payload: { phase, run_id: evt.runId, ...data }, + }; + } return null; } + case "command_output": + return { + name: "command.output_stream", + payload: { run_id: evt.runId, stream: evt.stream, ...data }, + }; + case "patch": + return { + name: "patch.summary_created", + payload: { run_id: evt.runId, stream: evt.stream, ...data }, + }; case "plan": return { name: "planning.plan_updated", @@ -297,6 +324,10 @@ export function installRuntimeObservers( api.registerHook( ["session:compact:before", "session:compact:after"], compactHandler, + { + name: "opencoat-bridge-session-compact", + description: "Mirror OpenClaw session compaction events into OpenCOAT memory joinpoints.", + }, ); } diff --git a/integrations/openclaw-opencoat-bridge/src/types.ts b/integrations/openclaw-opencoat-bridge/src/types.ts index 1808345..a387ae6 100644 --- a/integrations/openclaw-opencoat-bridge/src/types.ts +++ b/integrations/openclaw-opencoat-bridge/src/types.ts @@ -57,7 +57,7 @@ export type BridgePluginApi = { sessionKey?: string; context?: Record; }) => void | Promise, - opts?: { priority?: number }, + opts?: { name?: string; description?: string; priority?: number }, ) => void; }; diff --git a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py index ec805fe..20a8369 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/aliases.py @@ -21,6 +21,8 @@ "planning.before_start": "before_planning", "planning.after_start": "after_planning", "planning.plan_updated": "after_planning", + "command.output_stream": "command.output_stream", + "patch.summary_created": "patch.summary_created", # Tool "tool.before_call": "before_tool_call", "tool.after_execute": "after_tool_call", @@ -54,6 +56,7 @@ { "input.received", "queue.before_enqueue", + "queue.after_enqueue", "queue.before_collect", "reply_run.before_begin", "reply_run.phase.running", diff --git a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py index 138e961..9e64e7b 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/joinpoint/catalog.py @@ -85,7 +85,12 @@ class CatalogEntry: CatalogEntry( "queue.before_enqueue", JoinpointLevel.LIFECYCLE, - "Bridge: queue depth poll (observe; not sync at enqueueFollowupRun)", + "Bridge: native queue_before_enqueue hook; queue poll fallback on older OpenClaw", + ), + CatalogEntry( + "queue.after_enqueue", + JoinpointLevel.LIFECYCLE, + "Bridge: native queue_after_enqueue hook (observe after queue decision)", ), CatalogEntry( "queue.before_collect", @@ -127,6 +132,16 @@ class CatalogEntry: JoinpointLevel.LIFECYCLE, "Concern verifier fail path (not emitted by bridge yet)", ), + CatalogEntry( + "command.output_stream", + JoinpointLevel.LIFECYCLE, + "Bridge: onAgentEvent command_output stream", + ), + CatalogEntry( + "patch.summary_created", + JoinpointLevel.LIFECYCLE, + "Bridge: onAgentEvent patch stream (summary)", + ), ) # v0.1 dotted aliases registered for inspect / pointcut validation (matcher resolves) diff --git a/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py b/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py index e38988c..0425f17 100644 --- a/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py +++ b/packages/opencoat-runtime/tests/core/test_joinpoint_aliases.py @@ -50,6 +50,16 @@ def test_matcher_accepts_v01_pointcut_against_legacy_event_name() -> None: assert result.matched -def test_queue_joinpoint_in_catalog_not_emitted_by_bridge_yet() -> None: +def test_queue_joinpoints_in_catalog() -> None: assert "queue.before_enqueue" in JOINPOINT_CATALOG + assert "queue.after_enqueue" in JOINPOINT_CATALOG assert canonical_joinpoint_name("queue.before_enqueue") == "queue.before_enqueue" + + +def test_observe_wave_joinpoints_in_catalog() -> None: + for name in ( + "command.output_stream", + "patch.summary_created", + "error.detected", + ): + assert name in JOINPOINT_CATALOG, name diff --git a/packages/opencoat-runtime/tests/core/test_queue_joinpoint_weave.py b/packages/opencoat-runtime/tests/core/test_queue_joinpoint_weave.py new file mode 100644 index 0000000..2b6c1ad --- /dev/null +++ b/packages/opencoat-runtime/tests/core/test_queue_joinpoint_weave.py @@ -0,0 +1,158 @@ +"""Queue joinpoint weaving — block and rewrite advice targets.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from opencoat_runtime_core.advice import AdviceGenerator +from opencoat_runtime_core.config import RuntimeConfig +from opencoat_runtime_core.coordinator import ConcernCoordinator +from opencoat_runtime_core.llm import StubLLMClient +from opencoat_runtime_core.loops import JoinpointPipeline +from opencoat_runtime_core.pointcut.matcher import PointcutMatcher +from opencoat_runtime_core.weaving import ConcernWeaver +from opencoat_runtime_protocol import ( + AdviceKind, + AdviceType, + AopAdvice, + Concern, + JoinpointEvent, + PointcutDef, + WeavingLevel, + WeavingOperation, + WeavingPolicy, +) +from opencoat_runtime_protocol.envelopes import PointcutMatch +from opencoat_runtime_storage.memory import MemoryConcernStore, MemoryDCNStore + + +def _queue_concern( + concern_id: str, + *, + mode: WeavingOperation, + target: str, + keyword: str, + content: str, +) -> Concern: + return Concern( + id=concern_id, + name=f"queue test {concern_id}", + pointcuts=[ + PointcutDef( + id="pc-queue", + joinpoints=["queue.before_enqueue"], + match=PointcutMatch(any_keywords=[keyword]), + ) + ], + advices=[ + AopAdvice( + id="adv-queue", + kind=AdviceKind.BEFORE, + pointcut_ref="pc-queue", + content=content, + template=AdviceType.MEMORY_WRITE_GUARD + if mode == WeavingOperation.BLOCK + else AdviceType.REWRITE_GUIDANCE, + effect=WeavingPolicy( + mode=mode, + level=WeavingLevel.OUTPUT_LEVEL, + target=target, + priority=0.9, + ), + ) + ], + ) + + +def _make_loop() -> tuple[JoinpointPipeline, MemoryConcernStore]: + cfg = RuntimeConfig() + store = MemoryConcernStore() + loop = JoinpointPipeline( + config=cfg, + concern_store=store, + dcn_store=MemoryDCNStore(), + matcher=PointcutMatcher(), + coordinator=ConcernCoordinator(budgets=cfg.budgets), + weaver=ConcernWeaver(budgets=cfg.budgets), + advice_plugin=AdviceGenerator(llm=StubLLMClient()), + ) + return loop, store + + +def _queue_joinpoint(prompt: str) -> JoinpointEvent: + return JoinpointEvent( + id="jp-queue-test", + level=1, + name="queue.before_enqueue", + host="openclaw", + agent_session_id="sess-queue", + host_round_id="run-queue", + ts=datetime.now(UTC), + payload={ + "stage": "before_enqueue", + "prompt": prompt, + "summary_line": "user follow-up", + "text": prompt, + "raw_text": prompt, + }, + ) + + +def test_queue_block_advice_woven() -> None: + loop, store = _make_loop() + store.upsert( + _queue_concern( + "oc.test.queue-block", + mode=WeavingOperation.BLOCK, + target="queue.prompt", + keyword="QUEUE_DOGFOOD_BLOCK", + content="blocked by test", + ), + ) + + injection = loop.run(_queue_joinpoint("QUEUE_DOGFOOD_BLOCK enqueue me")) + assert injection is not None + assert any(row.mode == "block" and row.target == "queue.prompt" for row in injection.injections) + + +def test_queue_prompt_rewrite_woven() -> None: + loop, store = _make_loop() + store.upsert( + _queue_concern( + "oc.test.queue-prompt", + mode=WeavingOperation.REWRITE, + target="queue.prompt", + keyword="QUEUE_DOGFOOD_REWRITE_PROMPT", + content="rewritten prompt", + ), + ) + + injection = loop.run( + _queue_joinpoint("QUEUE_DOGFOOD_REWRITE_PROMPT please queue"), + ) + assert injection is not None + assert any( + row.mode == "rewrite" and row.target == "queue.prompt" and "rewritten prompt" in row.content + for row in injection.injections + ) + + +def test_queue_summary_rewrite_woven() -> None: + loop, store = _make_loop() + store.upsert( + _queue_concern( + "oc.test.queue-summary", + mode=WeavingOperation.REWRITE, + target="queue.summary_line", + keyword="QUEUE_DOGFOOD_REWRITE_SUMMARY", + content="rewritten summary", + ), + ) + + injection = loop.run( + _queue_joinpoint("QUEUE_DOGFOOD_REWRITE_SUMMARY tweak"), + ) + assert injection is not None + assert any( + row.mode == "rewrite" and row.target == "queue.summary_line" for row in injection.injections + ) diff --git a/scripts/check-openclaw-fork.sh b/scripts/check-openclaw-fork.sh new file mode 100755 index 0000000..f97fb5a --- /dev/null +++ b/scripts/check-openclaw-fork.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Verify global `openclaw` + gateway service are 1:1 with ~/openclaw-fork. +set -euo pipefail + +FORK_ROOT="${OPENCLAW_FORK_ROOT:-$HOME/openclaw-fork}" +LOCK="${HOME}/.openclaw/openclaw-fork.json" +FAIL=0 + +say_ok() { printf ' ok %s\n' "$1"; } +say_bad() { printf ' FAIL %s\n' "$1"; FAIL=1; } + +echo "OpenClaw fork alignment (expected root: ${FORK_ROOT})" + +if [[ ! -d "$FORK_ROOT/.git" ]]; then + say_bad "fork repo missing: $FORK_ROOT (.git)" +else + commit="$(git -C "$FORK_ROOT" rev-parse --short HEAD 2>/dev/null || echo '?')" + branch="$(git -C "$FORK_ROOT" branch --show-current 2>/dev/null || echo '?')" + say_ok "fork git ${branch} @ ${commit}" +fi + +if [[ ! -f "$FORK_ROOT/openclaw.mjs" || ! -f "$FORK_ROOT/dist/index.js" ]]; then + say_bad "fork not built — run: cd $FORK_ROOT && pnpm install && pnpm build" +else + fork_ver="$(node "$FORK_ROOT/openclaw.mjs" --version 2>/dev/null || true)" + say_ok "fork CLI: ${fork_ver:-unknown}" +fi + +if ! command -v openclaw >/dev/null; then + say_bad "openclaw not on PATH" +else + cli_path="$(command -v openclaw)" + cli_ver="$(openclaw --version 2>/dev/null || true)" + say_ok "PATH openclaw: ${cli_ver:-?} (${cli_path})" +fi + +if command -v npm >/dev/null; then + npm_pkg="$(npm root -g 2>/dev/null)/openclaw" + if [[ -e "$npm_pkg" ]]; then + if [[ -L "$npm_pkg" ]]; then + resolved="$(cd "$npm_pkg" 2>/dev/null && pwd -P || true)" + if [[ "$resolved" == "$FORK_ROOT" ]]; then + say_ok "npm -g openclaw resolves to fork (${resolved})" + else + say_bad "npm -g openclaw resolves to ${resolved:-?}, want ${FORK_ROOT}" + fi + else + say_bad "npm -g openclaw is a copy, not symlink — run: ./scripts/use-openclaw-fork.sh" + fi + else + say_bad "npm -g openclaw not installed — run: ./scripts/use-openclaw-fork.sh" + fi +fi + +if command -v openclaw >/dev/null; then + gw_cmd="$(openclaw gateway status 2>/dev/null | awk -F': ' '/^Command:/{print $2; exit}' || true)" + if [[ "$gw_cmd" == *"$FORK_ROOT/dist/index.js"* ]]; then + say_ok "gateway service uses fork dist" + elif [[ -n "$gw_cmd" ]]; then + say_bad "gateway not on fork: ${gw_cmd}" + else + say_bad "gateway status unavailable — is LaunchAgent running?" + fi + cli_gw="$(openclaw gateway status 2>/dev/null | awk -F': ' '/^CLI version:|^Gateway version:/{print $2}' | tr '\n' ' ' || true)" + if [[ -n "$cli_gw" ]]; then + say_ok "versions: ${cli_gw}" + fi +fi + +if [[ -f "$LOCK" ]]; then + say_ok "lock file: $LOCK" +else + say_bad "missing lock file — run: ./scripts/use-openclaw-fork.sh" +fi + +if [[ "$FAIL" -eq 0 ]]; then + echo "All checks passed — global openclaw is 1:1 with fork." +else + echo "Fix with: ./scripts/use-openclaw-fork.sh [--update] [--build]" >&2 + exit 1 +fi diff --git a/scripts/use-openclaw-fork.sh b/scripts/use-openclaw-fork.sh new file mode 100755 index 0000000..e49a770 --- /dev/null +++ b/scripts/use-openclaw-fork.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Default OpenClaw = HyperdustLabs fork (opencoat/hooks-v0.1) at ~/openclaw-fork. +# Global npm install and gateway LaunchAgent must be 1:1 with that tree. +# +# Usage: +# ./scripts/use-openclaw-fork.sh # bind CLI + gateway to existing fork +# ./scripts/use-openclaw-fork.sh --clone # git clone fork first +# ./scripts/use-openclaw-fork.sh --update # git pull + pnpm install + rebuild +# ./scripts/use-openclaw-fork.sh --build # pnpm build only (no git pull) +# ./scripts/use-openclaw-fork.sh --check # verify 1:1 (same as check-openclaw-fork.sh) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FORK_ROOT="${OPENCLAW_FORK_ROOT:-$HOME/openclaw-fork}" +FORK_REPO="${OPENCLAW_FORK_REPO:-https://github.com/HyperdustLabs/openclaw.git}" +FORK_BRANCH="${OPENCLAW_FORK_BRANCH:-opencoat/hooks-v0.1}" +LOCAL_BIN="${HOME}/.local/bin" +SHIM="${LOCAL_BIN}/openclaw-fork" +LOCK="${HOME}/.openclaw/openclaw-fork.json" + +clone=false +update=false +build=false +check=false +for arg in "$@"; do + case "$arg" in + --clone) clone=true ;; + --update) update=true; build=true ;; + --build) build=true ;; + --check) check=true ;; + -h|--help) + sed -n '2,12p' "$0" + exit 0 + ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +if [[ "$check" == true ]]; then + exec "${ROOT}/scripts/check-openclaw-fork.sh" +fi + +if [[ "$clone" == true && ! -d "$FORK_ROOT/.git" ]]; then + git clone --branch "$FORK_BRANCH" "$FORK_REPO" "$FORK_ROOT" +fi + +if [[ ! -d "$FORK_ROOT" ]]; then + echo "missing $FORK_ROOT — run with --clone or set OPENCLAW_FORK_ROOT" >&2 + exit 1 +fi + +if [[ "$update" == true ]]; then + git -C "$FORK_ROOT" fetch origin "$FORK_BRANCH" + git -C "$FORK_ROOT" checkout "$FORK_BRANCH" + git -C "$FORK_ROOT" pull --ff-only origin "$FORK_BRANCH" || true +fi + +if [[ ! -d "$FORK_ROOT/node_modules" ]] || [[ "$update" == true ]]; then + (cd "$FORK_ROOT" && (command -v pnpm >/dev/null && pnpm install || npm install)) +fi + +if [[ "$build" == true ]] || [[ ! -f "$FORK_ROOT/dist/index.js" ]]; then + echo "building openclaw-fork..." + (cd "$FORK_ROOT" && (command -v pnpm >/dev/null && pnpm build || npm run build)) +fi + +if [[ ! -f "$FORK_ROOT/dist/index.js" ]]; then + echo "build failed — dist/index.js missing under $FORK_ROOT" >&2 + exit 1 +fi + +# Remove registry/global copies that are not the fork (avoid version drift). +if command -v npm >/dev/null; then + npm_global="$(npm root -g 2>/dev/null)/openclaw" + if [[ -e "$npm_global" && ! -L "$npm_global" ]]; then + echo "removing non-symlink npm global openclaw (registry copy)..." + npm uninstall -g openclaw 2>/dev/null || true + fi + echo "linking npm -g openclaw -> $FORK_ROOT" + (cd "$FORK_ROOT" && npm install -g .) +fi + +mkdir -p "$LOCAL_BIN" "${HOME}/.openclaw" +cat >"$SHIM" </dev/null; then + rm -f "${LOCAL_BIN}/openclaw" +fi +ln -sf "$SHIM" "${LOCAL_BIN}/openclaw" + +commit="$(git -C "$FORK_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)" +commit_short="$(git -C "$FORK_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" +version="$(node "$FORK_ROOT/openclaw.mjs" --version 2>/dev/null || echo unknown)" +installed_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +python3 - "$LOCK" </dev/null; then + openclaw config validate + openclaw gateway install --force + openclaw daemon restart || true +fi + +echo "" +echo "OpenClaw fork is now the default (1:1 with ${FORK_ROOT})" +echo " version: ${version}" +echo " commit: ${commit_short} (${FORK_BRANCH})" +echo " lock: ${LOCK}" +echo "" +"${ROOT}/scripts/check-openclaw-fork.sh"