diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b1fde2..9ccd78c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to ClawRouter. - Endpoint count 84 → 83, removed the Chat section, added the required-param pre-check note up front. - Fixed param names that the agent would otherwise feed incorrectly and trip the pre-check 400: `social/mindshare` is `q` + `interval` (not `project` + `window`), `search/*` family is `q` (not `query`), `token/holders` + `token/transfers` need `address` AND `chain`, `onchain/gas-price` needs `chain`, `onchain/tx` needs `hash` + `chain`, exchange family universally needs `pair`, market family uses `symbol`, prediction-market endpoints use their specific identifier params (`market_slug`, `event_slug`, `condition_id`, `market_ticker`, `event_ticker`, `ticker`, `address`), and `fund/ranking` + `project/defi/*` need `metric`. - Reworded the example flows that previously used wrong param names (`search/project?query=ethena` → `?q=ethena`, mindshare example fixed). + - **Phone skill — voice/call `from` is now optional with server-side auto-pick.** blockrun moved `from` from required → optional on `/v1/voice/call`. After payment verification the server picks a caller-ID from the wallet's owned numbers: 0 active → `403 no_active_number` with buy-first hint, exactly 1 → auto-used, 2+ → `400 ambiguous_from` listing all candidates. The prior skill said "Otherwise Bland picks one" — wrong, and would have made agents leave `from` off and confuse users with the 403/400 responses. Updated `skills/phone/SKILL.md` with the actual auto-pick rule table and the ownership-mismatch 403 behavior. - **No code change for `real_face_asset_id`, no new partner tools, no proxy whitelist change.** Surf + Phone are both base+skill integrations (per the v0.12.193 rule). All blockrun upstream changes here flow through the existing `proxyPaidApiRequest` path transparently. @@ -87,7 +88,7 @@ All notable changes to ClawRouter. - **Bug 1 — phantom $0.54 charge on 4xx voice POST.** First smoke test POSTed `/v1/voice/call` with empty `{}` body to exercise routing without spending money. BlockRun returned 400 (Zod validation: "expected string, received undefined"). The wallet wasn't charged, but the telemetry hook saw `paymentStore.amountUsd = 0` and fell back to `estimatePhoneCost("/v1/voice/call") = $0.54`. Stats would record a phantom voice call. Fix: gate the fallback on `upstream.status` being 2xx — any 4xx/5xx skips the fallback and logs `$0`. - **Bug 2 — GET poll miscounted as another $0.54 voice call.** After placing a real call, polling `GET /v1/voice/call/{call_id}` for transcript status (free upstream) was being logged at $0.54 because the longest-prefix match on `voice/call/` triggered the same fallback row as the initiating POST. Every 30s poll would inflate stats by $0.54. Fix: also gate the fallback on `req.method === "POST"` — GET polls log `$0`. - **Refactor**: gate logic was originally inline inside `proxyPaidApiRequest`. Pulled it out into `resolvePhoneTelemetryCost(args)` so the rules are independently testable (the call site is now four lines passing an args bag through the helper). Adds 8 vitest cases covering: paid-amount-wins, 4xx phantom guard, GET poll guard, 5xx guard, missing-method guard, non-phone-passthrough, and the original "successful POST with empty paymentStore → fallback" path. Without the helper extraction, locking these gates in tests would have required a full integration test with a mocked upstream — too heavy for telemetry-only logic. -- **Tests** — new `src/proxy.phone-routing.test.ts` (regex matching for /v1/phone/*, /v1/voice/*, /v1/voice/call/{id} poll, plus negative case for /v1/phonebook), `src/proxy.phone-pricing.test.ts` (longest-prefix matching + the 8 `resolvePhoneTelemetryCost` gate cases above), `src/parse-call-args.test.ts` (both flag forms, quoted task spans, E.164 first-token detection). Total 31 new test cases; all 569 vitest tests green; typecheck + lint clean. +- **Tests** — new `src/proxy.phone-routing.test.ts` (regex matching for /v1/phone/\*, /v1/voice/\*, /v1/voice/call/{id} poll, plus negative case for /v1/phonebook), `src/proxy.phone-pricing.test.ts` (longest-prefix matching + the 8 `resolvePhoneTelemetryCost` gate cases above), `src/parse-call-args.test.ts` (both flag forms, quoted task spans, E.164 first-token detection). Total 31 new test cases; all 569 vitest tests green; typecheck + lint clean. - **Smoke test record** (free-tier verification before the real call): list-numbers ($0.001) returned an existing wallet-owned number `+15707043521` (PA, expires 2026-06-15); lookup ($0.01) on that same number returned full Twilio carrier metadata (`type: nonFixedVoip`, `carrier_name: Twilio - SMS/MMS-SVR`); negative test `/v1/phonebook/test` correctly rejected by the partner regex (502 from chat-completion fallback rather than partner routing); CLI table formatting + expiry-warning logic verified by `clawrouter phone numbers list`. --- diff --git a/README.md b/README.md index ba01100..2df9ab0 100644 --- a/README.md +++ b/README.md @@ -307,16 +307,16 @@ Verify phone numbers and place AI-powered outbound voice calls directly from cha Calls are **fire-and-forget**: the request returns a `call_id` and `poll_url` immediately. The call itself runs in the cloud for up to 30 minutes. Poll `GET /v1/voice/call/{call_id}` (or `clawrouter share`/transcripts dashboard) to retrieve the transcript and recording when status is `completed`. -| Operation | Provider | Price | -| --------------------------------- | -------- | ---------------------------- | -| Phone lookup (carrier, line type) | Twilio | $0.01 | -| Fraud check (SIM-swap, fwd) | Twilio | $0.05 | -| Buy phone number (30-day lease) | Twilio | $5.00 | -| Renew lease (+30 days) | Twilio | $5.00 | -| List wallet's owned numbers | Twilio | $0.001 | -| Release a number | Twilio | free | -| **AI voice call (≤30 min)** | Bland.ai | **$0.54 flat per call** | -| Poll call status / transcript | Bland.ai | free | +| Operation | Provider | Price | +| --------------------------------- | -------- | ----------------------- | +| Phone lookup (carrier, line type) | Twilio | $0.01 | +| Fraud check (SIM-swap, fwd) | Twilio | $0.05 | +| Buy phone number (30-day lease) | Twilio | $5.00 | +| Renew lease (+30 days) | Twilio | $5.00 | +| List wallet's owned numbers | Twilio | $0.001 | +| Release a number | Twilio | free | +| **AI voice call (≤30 min)** | Bland.ai | **$0.54 flat per call** | +| Poll call status / transcript | Bland.ai | free | **CLI for wallet-owned numbers:** @@ -354,11 +354,11 @@ Surf is BlockRun's unified crypto data API — **84 endpoints across 13 domains* ClawRouter ships Surf as a **skill, not as typed wrappers**. The proxy whitelists `/v1/surf/*` so any call through the local proxy is paid x402 from the same wallet; the agent reads `skills/surf/SKILL.md` for the endpoint catalog and crafts the HTTP call. No `blockrun_surf_*` tool definitions to maintain; a new Surf endpoint requires zero ClawRouter release. -| Tier | Cost | Examples | -| ---- | --------: | ------------------------------------------------------------- | -| 1 | **$0.001**| prices, rankings, lists, news | -| 2 | **$0.005**| orderbooks, candles, search, wallet details, social mindshare | -| 3 | **$0.020**| on-chain SQL / query / schema, chat completions | +| Tier | Cost | Examples | +| ---- | ---------: | ------------------------------------------------------------- | +| 1 | **$0.001** | prices, rankings, lists, news | +| 2 | **$0.005** | orderbooks, candles, search, wallet details, social mindshare | +| 3 | **$0.020** | on-chain SQL / query / schema, chat completions | **Usage (HTTP):** diff --git a/scripts/update.sh b/scripts/update.sh index fa15130..87e7c76 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -324,32 +324,173 @@ for stale in "$HOME/.openclaw/extensions/clawrouter.backup."* "$HOME/.openclaw/e [ -d "$stale" ] && rm -rf "$stale" done +apply_scoped_model_trim() { + local rejected_path="$1" + if [ -z "$rejected_path" ] || [ ! -f "$rejected_path" ] || [ ! -f "$CONFIG_PATH" ]; then + return 1 + fi + + CONFIG_PATH="$CONFIG_PATH" REJECTED_CONFIG_PATH="$rejected_path" node <<'NODE' +const fs = require('fs'); + +const activePath = process.env.CONFIG_PATH; +const rejectedPath = process.env.REJECTED_CONFIG_PATH; + +function fail(message) { + console.log(` Skipped scoped config trim: ${message}`); + process.exit(1); +} + +function byteSize(value) { + return Buffer.byteLength(JSON.stringify(value ?? null, null, 2)); +} + +function objectKeys(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value).sort() : []; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function getBlockrunModels(config) { + return config?.models?.providers?.blockrun?.models; +} + +const active = JSON.parse(fs.readFileSync(activePath, 'utf8')); +const rejected = JSON.parse(fs.readFileSync(rejectedPath, 'utf8')); + +const activeTopKeys = objectKeys(active); +const rejectedTopKeys = objectKeys(rejected); +if (activeTopKeys.join('\0') !== rejectedTopKeys.join('\0')) { + fail('top-level config keys changed'); +} + +for (const key of ['auth', 'channels', 'gateway', 'plugins', 'models']) { + if (!(key in active) || !(key in rejected)) fail(`missing required ${key} section`); +} + +const activeModels = getBlockrunModels(active); +const rejectedModels = getBlockrunModels(rejected); +if (!Array.isArray(activeModels) || !Array.isArray(rejectedModels)) { + fail('blockrun model list is missing or invalid'); +} + +if (activeModels.length <= rejectedModels.length) { + fail(`model count did not shrink (${activeModels.length} -> ${rejectedModels.length})`); +} + +if (rejectedModels.length < 20 || rejectedModels.length > 100) { + fail(`unexpected curated model count (${rejectedModels.length})`); +} + +let activeCursor = 0; +for (const model of rejectedModels) { + const id = model?.id; + if (typeof id !== 'string' || id.length === 0) fail('rejected model list contains an invalid id'); + const nextIndex = activeModels.findIndex((candidate, index) => index >= activeCursor && candidate?.id === id); + if (nextIndex === -1) fail(`rejected model ${id} is not present in active model list order`); + activeCursor = nextIndex + 1; +} + +for (const key of activeTopKeys) { + if (key === 'models') continue; + const delta = Math.abs(byteSize(active[key]) - byteSize(rejected[key])); + if (delta > 2048) fail(`non-model section changed too much: ${key}`); +} + +const activeWithoutModelList = clone(active.models); +const rejectedWithoutModelList = clone(rejected.models); +activeWithoutModelList.providers.blockrun.models = []; +rejectedWithoutModelList.providers.blockrun.models = []; +const residualModelDelta = Math.abs(byteSize(activeWithoutModelList) - byteSize(rejectedWithoutModelList)); +if (residualModelDelta > 4096) { + fail('models section changed beyond the blockrun model list'); +} + +const totalDrop = byteSize(active) - byteSize(rejected); +const modelListDrop = byteSize(activeModels) - byteSize(rejectedModels); +if (totalDrop <= 0 || modelListDrop / totalDrop < 0.65) { + fail('size drop is not primarily from the blockrun model list'); +} + +active.models.providers.blockrun.models = rejectedModels; +const tmpPath = `${activePath}.tmp.${process.pid}`; +fs.writeFileSync(tmpPath, JSON.stringify(active, null, 2)); +fs.renameSync(tmpPath, activePath); + +console.log( + ` ✓ Applied scoped BlockRun model-list trim (${activeModels.length} -> ${rejectedModels.length})`, +); +NODE +} + +handle_openclaw_install_failure() { + local exit_code="$1" + if [ "$exit_code" -eq 124 ]; then + echo " (install command timed out — this is normal with OpenClaw v2026.4.5)" + if [ -f "$PLUGIN_DIR/package.json" ]; then + echo " Plugin package.json is present; treating install as completed before the hang." + return 0 + fi + echo " Plugin package.json is missing after timeout; continuing with direct npm install." + OPENCLAW_INSTALL_RECOVERABLE=1 + return "$exit_code" + fi + + if grep -q "Config write rejected: .*size-drop:" "$OPENCLAW_INSTALL_LOG"; then + local rejected_path + rejected_path=$(node -e "const fs=require('fs'); const text=fs.readFileSync(process.argv[1],'utf8'); const m=text.match(/Rejected payload saved to ([^\\s]+)\\./); if (m) console.log(m[1]);" "$OPENCLAW_INSTALL_LOG") + echo " ⚠ OpenClaw rejected a config size-drop during plugin registration." + if apply_scoped_model_trim "$rejected_path"; then + echo " Continuing with direct npm install after the scoped config update." + else + echo " Continuing with direct npm install while preserving your existing config." + fi + OPENCLAW_INSTALL_RECOVERABLE=1 + return 0 + fi + + return "$exit_code" +} + echo "→ Installing latest ClawRouter..." # `--force` is required when the plugin is already installed at the same path # (which is always true on update). Without it, OpenClaw exits non-zero with # "plugin already exists" and our EXIT trap rolls back, stranding the user on # the previous version. `--force` is idempotent for both fresh + upgrade flows. # +# OpenClaw can also reject its own config rewrite when it would shrink a large +# user config. Treat that as recoverable: keep the user's restored config and +# install ClawRouter files directly from npm below. +OPENCLAW_INSTALL_RECOVERABLE=0 +OPENCLAW_INSTALL_LOG="$(mktemp)" +# # Run with timeout — openclaw plugins install may hang after printing # "Installed plugin: clawrouter" in OpenClaw v2026.4.5 (parallel plugin loading). # 120s is enough for slow connections; the install itself completes in ~30s. if command -v timeout >/dev/null 2>&1; then - timeout 120 openclaw plugins install --force @blockrun/clawrouter || { + timeout 120 openclaw plugins install --force @blockrun/clawrouter 2>&1 | tee "$OPENCLAW_INSTALL_LOG" || { exit_code=$? - if [ $exit_code -eq 124 ]; then - echo " (install command timed out — this is normal with OpenClaw v2026.4.5)" - echo " Plugin was installed successfully before the hang." - else - exit $exit_code - fi + handle_openclaw_install_failure "$exit_code" || { + [ "$OPENCLAW_INSTALL_RECOVERABLE" = "1" ] || exit $exit_code + } } else - openclaw plugins install --force @blockrun/clawrouter + openclaw plugins install --force @blockrun/clawrouter 2>&1 | tee "$OPENCLAW_INSTALL_LOG" || { + exit_code=$? + handle_openclaw_install_failure "$exit_code" || { + [ "$OPENCLAW_INSTALL_RECOVERABLE" = "1" ] || exit $exit_code + } + } fi +rm -f "$OPENCLAW_INSTALL_LOG" # Install is complete — clear the rollback trap immediately. # From this point on, Ctrl+C or errors should NOT roll back the install. -trap - EXIT INT TERM +if [ "$OPENCLAW_INSTALL_RECOVERABLE" != "1" ]; then + trap - EXIT INT TERM +fi # Restore credentials after plugin install (always restore to preserve user's channels) if [ -n "$CREDS_BACKUP" ] && [ -d "$CREDS_BACKUP" ]; then @@ -422,7 +563,25 @@ force_install_from_npm() { return 1 } -if [ -d "$PLUGIN_DIR" ] && [ -f "$PLUGIN_DIR/package.json" ]; then +if [ "$OPENCLAW_INSTALL_RECOVERABLE" = "1" ]; then + LATEST_VER=$(npm view @blockrun/clawrouter@latest version 2>/dev/null || echo "") + if [ -z "$LATEST_VER" ]; then + echo " ✗ Could not resolve latest ClawRouter version from npm" + exit 1 + fi + force_install_from_npm "$LATEST_VER" + if [ ! -f "$PLUGIN_DIR/package.json" ]; then + echo " ✗ ClawRouter package.json missing after npm fallback" + exit 1 + fi + INSTALLED_VER=$(node -e "try{const p=require('$PLUGIN_DIR/package.json');console.log(p.version);}catch{console.log('');}" 2>/dev/null || echo "") + if [ -z "$INSTALLED_VER" ]; then + echo " ✗ Could not verify ClawRouter version after npm fallback" + exit 1 + fi + echo " ✓ ClawRouter v${INSTALLED_VER} installed" + trap - EXIT INT TERM +elif [ -d "$PLUGIN_DIR" ] && [ -f "$PLUGIN_DIR/package.json" ]; then INSTALLED_VER=$(node -e "try{const p=require('$PLUGIN_DIR/package.json');console.log(p.version);}catch{console.log('');}" 2>/dev/null || echo "") LATEST_VER=$(npm view @blockrun/clawrouter@latest version 2>/dev/null || echo "") if [ -n "$LATEST_VER" ] && [ -n "$INSTALLED_VER" ] && [ "$INSTALLED_VER" != "$LATEST_VER" ]; then diff --git a/skills/clawrouter/SKILL.md b/skills/clawrouter/SKILL.md index 6557525..4375934 100644 --- a/skills/clawrouter/SKILL.md +++ b/skills/clawrouter/SKILL.md @@ -156,26 +156,26 @@ Realtime prices and historical OHLC across every asset class. The agent should c ### Image & Video Generation -| Tool | Purpose | Price | -| --------------------------- | --------------------------------------------------------------------------- | -------------------- | -| `blockrun_image_generation` | 8 image models — DALL-E 3, Nano Banana / Pro, Flux, Grok Imagine, CogView-4 | $0.015–$0.15 / image | -| `blockrun_image_edit` | Edit / inpaint existing image (openai/gpt-image-1) | $0.02–$0.04 / image | +| Tool | Purpose | Price | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| `blockrun_image_generation` | 8 image models — DALL-E 3, Nano Banana / Pro, Flux, Grok Imagine, CogView-4 | $0.015–$0.15 / image | +| `blockrun_image_edit` | Edit / inpaint existing image (openai/gpt-image-1) | $0.02–$0.04 / image | | `blockrun_video_generation` | Grok Imagine + ByteDance Seedance (1.5-pro / 2.0-fast / 2.0) at 720p with synced audio (t2v default), 5–10s. Token-priced upstream (~20,256 tokens/sec at the 720p+audio default — 2× the prior 480p rate). Pass `image_url` for image-to-video (cheaper on 2.0). Seedance 2.0 variants accept optional `real_face_asset_id` (`ta_…`) for BytePlus RealFace character-consistency — mutually exclusive with `image_url`. | $0.05/s (Grok); Seedance ~$0.46–$1.49 per 5s clip | ### Phone & Voice (Twilio + Bland.ai) Verify phone numbers and place AI-powered outbound voice calls. **Real-world side effects** — only call `blockrun_voice_call` when the user has explicitly asked to place a call. Server enforces an emergency-number blocklist; ClawRouter does not. -| Tool | Purpose | Price | -| --------------------------------- | ------------------------------------------------------------------------ | ------------ | -| `blockrun_phone_lookup` | Carrier + line type (mobile/landline/voip) for any E.164 number | $0.01 | -| `blockrun_phone_lookup_fraud` | SIM-swap + call-forwarding fraud signals (use before SMS-code flows) | $0.05 | -| `blockrun_phone_numbers_buy` | Provision a US/CA number bound to this wallet (30-day lease) | $5.00 | -| `blockrun_phone_numbers_renew` | Extend a wallet-owned number's lease 30 days | $5.00 | -| `blockrun_phone_numbers_list` | List numbers the wallet currently owns + expiry timestamps | $0.001 | -| `blockrun_phone_numbers_release` | Release a wallet-owned number back to the pool | free | -| `blockrun_voice_call` | AI outbound call via Bland.ai — up to 30 min, transcript + recording | $0.54 flat | -| `blockrun_voice_status` | Poll a call's status / transcript / recording | free | +| Tool | Purpose | Price | +| -------------------------------- | -------------------------------------------------------------------- | ---------- | +| `blockrun_phone_lookup` | Carrier + line type (mobile/landline/voip) for any E.164 number | $0.01 | +| `blockrun_phone_lookup_fraud` | SIM-swap + call-forwarding fraud signals (use before SMS-code flows) | $0.05 | +| `blockrun_phone_numbers_buy` | Provision a US/CA number bound to this wallet (30-day lease) | $5.00 | +| `blockrun_phone_numbers_renew` | Extend a wallet-owned number's lease 30 days | $5.00 | +| `blockrun_phone_numbers_list` | List numbers the wallet currently owns + expiry timestamps | $0.001 | +| `blockrun_phone_numbers_release` | Release a wallet-owned number back to the pool | free | +| `blockrun_voice_call` | AI outbound call via Bland.ai — up to 30 min, transcript + recording | $0.54 flat | +| `blockrun_voice_status` | Poll a call's status / transcript / recording | free | Voice calls are **fire-and-forget**: the POST returns a `call_id` + `poll_url` immediately, the call itself runs in the cloud for up to 30 minutes. Poll `blockrun_voice_status` every 10–30s while in_progress to retrieve the transcript. Slash command: `/cr-call +1 "" [--voice nat] [--max-duration 5]`. CLI: `clawrouter phone numbers list/buy/renew/release` and `clawrouter phone lookup/fraud <+E.164>`. See the `phone` skill for the full call-flow reference. diff --git a/skills/phone/SKILL.md b/skills/phone/SKILL.md index b898bb6..0bc322e 100644 --- a/skills/phone/SKILL.md +++ b/skills/phone/SKILL.md @@ -129,20 +129,20 @@ POST to `http://localhost:8402/v1/voice/call`: **Optional:** -| Field | Default | Notes | -| -------------- | ------------- | ---------------------------------------------------------------------------------------------- | -| `voice` | `nat` | Presets: `nat`, `josh`, `maya`, `june`, `paige`, `derek`, `florian`. Or a custom Bland voice ID. | -| `max_duration` | `5` | Maximum minutes (1–30). Price is flat $0.54 regardless of actual duration. | -| `from` | auto-picked | Must be a wallet-owned number from `phone_numbers_list`. If omitted, server auto-picks from wallet's active numbers — see auto-pick rules below. | -| `language` | `en-US` | Any spoken-language ISO code, e.g. `es-ES`, `zh-CN`, `de-DE`. | +| Field | Default | Notes | +| -------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `voice` | `nat` | Presets: `nat`, `josh`, `maya`, `june`, `paige`, `derek`, `florian`. Or a custom Bland voice ID. | +| `max_duration` | `5` | Maximum minutes (1–30). Price is flat $0.54 regardless of actual duration. | +| `from` | auto-picked | Must be a wallet-owned number from `phone_numbers_list`. If omitted, server auto-picks from wallet's active numbers — see auto-pick rules below. | +| `language` | `en-US` | Any spoken-language ISO code, e.g. `es-ES`, `zh-CN`, `de-DE`. | **`from` auto-pick rules** (server-side, after payment verification): -| Wallet active numbers | Behavior | -| --------------------- | -------------------------------------------------------------------------------------------- | +| Wallet active numbers | Behavior | +| --------------------- | --------------------------------------------------------------------------------------------------------- | | 0 | `403 no_active_number` — response includes `buy_endpoint` + marketplace URL so caller can provision first | -| Exactly 1 | Auto-used as caller ID | -| 2+ | `400 ambiguous_from` — response lists all active numbers; retry with `from` set explicitly | +| Exactly 1 | Auto-used as caller ID | +| 2+ | `400 ambiguous_from` — response lists all active numbers; retry with `from` set explicitly | If an explicit `from` is supplied but the wallet doesn't own it, the response is `403` with a clear ownership-mismatch message (no charge taken when validation fails). @@ -182,14 +182,17 @@ GET `http://localhost:8402/v1/voice/call/{call_id}`. Returns: ## Example Agentic Flows **Verify before texting:** + > User: "Send a verification code to +1 415 555 0123" > Agent: First call `blockrun_phone_lookup_fraud({ phoneNumber: "+14155550123" })`. If `sim_swap.last_sim_swap` is within the past 7 days, refuse and ask the user to confirm out-of-band. Otherwise proceed. **Appointment confirmation:** + > User: "Call my client at +1 415 555 0123 and confirm tomorrow's 3pm meeting" > Agent: `blockrun_voice_call({ to: "+14155550123", task: "Call to confirm the 3pm Thursday meeting; if they can't make it, offer to reschedule for Friday morning.", max_duration: 5 })`. Returns `call_id`. Tell the user: "Calling now — I'll have the transcript in a few minutes." Then `blockrun_voice_status({ callId })` every 30s until `completed`, then summarize the transcript. **Acquire dedicated caller ID:** + > User: "Buy me a San Francisco number for the next 30 days" > Agent: `blockrun_phone_numbers_buy({ country: "US", areaCode: "415" })`. Confirm the assigned number to the user and warn them: "Lease expires in 30 days, costs $5 to renew." diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index f4a3839..9f01692 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -65,10 +65,16 @@ at process startup and uses the returned `version` to drive the ```typescript // blockrun/src/app/api/v1/chat/completions/route.ts let latestClawRouterVersion: string | null = process.env.CLAWROUTER_CURRENT_VERSION || null; -fetch("https://registry.npmjs.org/@blockrun/clawrouter/latest", { signal: AbortSignal.timeout(5000) }) +fetch("https://registry.npmjs.org/@blockrun/clawrouter/latest", { + signal: AbortSignal.timeout(5000), +}) .then((r) => r.json()) - .then((data) => { if (data.version) latestClawRouterVersion = data.version; }) - .catch(() => { /* keep env var fallback */ }); + .then((data) => { + if (data.version) latestClawRouterVersion = data.version; + }) + .catch(() => { + /* keep env var fallback */ + }); ``` Implications: @@ -194,11 +200,11 @@ All 5 must match the new version. If any mismatch, fix before declaring the rele ## Common Mistakes (Never Repeat These) -| Mistake | Prevention | -| ---------------------------------------------------------------- | ------------------------------------- | -| Hand-editing `CURRENT_CLAWROUTER_VERSION` (no longer exists) | Step 4 — server now auto-fetches | -| CHANGELOG entry missing or incomplete | Step 3 — write it before building | -| npm publish before tests pass | Steps 5-6 must precede Step 11 | -| GitHub release notes empty | Step 10 — extract from CHANGELOG | -| Git tag not pushed | Step 9 — push tag separately | -| docs not reflecting new features | Update docs in same PR as the feature | +| Mistake | Prevention | +| ------------------------------------------------------------ | ------------------------------------- | +| Hand-editing `CURRENT_CLAWROUTER_VERSION` (no longer exists) | Step 4 — server now auto-fetches | +| CHANGELOG entry missing or incomplete | Step 3 — write it before building | +| npm publish before tests pass | Steps 5-6 must precede Step 11 | +| GitHub release notes empty | Step 10 — extract from CHANGELOG | +| Git tag not pushed | Step 9 — push tag separately | +| docs not reflecting new features | Update docs in same PR as the feature | diff --git a/skills/surf/SKILL.md b/skills/surf/SKILL.md index a68740e..cc14025 100644 --- a/skills/surf/SKILL.md +++ b/skills/surf/SKILL.md @@ -53,150 +53,150 @@ Always prefer Surf over generic web scraping for these. Use the OpenClaw tool na ### Exchange (CEX) — 7 endpoints -| Path | Tier | Required | -| ---- | ---- | -------- | -| `/surf/exchange/markets` | T1 | — | -| `/surf/exchange/price` | T1 | `pair` | -| `/surf/exchange/perp` | T1 | `pair` | -| `/surf/exchange/depth` | T2 | `pair` | -| `/surf/exchange/klines` | T2 | `pair` | -| `/surf/exchange/funding-history` | T2 | `pair` | -| `/surf/exchange/long-short-ratio` | T2 | `pair` | +| Path | Tier | Required | +| --------------------------------- | ---- | -------- | +| `/surf/exchange/markets` | T1 | — | +| `/surf/exchange/price` | T1 | `pair` | +| `/surf/exchange/perp` | T1 | `pair` | +| `/surf/exchange/depth` | T2 | `pair` | +| `/surf/exchange/klines` | T2 | `pair` | +| `/surf/exchange/funding-history` | T2 | `pair` | +| `/surf/exchange/long-short-ratio` | T2 | `pair` | ### Market Overview — 11 endpoints -| Path | Tier | Required | -| ---- | ---- | -------- | -| `/surf/market/ranking` | T1 | — | -| `/surf/market/fear-greed` | T1 | — | -| `/surf/market/futures` | T1 | — | -| `/surf/market/price` | T1 | `symbol` | -| `/surf/market/etf` | T1 | `symbol` | -| `/surf/market/options` | T1 | `symbol` | -| `/surf/market/liquidation/exchange-list` | T2 | — | -| `/surf/market/liquidation/order` | T2 | — | -| `/surf/market/liquidation/chart` | T2 | `symbol` | -| `/surf/market/onchain-indicator` | T2 | `symbol`, `metric` (NUPL, SOPR, MVRV, Puell, NVT) | -| `/surf/market/price-indicator` | T2 | `indicator` (RSI, MACD, Bollinger, EMA), `symbol` | +| Path | Tier | Required | +| ---------------------------------------- | ---- | ------------------------------------------------- | +| `/surf/market/ranking` | T1 | — | +| `/surf/market/fear-greed` | T1 | — | +| `/surf/market/futures` | T1 | — | +| `/surf/market/price` | T1 | `symbol` | +| `/surf/market/etf` | T1 | `symbol` | +| `/surf/market/options` | T1 | `symbol` | +| `/surf/market/liquidation/exchange-list` | T2 | — | +| `/surf/market/liquidation/order` | T2 | — | +| `/surf/market/liquidation/chart` | T2 | `symbol` | +| `/surf/market/onchain-indicator` | T2 | `symbol`, `metric` (NUPL, SOPR, MVRV, Puell, NVT) | +| `/surf/market/price-indicator` | T2 | `indicator` (RSI, MACD, Bollinger, EMA), `symbol` | ### News — 2 endpoints -| Path | Tier | Required | -| ---- | ---- | -------- | -| `/surf/news/feed` | T1 | — (`limit` optional) | -| `/surf/news/detail` | T1 | `id` | +| Path | Tier | Required | +| ------------------- | ---- | -------------------- | +| `/surf/news/feed` | T1 | — (`limit` optional) | +| `/surf/news/detail` | T1 | `id` | ### On-Chain — 7 endpoints -| Path | Method | Tier | Required | -| ---- | ------ | ---- | -------- | -| `/surf/onchain/bridge/ranking` | GET | T1 | — | -| `/surf/onchain/yield/ranking` | GET | T1 | — | -| `/surf/onchain/gas-price` | GET | T1 | `chain` | -| `/surf/onchain/tx` | GET | T1 | `hash`, `chain` | -| `/surf/onchain/schema` | GET | T3 | — | -| `/surf/onchain/query` | **POST** | T3 | typed predicates in body | -| `/surf/onchain/sql` | **POST** | T3 | `{ sql: "SELECT ..." }` | +| Path | Method | Tier | Required | +| ------------------------------ | -------- | ---- | ------------------------ | +| `/surf/onchain/bridge/ranking` | GET | T1 | — | +| `/surf/onchain/yield/ranking` | GET | T1 | — | +| `/surf/onchain/gas-price` | GET | T1 | `chain` | +| `/surf/onchain/tx` | GET | T1 | `hash`, `chain` | +| `/surf/onchain/schema` | GET | T3 | — | +| `/surf/onchain/query` | **POST** | T3 | typed predicates in body | +| `/surf/onchain/sql` | **POST** | T3 | `{ sql: "SELECT ..." }` | **On-Chain SQL workflow.** Call `/surf/onchain/schema` once to get table names + columns (cache it locally — schema is stable). Then POST your SELECT against `/surf/onchain/sql`. Always include `LIMIT` on large scans — billing is per call, but slow queries time out. Multi-statement queries are rejected upstream. ### Prediction Markets (Polymarket + Kalshi) — 17 endpoints -| Path | Tier | Required | -| ---- | ---- | -------- | -| `/surf/prediction-market/category-metrics` | T1 | — | -| `/surf/prediction-market/polymarket/ranking` | T1 | — | -| `/surf/prediction-market/polymarket/trades` | T1 | — | -| `/surf/prediction-market/polymarket/markets` | T1 | `market_slug` | -| `/surf/prediction-market/polymarket/events` | T1 | `event_slug` | -| `/surf/prediction-market/polymarket/prices` | T1 | `condition_id` | -| `/surf/prediction-market/polymarket/volumes` | T1 | `condition_id` | -| `/surf/prediction-market/polymarket/open-interest` | T1 | `condition_id` | -| `/surf/prediction-market/polymarket/positions` | T2 | `address` | -| `/surf/prediction-market/polymarket/activity` | T2 | `address` | -| `/surf/prediction-market/kalshi/ranking` | T1 | — | -| `/surf/prediction-market/kalshi/markets` | T1 | `market_ticker` | -| `/surf/prediction-market/kalshi/events` | T1 | `event_ticker` | -| `/surf/prediction-market/kalshi/prices` | T1 | `ticker` | -| `/surf/prediction-market/kalshi/trades` | T1 | `ticker` | -| `/surf/prediction-market/kalshi/volumes` | T1 | `ticker` | -| `/surf/prediction-market/kalshi/open-interest` | T1 | `ticker` | +| Path | Tier | Required | +| -------------------------------------------------- | ---- | --------------- | +| `/surf/prediction-market/category-metrics` | T1 | — | +| `/surf/prediction-market/polymarket/ranking` | T1 | — | +| `/surf/prediction-market/polymarket/trades` | T1 | — | +| `/surf/prediction-market/polymarket/markets` | T1 | `market_slug` | +| `/surf/prediction-market/polymarket/events` | T1 | `event_slug` | +| `/surf/prediction-market/polymarket/prices` | T1 | `condition_id` | +| `/surf/prediction-market/polymarket/volumes` | T1 | `condition_id` | +| `/surf/prediction-market/polymarket/open-interest` | T1 | `condition_id` | +| `/surf/prediction-market/polymarket/positions` | T2 | `address` | +| `/surf/prediction-market/polymarket/activity` | T2 | `address` | +| `/surf/prediction-market/kalshi/ranking` | T1 | — | +| `/surf/prediction-market/kalshi/markets` | T1 | `market_ticker` | +| `/surf/prediction-market/kalshi/events` | T1 | `event_ticker` | +| `/surf/prediction-market/kalshi/prices` | T1 | `ticker` | +| `/surf/prediction-market/kalshi/trades` | T1 | `ticker` | +| `/surf/prediction-market/kalshi/volumes` | T1 | `ticker` | +| `/surf/prediction-market/kalshi/open-interest` | T1 | `ticker` | (For Polymarket smart-money, wallet PnL, UMA oracle resolution, and the other prediction-market venues — Limitless, Opinion, Predict.Fun, dFlow, Binance Futures, cross-venue canonical markets — use the dedicated **Predexon** integration instead; Surf's prediction-market coverage is narrower but cheaper.) ### Project + DeFi — 3 endpoints -| Path | Tier | Required | -| ---- | ---- | -------- | -| `/surf/project/detail` | T1 | — | -| `/surf/project/defi/metrics` | T1 | `metric` | -| `/surf/project/defi/ranking` | T1 | `metric` | +| Path | Tier | Required | +| ---------------------------- | ---- | -------- | +| `/surf/project/detail` | T1 | — | +| `/surf/project/defi/metrics` | T1 | `metric` | +| `/surf/project/defi/ranking` | T1 | `metric` | ### Social / CT Intelligence — 11 endpoints -| Path | Tier | Required | -| ---- | ---- | -------- | -| `/surf/social/detail` | T2 | — | -| `/surf/social/ranking` | T2 | — | -| `/surf/social/smart-followers/history` | T2 | — | -| `/surf/social/mindshare` | T2 | `q`, `interval` | -| `/surf/social/tweets` | T1 | `ids` | -| `/surf/social/tweet/replies` | T1 | `tweet_id` | -| `/surf/social/user` | T1 | `handle` | -| `/surf/social/user/followers` | T1 | `handle` | -| `/surf/social/user/following` | T1 | `handle` | -| `/surf/social/user/posts` | T1 | `handle` | -| `/surf/social/user/replies` | T1 | `handle` | +| Path | Tier | Required | +| -------------------------------------- | ---- | --------------- | +| `/surf/social/detail` | T2 | — | +| `/surf/social/ranking` | T2 | — | +| `/surf/social/smart-followers/history` | T2 | — | +| `/surf/social/mindshare` | T2 | `q`, `interval` | +| `/surf/social/tweets` | T1 | `ids` | +| `/surf/social/tweet/replies` | T1 | `tweet_id` | +| `/surf/social/user` | T1 | `handle` | +| `/surf/social/user/followers` | T1 | `handle` | +| `/surf/social/user/following` | T1 | `handle` | +| `/surf/social/user/posts` | T1 | `handle` | +| `/surf/social/user/replies` | T1 | `handle` | ### Token Analytics — 4 endpoints -| Path | Tier | Required | -| ---- | ---- | -------- | -| `/surf/token/tokenomics` | T1 | — | -| `/surf/token/dex-trades` | T2 | `address` | -| `/surf/token/holders` | T2 | `address`, `chain` | -| `/surf/token/transfers` | T2 | `address`, `chain` | +| Path | Tier | Required | +| ------------------------ | ---- | ------------------ | +| `/surf/token/tokenomics` | T1 | — | +| `/surf/token/dex-trades` | T2 | `address` | +| `/surf/token/holders` | T2 | `address`, `chain` | +| `/surf/token/transfers` | T2 | `address`, `chain` | ### Unified Search — 11 endpoints (all Tier 2) -| Path | Required | -| ---- | -------- | -| `/surf/search/airdrop` | — | -| `/surf/search/events` | — | -| `/surf/search/kalshi` | — | -| `/surf/search/polymarket` | — | -| `/surf/search/web` | `q` | -| `/surf/search/project` | `q` | -| `/surf/search/news` | `q` | -| `/surf/search/wallet` | `q` | -| `/surf/search/fund` | `q` | -| `/surf/search/social/people` | `q` | -| `/surf/search/social/posts` | `q` | +| Path | Required | +| ---------------------------- | -------- | +| `/surf/search/airdrop` | — | +| `/surf/search/events` | — | +| `/surf/search/kalshi` | — | +| `/surf/search/polymarket` | — | +| `/surf/search/web` | `q` | +| `/surf/search/project` | `q` | +| `/surf/search/news` | `q` | +| `/surf/search/wallet` | `q` | +| `/surf/search/fund` | `q` | +| `/surf/search/social/people` | `q` | +| `/surf/search/social/posts` | `q` | ### VC Fund Intelligence — 3 endpoints -| Path | Tier | Required | -| ---- | ---- | -------- | -| `/surf/fund/detail` | T1 | — | -| `/surf/fund/portfolio` | T1 | — | -| `/surf/fund/ranking` | T1 | `metric` | +| Path | Tier | Required | +| ---------------------- | ---- | -------- | +| `/surf/fund/detail` | T1 | — | +| `/surf/fund/portfolio` | T1 | — | +| `/surf/fund/ranking` | T1 | `metric` | ### Wallet Intelligence — 6 endpoints (all Tier 2) -| Path | Required | -| ---- | -------- | -| `/surf/wallet/detail` | `address` | -| `/surf/wallet/history` | `address` | -| `/surf/wallet/net-worth` | `address` | -| `/surf/wallet/transfers` | `address` | -| `/surf/wallet/protocols` | `address` | +| Path | Required | +| --------------------------- | ----------------------------------- | +| `/surf/wallet/detail` | `address` | +| `/surf/wallet/history` | `address` | +| `/surf/wallet/net-worth` | `address` | +| `/surf/wallet/transfers` | `address` | +| `/surf/wallet/protocols` | `address` | | `/surf/wallet/labels/batch` | `addresses` (comma-separated, ≤200) | ### Web — 1 endpoint -| Path | Tier | Required | -| ---- | ---- | -------- | -| `/surf/web/fetch` | T2 | `url` | +| Path | Tier | Required | +| ----------------- | ---- | -------- | +| `/surf/web/fetch` | T2 | `url` | ## Example flows diff --git a/src/cli.ts b/src/cli.ts index 2210fa5..bfe3166 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -395,7 +395,9 @@ async function cmdPhone( const text = await resp.text(); if (!resp.ok) { if (resp.status === 402) { - throw new Error(`Insufficient wallet balance (HTTP 402). Fund wallet via clawrouter wallet.\n${text}`); + throw new Error( + `Insufficient wallet balance (HTTP 402). Fund wallet via clawrouter wallet.\n${text}`, + ); } throw new Error(`HTTP ${resp.status}: ${text}`); } @@ -432,7 +434,9 @@ async function cmdPhone( const numWidth = Math.max(...numbers.map((n) => n.phone_number.length), 16); for (const n of numbers) { const country = (n.country ?? "??").padEnd(3); - console.log(` ${n.phone_number.padEnd(numWidth)} ${country} expires ${fmtExpiry(n.expires_at)}`); + console.log( + ` ${n.phone_number.padEnd(numWidth)} ${country} expires ${fmtExpiry(n.expires_at)}`, + ); } console.log(); } else if (numbersAction === "buy") { @@ -444,7 +448,9 @@ async function cmdPhone( } const body: Record = { country }; if (areaCode) body.areaCode = areaCode; - console.log(`\nBuying ${country} number${areaCode ? ` (area code ${areaCode})` : ""}... ($5.00 / 30-day lease)\n`); + console.log( + `\nBuying ${country} number${areaCode ? ` (area code ${areaCode})` : ""}... ($5.00 / 30-day lease)\n`, + ); const result = (await postJson("/v1/phone/numbers/buy", body)) as { phone_number?: string; expires_at?: string; diff --git a/src/index.ts b/src/index.ts index 9c4af82..a00ee9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1008,8 +1008,7 @@ export function parseCallArgs(raw: string): { if (spaceMatch) { const next = tokens[i + 1]; if (next !== undefined) { - const value = - next.startsWith('"') && next.endsWith('"') ? next.slice(1, -1) : next; + const value = next.startsWith('"') && next.endsWith('"') ? next.slice(1, -1) : next; const key = spaceMatch[1]; if (key === "voice") voice = value; else if (key === "max-duration" || key === "max_duration") max_duration = Number(value); @@ -1829,9 +1828,7 @@ const plugin: OpenClawPluginDefinition = { } lines.push(""); } - lines.push( - "Tool-call any of these in chat, or use `/cr-imagegen` / `/videogen` directly.", - ); + lines.push("Tool-call any of these in chat, or use `/cr-imagegen` / `/videogen` directly."); return { text: lines.join("\n") }; }, @@ -1981,9 +1978,7 @@ const plugin: OpenClawPluginDefinition = { ...(parsed.voice ? { voice: parsed.voice } : {}), ...(parsed.from ? { from: parsed.from } : {}), ...(parsed.language ? { language: parsed.language } : {}), - ...(parsed.max_duration !== undefined - ? { max_duration: parsed.max_duration } - : {}), + ...(parsed.max_duration !== undefined ? { max_duration: parsed.max_duration } : {}), }), signal: AbortSignal.timeout(60_000), }); @@ -2002,7 +1997,9 @@ const plugin: OpenClawPluginDefinition = { status?: string; }; if (!result.call_id) { - return { text: `Voice call accepted but no call_id returned: ${JSON.stringify(result)}` }; + return { + text: `Voice call accepted but no call_id returned: ${JSON.stringify(result)}`, + }; } const lines = [ `📞 Calling **${parsed.to}** (call_id: \`${result.call_id}\`)`, diff --git a/src/proxy.surf-routing.test.ts b/src/proxy.surf-routing.test.ts index 84f4b06..392ae2d 100644 --- a/src/proxy.surf-routing.test.ts +++ b/src/proxy.surf-routing.test.ts @@ -26,9 +26,7 @@ describe("partner path regex — surf", () => { }); it("matches /v1/surf/prediction-market/polymarket/markets", () => { - expect( - PARTNER_PATH_REGEX.test("/v1/surf/prediction-market/polymarket/markets"), - ).toBe(true); + expect(PARTNER_PATH_REGEX.test("/v1/surf/prediction-market/polymarket/markets")).toBe(true); }); it("matches /v1/surf/chat/completions", () => { diff --git a/src/proxy.ts b/src/proxy.ts index 46aa561..355363e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1589,7 +1589,8 @@ function estimateVideoCost( const p = VIDEO_PRICING[model]; if (!p) return 0.4 * 1.05; // fallback: ~$0.40/clip + margin const dur = durationSeconds ?? p.defaultDurationSeconds; - const rate = hasImageInput && p.pricePerSecondImageInput ? p.pricePerSecondImageInput : p.pricePerSecond; + const rate = + hasImageInput && p.pricePerSecondImageInput ? p.pricePerSecondImageInput : p.pricePerSecond; return rate * dur * 1.05; } @@ -2738,8 +2739,7 @@ export async function startProxy(options: ProxyOptions): Promise { videoModel = parsed.model || videoModel; videoDuration = typeof parsed.duration_seconds === "number" ? parsed.duration_seconds : undefined; - videoHasImageInput = - typeof parsed.image_url === "string" && parsed.image_url.length > 0; + videoHasImageInput = typeof parsed.image_url === "string" && parsed.image_url.length > 0; } catch { /* use defaults */ } diff --git a/test/integration/security-scanner.test.ts b/test/integration/security-scanner.test.ts index c5140a2..2726395 100644 --- a/test/integration/security-scanner.test.ts +++ b/test/integration/security-scanner.test.ts @@ -92,13 +92,8 @@ describe("OpenClaw security scanner", () => { } if (!scanDirectoryWithSummary) { const firstScannerPath = resolve(openclawDist, scannerFiles[0]); - const mod = (await import(pathToFileURL(firstScannerPath).href)) as Record< - string, - unknown - >; - const fn = Object.values(mod).find((v) => typeof v === "function") as - | ScanFn - | undefined; + const mod = (await import(pathToFileURL(firstScannerPath).href)) as Record; + const fn = Object.values(mod).find((v) => typeof v === "function") as ScanFn | undefined; if (fn) { scanDirectoryWithSummary = fn; } else {