diff --git a/.env.example b/.env.example index 62802a7..9d437ee 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,15 @@ ACP_BOUNTY_API_URL=https://bounty.virtuals.io # Seller WebSocket / ACP socket URL ACP_SOCKET_URL=https://acpx.virtuals.io + +# Optional OpenRouter integration for ops-recovery offerings. +# If OPENROUTER_API_KEY is unset, the runtime falls back to deterministic rule-based recovery. +OPENROUTER_API_KEY= +OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +OPENROUTER_MODEL= +# Recommended free default (OpenRouter free-model router): +OPENROUTER_FREE_MODEL=openrouter/free +# Optional paid model override (leave empty to keep free routing): +# OPENROUTER_MODEL=google/gemini-2.5-flash-lite +OPENROUTER_SITE_URL=https://app.virtuals.io +OPENROUTER_APP_NAME=acp-ops-recovery-router diff --git a/README.md b/README.md index 5c4aaca..fe4f725 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,24 @@ Any agent can sell services on the ACP marketplace. The workflow: See [Seller reference](./references/seller.md) for the full guide. +### OpenRouter Free Model Daily Ops + +For seller runtimes that must stay on free OpenRouter models: + +- Dry-run health check and recommendation: + - `npm run openrouter:free:check` +- Apply selected free model to Railway env: + - `npm run openrouter:free:apply` +- Apply and immediately redeploy runtime: + - `npm run openrouter:free:apply:deploy` + +Behavior: + +- Fetches live model list from OpenRouter +- Filters to text-capable models with zero prompt/completion pricing +- Probes candidates and selects the first healthy model +- Sets `OPENROUTER_FREE_MODEL=` and removes `OPENROUTER_MODEL` (paid override) + ## Registering Resources Resources are external APIs or services that your agent can register and make available to other agents. Resources can be referenced in job offerings to indicate dependencies or capabilities your agent provides. diff --git a/data/rapid_recovery_telegram_targets.json b/data/rapid_recovery_telegram_targets.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/rapid_recovery_telegram_targets.json @@ -0,0 +1 @@ +[] diff --git a/docs/rapid-recovery-router-7day-sprint.md b/docs/rapid-recovery-router-7day-sprint.md new file mode 100644 index 0000000..5eb646a --- /dev/null +++ b/docs/rapid-recovery-router-7day-sprint.md @@ -0,0 +1,126 @@ +# Rapid-Recovery-Router 7일 매출 스프린트 실행 가이드 + +## 목표 + +- 핵심 KPI: `외부 유료건수/일` +- 7일 목표: 외부 유료 7건 +- 가격 구조: `0.02 -> 0.05 -> 0.12` +- 모델 정책: `OPENROUTER_MODEL` unset, `OPENROUTER_FREE_MODEL`만 사용 + +## 오퍼 구성 + +- `ops_recovery_hotfix_openrouter_v1` (0.02) +- `ops_recovery_turbo_v1` (0.05) +- `ops_recovery_guardrail_v1` (0.12) + +모든 오퍼 설명 첫 줄은 아래 문제 키워드 고정: + +- `timeout | validation | rejected | retry payload` + +## 일일 운영 명령 + +### 1) 무료 모델 점검 + 배포 + +```bash +npm run openrouter:free:apply:deploy +``` + +실패 시 자동 롤백: + +- `OPENROUTER_FREE_MODEL=openrouter/free` +- `OPENROUTER_MODEL` 삭제 + +### 2) KPI 리포트 생성 (JSON/CSV) + +```bash +npx tsx scripts/rapid_recovery_kpi_report.ts \ + --window-hours 24 \ + --output-json logs/rapid_recovery_kpi_latest.json \ + --output-csv logs/rapid_recovery_kpi_latest.csv +``` + +포함 항목: + +- `external_jobs_24h` +- `external_usdc_24h` +- offering별 전환 +- 업셀 전환율 +- 리드탐색 비용/성과 + +### 3) 프로필 자동 업데이트 + +```bash +npx tsx scripts/rapid_recovery_profile_daily_update.ts \ + --kpi-json logs/rapid_recovery_kpi_latest.json +``` + +업데이트 항목 제한: + +- 최근 24h 외부 유료건수 +- 평균 처리시간 +- 대표 성공 케이스 + +### 4) 리드탐색 바운티 루프 + +```bash +npx tsx scripts/rapid_recovery_lead_bounty_loop.ts +``` + +가드레일: + +- 전일 외부 유료건수 `< 1`일 때만 집행 +- 일일 상한 `0.10 USDC` +- 하루 최대 1건 +- relevance + 가격 상한 통과 시에만 자동 선택 +- 위반 시 `logs/rapid_recovery_lead_bounty_state.json`에 자동 중단 기록 + +### 5) Telegram 자동 아웃바운드 + +```bash +npx tsx scripts/rapid_recovery_telegram_outbound.ts +``` + +필수 환경변수: + +- `TELEGRAM_BOT_TOKEN` + +기본 타깃 파일: + +- `data/rapid_recovery_telegram_targets.json` + +가드레일: + +- 일일 총 발송 상한 +- 대상별 쿨다운 +- 중복 메시지 차단 +- 금지 키워드 필터 +- 연속 실패/거부율/신고 신호 기반 즉시 중단 + +로그: + +- `logs/rapid_recovery_telegram_send_log.jsonl` +- 필드: `who/when/template/version/result` + +### 6) 일일 통합 실행 + +```bash +npm run rapid:daily +``` + +Dry-run: + +```bash +npm run rapid:daily -- --dry-run +``` + +## ACP 노출 카피 포맷 + +- 입력 1줄 +- 복구결과 3종(JSON) +- CTA: `0.02 진입 -> 0.05 Turbo -> 0.12 Guardrail` + +## Telegram 노출 템플릿 원칙 + +- 단문 문제-해결-CTA 구조 +- 금지: 과장, 수익보장, 스팸성 키워드 +- CTA는 항상 `0.02 -> 0.05/0.12` 경로만 노출 diff --git a/package.json b/package.json index 83eaa39..de8c437 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,15 @@ "seller:run": "tsx bin/acp.ts serve start", "seller:stop": "tsx bin/acp.ts serve stop", "seller:check": "tsx bin/acp.ts serve status", + "openrouter:free:check": "node scripts/openrouter_free_daily_check.mjs", + "openrouter:free:apply": "node scripts/openrouter_free_daily_check.mjs --apply", + "openrouter:free:apply:deploy": "node scripts/openrouter_free_daily_check.mjs --apply --deploy", + "rapid:kpi": "tsx scripts/rapid_recovery_kpi_report.ts --window-hours 24", + "rapid:profile:update": "tsx scripts/rapid_recovery_profile_daily_update.ts", + "rapid:lead:bounty": "tsx scripts/rapid_recovery_lead_bounty_loop.ts", + "rapid:telegram:outbound": "tsx scripts/rapid_recovery_telegram_outbound.ts", + "rapid:daily": "tsx scripts/rapid_recovery_daily_ops.ts", + "test": "tsx --test tests/*.test.ts", "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky" diff --git a/scripts/openrouter_free_daily_check.mjs b/scripts/openrouter_free_daily_check.mjs new file mode 100644 index 0000000..11f4259 --- /dev/null +++ b/scripts/openrouter_free_daily_check.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node + +import { execFileSync } from "child_process"; + +const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const DEFAULT_SITE_URL = "https://app.virtuals.io"; +const DEFAULT_APP_NAME = "acp-ops-recovery-router"; +const ROLLBACK_FREE_MODEL = "openrouter/free"; + +const argv = new Set(process.argv.slice(2)); +const shouldApply = argv.has("--apply"); +const shouldDeploy = argv.has("--deploy"); +const verbose = argv.has("--verbose"); + +function run(cmd, args, options = {}) { + return execFileSync(cmd, args, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + ...options, + }).trim(); +} + +function runInherit(cmd, args) { + execFileSync(cmd, args, { stdio: "inherit" }); +} + +function toVarsMap(rawJson) { + const parsed = JSON.parse(rawJson); + if (Array.isArray(parsed)) { + return Object.fromEntries(parsed.map((entry) => [entry.name, String(entry.value ?? "")])); + } + if (parsed && typeof parsed === "object") { + return Object.fromEntries( + Object.entries(parsed).map(([key, value]) => [key, String(value ?? "")]) + ); + } + throw new Error("Unexpected format from `railway variables --json`."); +} + +function isFreeModel(modelId) { + const id = String(modelId || "") + .trim() + .toLowerCase(); + return id === "openrouter/free" || id.endsWith(":free"); +} + +function hasZeroCost(model) { + const pricing = model?.pricing ?? {}; + const prompt = Number(pricing.prompt ?? pricing.input ?? Number.NaN); + const completion = Number(pricing.completion ?? pricing.output ?? Number.NaN); + return prompt === 0 && completion === 0; +} + +function isTextCapable(model) { + const modalities = model?.architecture?.input_modalities; + if (!Array.isArray(modalities) || modalities.length === 0) return true; + return modalities.includes("text"); +} + +function unique(list) { + return [...new Set(list.filter(Boolean))]; +} + +function applyFreeModelPolicy(modelId, { deploy } = { deploy: false }) { + const targetModel = isFreeModel(modelId) ? modelId : ROLLBACK_FREE_MODEL; + runInherit("railway", ["variables", "set", `OPENROUTER_FREE_MODEL=${targetModel}`]); + try { + runInherit("railway", ["variables", "delete", "OPENROUTER_MODEL"]); + } catch { + // OPENROUTER_MODEL may not exist; ignore. + } + if (deploy) { + try { + runInherit("npx", ["tsx", "bin/acp.ts", "serve", "deploy", "railway"]); + } catch { + // Fallback for environments where active-agent config is not set locally. + runInherit("railway", ["up", "--detach"]); + } + } + return targetModel; +} + +async function probeModel({ apiKey, baseUrl, siteUrl, appName, modelId }) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); + const requestBody = { + model: modelId, + temperature: 0, + max_tokens: 120, + response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: + "Return strict JSON with keys: classification, lane, summary, retry_payload, next_actions, message_templates, confidence.", + }, + { + role: "user", + content: JSON.stringify({ + input: { + error_text: "timeout while waiting for response from target agent", + target_system: "acp", + persona_mode: "speed", + }, + }), + }, + ], + }; + + try { + const response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + signal: controller.signal, + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": siteUrl, + "X-Title": appName, + }, + body: JSON.stringify(requestBody), + }); + + const body = await response.text(); + if (!response.ok) { + return { + ok: false, + status: response.status, + error: body.slice(0, 300), + }; + } + + let parsed = null; + try { + parsed = JSON.parse(body); + } catch { + parsed = null; + } + + const content = + parsed?.choices?.[0]?.message?.content && + typeof parsed.choices[0].message.content === "string" + ? parsed.choices[0].message.content + : ""; + + return { + ok: content.length > 0, + status: response.status, + usage: parsed?.usage ?? null, + error: content.length > 0 ? null : "empty model content", + }; + } catch (error) { + return { + ok: false, + status: 0, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} + +function modelProbeErrorMessage(probes) { + return `All free-model probes failed: ${JSON.stringify(probes, null, 2)}`; +} + +async function main() { + const railwayVars = toVarsMap(run("railway", ["variables", "--json"])); + const apiKey = railwayVars.OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY || ""; + if (!apiKey) { + throw new Error("OPENROUTER_API_KEY is missing (Railway/env)."); + } + + const baseUrl = (railwayVars.OPENROUTER_BASE_URL || DEFAULT_OPENROUTER_BASE_URL).replace( + /\/$/, + "" + ); + const siteUrl = railwayVars.OPENROUTER_SITE_URL || DEFAULT_SITE_URL; + const appName = railwayVars.OPENROUTER_APP_NAME || DEFAULT_APP_NAME; + const currentModel = railwayVars.OPENROUTER_FREE_MODEL || ""; + + const probes = []; + let selected = null; + let freeModels = []; + let freeModelsCount = 0; + let selectionError = null; + + try { + const modelsResponse = await fetch(`${baseUrl}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + if (!modelsResponse.ok) { + throw new Error(`Failed to fetch models: HTTP ${modelsResponse.status}`); + } + const modelsJson = await modelsResponse.json(); + const allModels = Array.isArray(modelsJson?.data) ? modelsJson.data : []; + freeModels = allModels.filter((model) => hasZeroCost(model) && isTextCapable(model)); + freeModelsCount = freeModels.length; + + const preferred = unique([ + currentModel, + ROLLBACK_FREE_MODEL, + "qwen/qwen3-coder:free", + "openai/gpt-oss-20b:free", + "openai/gpt-oss-120b:free", + "google/gemma-3-27b-it:free", + "mistralai/mistral-small-3.1-24b-instruct:free", + ...freeModels.map((model) => model.id), + ]).filter((modelId) => isFreeModel(modelId)); + + if (preferred.length === 0) { + throw new Error("No text-capable free models available."); + } + + for (const modelId of preferred) { + const result = await probeModel({ apiKey, baseUrl, siteUrl, appName, modelId }); + probes.push({ modelId, ...result }); + if (result.ok) { + selected = { modelId, usage: result.usage ?? null }; + break; + } + } + + if (!selected) { + throw new Error(modelProbeErrorMessage(probes)); + } + } catch (error) { + selectionError = error instanceof Error ? error.message : String(error); + if (!shouldApply) { + throw error; + } + } + + let appliedModel = null; + let rollbackApplied = false; + if (shouldApply) { + const targetModel = selected?.modelId ?? ROLLBACK_FREE_MODEL; + rollbackApplied = !selected; + appliedModel = applyFreeModelPolicy(targetModel, { deploy: shouldDeploy }); + } + + const selectedMeta = selected + ? (freeModels.find((model) => model.id === selected.modelId) ?? null) + : null; + const promptCostPerToken = Number( + selectedMeta?.pricing?.prompt ?? selectedMeta?.pricing?.input ?? 0 + ); + const completionCostPerToken = Number( + selectedMeta?.pricing?.completion ?? selectedMeta?.pricing?.output ?? 0 + ); + const promptTokens = Number(selected?.usage?.prompt_tokens ?? 0); + const completionTokens = Number(selected?.usage?.completion_tokens ?? 0); + const estimatedUsd = selected + ? promptTokens * promptCostPerToken + completionTokens * completionCostPerToken + : 0; + + const summary = { + timestamp: new Date().toISOString(), + applied: shouldApply, + deployed: shouldApply && shouldDeploy, + selectedModel: selected?.modelId ?? null, + appliedModel, + rollbackApplied, + rollbackModel: rollbackApplied ? ROLLBACK_FREE_MODEL : null, + currentModelBefore: currentModel || null, + openrouterModelPolicy: "OPENROUTER_MODEL unset", + freeModelsCount, + estimatedUsdPerProbe: estimatedUsd, + usage: { + promptTokens, + completionTokens, + }, + probes: verbose ? probes : probes.slice(0, 5), + selectionError, + }; + + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write( + JSON.stringify( + { + error: error instanceof Error ? error.message : String(error), + }, + null, + 2 + ) + "\n" + ); + process.exit(1); +}); diff --git a/scripts/rapid_recovery_daily_ops.ts b/scripts/rapid_recovery_daily_ops.ts new file mode 100644 index 0000000..ad3d5a7 --- /dev/null +++ b/scripts/rapid_recovery_daily_ops.ts @@ -0,0 +1,149 @@ +#!/usr/bin/env npx tsx + +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; + +type CliOptions = { + dryRun: boolean; +}; + +function parseArgs(argv: string[]): CliOptions { + return { + dryRun: argv.includes("--dry-run"), + }; +} + +function runStep(command: string, args: string[], cwd: string) { + const output = execFileSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return output.trim(); +} + +function parseMaybeJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const cwd = process.cwd(); + const nowKey = new Date().toISOString().slice(0, 10).replaceAll("-", ""); + const kpiJson = path.resolve(cwd, "logs", `rapid_recovery_kpi_${nowKey}.json`); + const kpiCsv = path.resolve(cwd, "logs", `rapid_recovery_kpi_${nowKey}.csv`); + + const summary: Record = { + executedAt: new Date().toISOString(), + dryRun: options.dryRun, + steps: [], + }; + + const steps: Array<{ name: string; command: string; output?: unknown; error?: string }> = []; + + const pushStep = (name: string, command: string, fn: () => string) => { + try { + const output = fn(); + steps.push({ name, command, output: output ? parseMaybeJson(output) : "" }); + } catch (error) { + steps.push({ + name, + command, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + pushStep( + "openrouter_free_apply_deploy", + options.dryRun + ? "node scripts/openrouter_free_daily_check.mjs" + : "node scripts/openrouter_free_daily_check.mjs --apply --deploy", + () => + options.dryRun + ? runStep("node", ["scripts/openrouter_free_daily_check.mjs"], cwd) + : runStep("node", ["scripts/openrouter_free_daily_check.mjs", "--apply", "--deploy"], cwd) + ); + + pushStep( + "kpi_report", + `npx tsx scripts/rapid_recovery_kpi_report.ts --window-hours 24 --output-json ${kpiJson} --output-csv ${kpiCsv}`, + () => + runStep( + "npx", + [ + "tsx", + "scripts/rapid_recovery_kpi_report.ts", + "--window-hours", + "24", + "--output-json", + kpiJson, + "--output-csv", + kpiCsv, + ], + cwd + ) + ); + + pushStep( + "profile_daily_update", + options.dryRun + ? `npx tsx scripts/rapid_recovery_profile_daily_update.ts --kpi-json ${kpiJson} --dry-run` + : `npx tsx scripts/rapid_recovery_profile_daily_update.ts --kpi-json ${kpiJson}`, + () => + options.dryRun + ? runStep( + "npx", + [ + "tsx", + "scripts/rapid_recovery_profile_daily_update.ts", + "--kpi-json", + kpiJson, + "--dry-run", + ], + cwd + ) + : runStep( + "npx", + ["tsx", "scripts/rapid_recovery_profile_daily_update.ts", "--kpi-json", kpiJson], + cwd + ) + ); + + pushStep( + "lead_bounty_loop", + options.dryRun + ? "npx tsx scripts/rapid_recovery_lead_bounty_loop.ts --dry-run" + : "npx tsx scripts/rapid_recovery_lead_bounty_loop.ts", + () => + options.dryRun + ? runStep("npx", ["tsx", "scripts/rapid_recovery_lead_bounty_loop.ts", "--dry-run"], cwd) + : runStep("npx", ["tsx", "scripts/rapid_recovery_lead_bounty_loop.ts"], cwd) + ); + + pushStep( + "telegram_outbound", + options.dryRun + ? "npx tsx scripts/rapid_recovery_telegram_outbound.ts --dry-run" + : "npx tsx scripts/rapid_recovery_telegram_outbound.ts", + () => + options.dryRun + ? runStep("npx", ["tsx", "scripts/rapid_recovery_telegram_outbound.ts", "--dry-run"], cwd) + : runStep("npx", ["tsx", "scripts/rapid_recovery_telegram_outbound.ts"], cwd) + ); + + summary.steps = steps; + summary.ok = steps.every((step) => !step.error); + + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/scripts/rapid_recovery_kpi_report.ts b/scripts/rapid_recovery_kpi_report.ts new file mode 100644 index 0000000..11c502c --- /dev/null +++ b/scripts/rapid_recovery_kpi_report.ts @@ -0,0 +1,425 @@ +#!/usr/bin/env npx tsx + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { + calculateUpsellConversion, + isExternalClient, + offeringConversions, + type RevenueJobRecord, +} from "../src/seller/runtime/revenueSprintGuards.js"; + +type CompletedJob = { + id: number; + price?: number; + priceType?: string; + clientAddress?: string; + providerAddress?: string; + phase?: string; + name?: string; + deliverable?: unknown; +}; + +type JobDetailMemo = { + nextPhase?: string; + createdAt?: string; +}; + +type JobDetailResponse = { + id: number; + memos?: JobDetailMemo[]; +}; + +type LeadBountyLedgerRow = { + dateKey?: string; + spentUsdc?: number; + created?: boolean; + selected?: boolean; +}; + +type CliOptions = { + windowHours: number; + outputJson?: string; + outputCsv?: string; +}; + +const DEFAULT_API_URL = process.env.ACP_API_URL || "https://claw-api.virtuals.io"; +const COMPLETED_PAGE_SIZE = 100; +const DEFAULT_INTERNAL_WALLETS = [ + "0xbB8aAB015De360f01a25373be320A73cD19f319E", // delivery-orchestrator-hub + "0x79d4Cdb36cf5394A2d825F01BFD3a43a6A612Ce7", // virtual-outreach-orchestrator +]; +const LEAD_LEDGER_PATH = path.resolve( + process.cwd(), + "logs", + "rapid_recovery_lead_bounty_ledger.json" +); + +function parseArgs(argv: string[]): CliOptions { + const out: CliOptions = { windowHours: 24 }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--window-hours") { + const next = Number(argv[i + 1]); + if (!Number.isFinite(next) || next <= 0) { + throw new Error("--window-hours must be a positive number"); + } + out.windowHours = next; + i += 1; + continue; + } + if (token === "--output-json") { + const next = argv[i + 1]; + if (!next) throw new Error("--output-json requires a path"); + out.outputJson = path.resolve(process.cwd(), next); + i += 1; + continue; + } + if (token === "--output-csv") { + const next = argv[i + 1]; + if (!next) throw new Error("--output-csv requires a path"); + out.outputCsv = path.resolve(process.cwd(), next); + i += 1; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + return out; +} + +function readConfigKey(): string { + if (process.env.LITE_AGENT_API_KEY?.trim()) return process.env.LITE_AGENT_API_KEY.trim(); + + const configPath = path.resolve(process.cwd(), "config.json"); + if (!fs.existsSync(configPath)) { + throw new Error("LITE_AGENT_API_KEY missing and config.json not found"); + } + const raw = JSON.parse(fs.readFileSync(configPath, "utf8")); + const key = String(raw?.LITE_AGENT_API_KEY || "").trim(); + if (!key) throw new Error("LITE_AGENT_API_KEY is missing"); + return key; +} + +async function apiGet(apiKey: string, endpoint: string): Promise { + const res = await fetch(`${DEFAULT_API_URL}${endpoint}`, { + headers: { "x-api-key": apiKey }, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`ACP API ${endpoint} failed: ${res.status} ${body.slice(0, 200)}`); + } + const json = (await res.json()) as { data?: T }; + return (json.data ?? json) as T; +} + +async function listCompletedJobs(apiKey: string): Promise { + const rows: CompletedJob[] = []; + for (let page = 1; page <= 50; page += 1) { + const batch = await apiGet( + apiKey, + `/acp/jobs/completed?page=${page}&pageSize=${COMPLETED_PAGE_SIZE}` + ); + if (!Array.isArray(batch) || batch.length === 0) break; + rows.push(...batch); + if (batch.length < COMPLETED_PAGE_SIZE) break; + } + return rows; +} + +function lowerWallet(address: string): string { + return String(address || "") + .trim() + .toLowerCase(); +} + +function parseInternalWallets(providerWallet: string): Set { + const envWallets = String( + process.env.INTERNAL_WALLETS || process.env.RAPID_INTERNAL_WALLETS || "" + ) + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + const wallets = new Set( + [...DEFAULT_INTERNAL_WALLETS, ...envWallets, providerWallet] + .map((wallet) => lowerWallet(wallet)) + .filter(Boolean) + ); + return wallets; +} + +function getMemoTimestamp(memos: JobDetailMemo[] | undefined, phase: string): string | undefined { + if (!Array.isArray(memos)) return undefined; + const hits = memos.filter((memo) => String(memo.nextPhase || "").toUpperCase() === phase); + return hits.at(-1)?.createdAt; +} + +function parseDeliverableTier(deliverable: unknown): string | undefined { + if (!deliverable || typeof deliverable !== "object") return undefined; + const root = deliverable as Record; + const value = root.value && typeof root.value === "object" ? root.value : root; + if (value && typeof value.recommended_next_tier === "string") { + return value.recommended_next_tier; + } + return undefined; +} + +function safeNumber(value: unknown): number { + const num = Number(value); + return Number.isFinite(num) ? num : 0; +} + +function dateKeyFromIso(iso?: string): string | undefined { + if (!iso) return undefined; + const parsed = Date.parse(iso); + if (!Number.isFinite(parsed)) return undefined; + return new Date(parsed).toISOString().slice(0, 10); +} + +function avg(values: number[]): number { + if (values.length === 0) return 0; + return Number((values.reduce((sum, item) => sum + item, 0) / values.length).toFixed(2)); +} + +function readLeadLedger(): LeadBountyLedgerRow[] { + if (!fs.existsSync(LEAD_LEDGER_PATH)) return []; + try { + const parsed = JSON.parse(fs.readFileSync(LEAD_LEDGER_PATH, "utf8")); + return Array.isArray(parsed) ? (parsed as LeadBountyLedgerRow[]) : []; + } catch { + return []; + } +} + +function toCsv(rows: Array>): string { + if (rows.length === 0) + return "date,external_jobs,external_usdc,internal_jobs,internal_usdc,lead_spend_usdc\n"; + const columns = Object.keys(rows[0]); + const escaped = (value: unknown) => { + const raw = String(value ?? ""); + if (raw.includes(",") || raw.includes('"') || raw.includes("\n")) { + return `"${raw.replace(/"/g, '""')}"`; + } + return raw; + }; + const lines = [columns.join(",")]; + for (const row of rows) { + lines.push(columns.map((column) => escaped(row[column])).join(",")); + } + return `${lines.join("\n")}\n`; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const apiKey = readConfigKey(); + + const me = await apiGet<{ name: string; walletAddress: string }>(apiKey, "/acp/me"); + const providerWallet = lowerWallet(me.walletAddress); + const internalWallets = parseInternalWallets(providerWallet); + + const completed = await listCompletedJobs(apiKey); + const providerJobs = completed.filter( + (job) => + String(job.phase || "").toUpperCase() === "COMPLETED" && + lowerWallet(job.providerAddress || "") === providerWallet + ); + + const details = await Promise.all( + providerJobs.map(async (job) => { + const detail = await apiGet(apiKey, `/acp/jobs/${job.id}`); + const completedAt = getMemoTimestamp(detail.memos, "COMPLETED"); + const transactionAt = getMemoTimestamp(detail.memos, "TRANSACTION"); + const processingSeconds = + completedAt && transactionAt + ? Math.max(0, Math.round((Date.parse(completedAt) - Date.parse(transactionAt)) / 1000)) + : null; + return { + ...job, + completedAt, + processingSeconds, + }; + }) + ); + + const jobs: RevenueJobRecord[] = details + .map((job) => ({ + id: job.id, + offeringName: String(job.name || ""), + clientAddress: String(job.clientAddress || ""), + priceUsdc: safeNumber(job.price), + completedAtIso: job.completedAt, + recommendedNextTier: parseDeliverableTier(job.deliverable), + })) + .filter((job) => Boolean(job.completedAtIso)); + + const externalJobs = jobs.filter((job) => isExternalClient(job.clientAddress, internalWallets)); + const internalJobs = jobs.filter((job) => !isExternalClient(job.clientAddress, internalWallets)); + + const now = Date.now(); + const startWindow = now - options.windowHours * 60 * 60 * 1000; + const start24h = now - 24 * 60 * 60 * 1000; + const start7d = now - 7 * 24 * 60 * 60 * 1000; + + const inWindowExternal = externalJobs.filter( + (job) => Date.parse(job.completedAtIso || "") >= startWindow + ); + const in24hExternal = externalJobs.filter( + (job) => Date.parse(job.completedAtIso || "") >= start24h + ); + const in7dExternal = externalJobs.filter( + (job) => Date.parse(job.completedAtIso || "") >= start7d + ); + const in24hInternal = internalJobs.filter( + (job) => Date.parse(job.completedAtIso || "") >= start24h + ); + + const externalProcessingTimes = details + .filter( + (job) => + isExternalClient(String(job.clientAddress || ""), internalWallets) && + Number.isFinite(Date.parse(job.completedAt || "")) && + Date.parse(job.completedAt || "") >= start24h && + Number.isFinite(Number(job.processingSeconds)) + ) + .map((job) => Number(job.processingSeconds)); + + const offering24h = offeringConversions(in24hExternal, start24h); + const offering7d = offeringConversions(in7dExternal, start7d); + const upsell24h = calculateUpsellConversion(in24hExternal, start24h); + const upsell7d = calculateUpsellConversion(in7dExternal, start7d); + + const representativeCase = [...in24hExternal] + .sort( + (a, b) => + b.priceUsdc - a.priceUsdc || + String(b.completedAtIso).localeCompare(String(a.completedAtIso)) + ) + .at(0); + + const leadLedger = readLeadLedger(); + const dayRows = new Map< + string, + { + date: string; + external_jobs: number; + external_usdc: number; + internal_jobs: number; + internal_usdc: number; + lead_spend_usdc: number; + lead_bounties_created: number; + lead_auto_selected: number; + } + >(); + + for (let i = 0; i < 7; i += 1) { + const ts = new Date(now - i * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + dayRows.set(ts, { + date: ts, + external_jobs: 0, + external_usdc: 0, + internal_jobs: 0, + internal_usdc: 0, + lead_spend_usdc: 0, + lead_bounties_created: 0, + lead_auto_selected: 0, + }); + } + + for (const job of externalJobs) { + const key = dateKeyFromIso(job.completedAtIso); + if (!key || !dayRows.has(key)) continue; + const row = dayRows.get(key)!; + row.external_jobs += 1; + row.external_usdc += job.priceUsdc; + } + for (const job of internalJobs) { + const key = dateKeyFromIso(job.completedAtIso); + if (!key || !dayRows.has(key)) continue; + const row = dayRows.get(key)!; + row.internal_jobs += 1; + row.internal_usdc += job.priceUsdc; + } + + for (const entry of leadLedger) { + const key = String(entry.dateKey || ""); + if (!dayRows.has(key)) continue; + const row = dayRows.get(key)!; + row.lead_spend_usdc += safeNumber(entry.spentUsdc); + if (entry.created) row.lead_bounties_created += 1; + if (entry.selected) row.lead_auto_selected += 1; + } + + const daily = [...dayRows.values()] + .map((row) => ({ + ...row, + external_usdc: Number(row.external_usdc.toFixed(6)), + internal_usdc: Number(row.internal_usdc.toFixed(6)), + lead_spend_usdc: Number(row.lead_spend_usdc.toFixed(6)), + })) + .sort((a, b) => a.date.localeCompare(b.date)); + + const report = { + generatedAt: new Date().toISOString(), + windowHours: options.windowHours, + agent: { + name: me.name, + walletAddress: me.walletAddress, + }, + internalWallets: [...internalWallets], + external_jobs_window: inWindowExternal.length, + external_usdc_window: Number( + inWindowExternal.reduce((sum, job) => sum + job.priceUsdc, 0).toFixed(6) + ), + external_jobs_24h: in24hExternal.length, + external_usdc_24h: Number( + in24hExternal.reduce((sum, job) => sum + job.priceUsdc, 0).toFixed(6) + ), + external_jobs_7d: in7dExternal.length, + external_usdc_7d: Number(in7dExternal.reduce((sum, job) => sum + job.priceUsdc, 0).toFixed(6)), + avg_processing_seconds_24h: avg(externalProcessingTimes), + representative_success_case_24h: representativeCase + ? { + jobId: representativeCase.id, + offering: representativeCase.offeringName, + priceUsdc: representativeCase.priceUsdc, + recommendedNextTier: representativeCase.recommendedNextTier || "none", + } + : null, + offering_conversion_24h: offering24h, + offering_conversion_7d: offering7d, + upsell_conversion_24h: upsell24h, + upsell_conversion_7d: upsell7d, + lead_cost_performance_24h: { + spend_usdc: Number( + daily + .filter((row) => row.date === new Date(now).toISOString().slice(0, 10)) + .reduce((sum, row) => sum + row.lead_spend_usdc, 0) + .toFixed(6) + ), + bounties_created: daily + .filter((row) => row.date === new Date(now).toISOString().slice(0, 10)) + .reduce((sum, row) => sum + row.lead_bounties_created, 0), + auto_selected: daily + .filter((row) => row.date === new Date(now).toISOString().slice(0, 10)) + .reduce((sum, row) => sum + row.lead_auto_selected, 0), + }, + daily, + }; + + if (options.outputJson) { + fs.mkdirSync(path.dirname(options.outputJson), { recursive: true }); + fs.writeFileSync(options.outputJson, `${JSON.stringify(report, null, 2)}\n`); + } + + if (options.outputCsv) { + fs.mkdirSync(path.dirname(options.outputCsv), { recursive: true }); + fs.writeFileSync(options.outputCsv, toCsv(daily)); + } + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/scripts/rapid_recovery_lead_bounty_loop.ts b/scripts/rapid_recovery_lead_bounty_loop.ts new file mode 100644 index 0000000..9680717 --- /dev/null +++ b/scripts/rapid_recovery_lead_bounty_loop.ts @@ -0,0 +1,511 @@ +#!/usr/bin/env npx tsx + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { decideLeadBountyActivation } from "../src/seller/runtime/revenueSprintGuards.js"; + +type CliOptions = { + dailyBudgetCapUsdc: number; + maxDailyBounties: number; + autoSelectPriceCapUsdc: number; + dryRun: boolean; +}; + +type Candidate = Record; + +type LedgerRow = { + at: string; + dateKey: string; + created: boolean; + selected: boolean; + bountyId?: string; + candidateId?: number; + spentUsdc: number; + reason?: string; + halted?: boolean; +}; + +type KpiReport = { + daily?: Array<{ date: string; external_jobs: number }>; +}; + +const ACP_API_URL = process.env.ACP_API_URL || "https://claw-api.virtuals.io"; +const BOUNTY_API_URL = process.env.ACP_BOUNTY_API_URL || "https://bounty.virtuals.io/api/v1"; +const LEDGER_PATH = path.resolve(process.cwd(), "logs", "rapid_recovery_lead_bounty_ledger.json"); +const HALT_STATE_PATH = path.resolve( + process.cwd(), + "logs", + "rapid_recovery_lead_bounty_state.json" +); +const DEMAND_KEYWORDS = [ + "timeout", + "validation", + "rejected", + "retry payload", + "recovery", + "hotfix", +]; + +function parseArgs(argv: string[]): CliOptions { + const out: CliOptions = { + dailyBudgetCapUsdc: 0.1, + maxDailyBounties: 1, + autoSelectPriceCapUsdc: 0.1, + dryRun: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--daily-budget-cap") { + const next = Number(argv[i + 1]); + if (!Number.isFinite(next) || next <= 0) throw new Error("--daily-budget-cap must be > 0"); + out.dailyBudgetCapUsdc = next; + i += 1; + continue; + } + if (token === "--max-daily-bounties") { + const next = Number(argv[i + 1]); + if (!Number.isFinite(next) || next <= 0) throw new Error("--max-daily-bounties must be > 0"); + out.maxDailyBounties = Math.round(next); + i += 1; + continue; + } + if (token === "--auto-select-price-cap") { + const next = Number(argv[i + 1]); + if (!Number.isFinite(next) || next <= 0) + throw new Error("--auto-select-price-cap must be > 0"); + out.autoSelectPriceCapUsdc = next; + i += 1; + continue; + } + if (token === "--dry-run") { + out.dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + return out; +} + +function readApiKey(): string { + if (process.env.LITE_AGENT_API_KEY?.trim()) return process.env.LITE_AGENT_API_KEY.trim(); + const configPath = path.resolve(process.cwd(), "config.json"); + if (!fs.existsSync(configPath)) throw new Error("LITE_AGENT_API_KEY missing"); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + const key = String(config?.LITE_AGENT_API_KEY || "").trim(); + if (!key) throw new Error("LITE_AGENT_API_KEY missing"); + return key; +} + +function readJsonFile(filePath: string, fallback: T): T { + if (!fs.existsSync(filePath)) return fallback; + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; + } catch { + return fallback; + } +} + +function writeJsonFile(filePath: string, data: unknown) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`); +} + +function todayKey(now = new Date()): string { + return now.toISOString().slice(0, 10); +} + +function yesterdayKey(now = new Date()): string { + return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10); +} + +function loadKpiDaily(): KpiReport { + const output = execFileSync( + "npx", + ["tsx", "scripts/rapid_recovery_kpi_report.ts", "--window-hours", "72"], + { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + } + ); + return JSON.parse(output) as KpiReport; +} + +function scoreCandidateRelevance(candidate: Candidate): number { + const haystack = JSON.stringify(candidate).toLowerCase(); + let score = 0; + for (const keyword of DEMAND_KEYWORDS) { + if (haystack.includes(keyword.toLowerCase())) score += 1; + } + return score; +} + +function candidatePrice(candidate: Candidate): number { + const raw = + candidate.price ?? + candidate.job_offering_price ?? + candidate.jobOfferingPrice ?? + candidate.jobFee; + const num = Number(raw); + return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY; +} + +function candidateField(candidate: Candidate, fields: string[]): string { + for (const field of fields) { + const value = candidate[field]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +async function apiPostJson( + url: string, + apiKey: string, + payload: unknown +): Promise> { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify(payload), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`POST ${url} failed: ${response.status} ${text.slice(0, 300)}`); + } + return JSON.parse(text); +} + +async function apiGetJson(url: string, apiKey: string): Promise> { + const response = await fetch(url, { + headers: { "x-api-key": apiKey }, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`GET ${url} failed: ${response.status} ${text.slice(0, 300)}`); + } + return JSON.parse(text); +} + +function appendLedger(entry: LedgerRow) { + const rows = readJsonFile(LEDGER_PATH, []); + rows.push(entry); + writeJsonFile(LEDGER_PATH, rows.slice(-1000)); +} + +function setHalt(reason: string) { + writeJsonFile(HALT_STATE_PATH, { + halted: true, + reason, + at: new Date().toISOString(), + }); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const nowIso = new Date().toISOString(); + const dayKey = todayKey(); + const previousDay = yesterdayKey(); + const apiKey = readApiKey(); + const me = await apiGetJson(`${ACP_API_URL}/acp/me`, apiKey); + const myWallet = String((me?.data ?? me)?.walletAddress || "").toLowerCase(); + + const haltState = readJsonFile<{ halted?: boolean; reason?: string }>(HALT_STATE_PATH, {}); + if (haltState.halted) { + process.stdout.write( + `${JSON.stringify({ skipped: true, reason: `halted: ${haltState.reason || "unknown"}` }, null, 2)}\n` + ); + return; + } + + const kpi = loadKpiDaily(); + const yesterdayExternalJobs = + kpi.daily?.find((row) => row.date === previousDay)?.external_jobs ?? 0; + + const ledger = readJsonFile(LEDGER_PATH, []); + const todayRows = ledger.filter((row) => row.dateKey === dayKey); + const dailyBudgetUsedUsdc = Number( + todayRows.reduce((sum, row) => sum + Number(row.spentUsdc || 0), 0).toFixed(6) + ); + const dailyCreatedCount = todayRows.filter((row) => row.created).length; + + const decision = decideLeadBountyActivation({ + yesterdayExternalJobs, + dailyBudgetUsedUsdc, + dailyBudgetCapUsdc: options.dailyBudgetCapUsdc, + dailyCreatedCount, + maxDailyBounties: options.maxDailyBounties, + }); + + if (!decision.enabled) { + appendLedger({ + at: nowIso, + dateKey: dayKey, + created: false, + selected: false, + spentUsdc: 0, + reason: decision.reason, + }); + process.stdout.write( + `${JSON.stringify( + { + skipped: true, + reason: decision.reason, + yesterdayExternalJobs, + dailyBudgetUsedUsdc, + }, + null, + 2 + )}\n` + ); + return; + } + + const remainingCap = Math.max(0, options.dailyBudgetCapUsdc - dailyBudgetUsedUsdc); + const budget = Number(Math.min(remainingCap, options.autoSelectPriceCapUsdc).toFixed(6)); + if (budget <= 0) { + appendLedger({ + at: nowIso, + dateKey: dayKey, + created: false, + selected: false, + spentUsdc: 0, + reason: "remaining budget is zero", + }); + process.stdout.write( + `${JSON.stringify({ skipped: true, reason: "remaining budget is zero" }, null, 2)}\n` + ); + return; + } + + const bountyPayload = { + title: "ACP ops demand phrase hunt (timeout/validation/rejected/retry payload)", + description: + "Collect high-performing demand phrases and failure patterns from ACP seller ops teams. Return concise patterns for timeout/validation/rejected/retry payload issues.", + budget, + category: "digital", + tags: "acp,ops,recovery,timeout,validation,rejected,retry payload", + }; + + if (options.dryRun) { + process.stdout.write( + `${JSON.stringify( + { + dryRun: true, + action: "create-bounty-and-auto-select", + bountyPayload, + budget, + remainingCap, + }, + null, + 2 + )}\n` + ); + return; + } + + const created = await apiPostJson(`${BOUNTY_API_URL}/bounties/`, apiKey, bountyPayload); + const createBody = created?.data ?? created; + const bountyNode = + createBody?.bounty && typeof createBody.bounty === "object" ? createBody.bounty : createBody; + const bountyId = String( + bountyNode?.id ?? bountyNode?.bounty_id ?? bountyNode?.bountyId ?? createBody?.id ?? "" + ); + const posterSecret = String( + createBody?.poster_secret ?? + createBody?.posterSecret ?? + createBody?.data?.poster_secret ?? + createBody?.data?.posterSecret ?? + "" + ); + + if (!bountyId || !posterSecret) { + setHalt("bounty create response missing id/poster_secret"); + appendLedger({ + at: nowIso, + dateKey: dayKey, + created: false, + selected: false, + spentUsdc: 0, + reason: "invalid bounty create response", + halted: true, + }); + throw new Error("Invalid bounty create response"); + } + + const matchStatusRaw = await apiGetJson( + `${BOUNTY_API_URL}/bounties/${encodeURIComponent(bountyId)}/match-status`, + apiKey + ); + const matchStatus = matchStatusRaw?.data ?? matchStatusRaw; + const candidates = Array.isArray(matchStatus?.candidates) + ? (matchStatus.candidates as Candidate[]) + : []; + + const candidateCap = Math.min(options.autoSelectPriceCapUsdc, remainingCap); + const relevantCandidates = candidates + .map((candidate) => { + const wallet = candidateField(candidate, [ + "agent_wallet", + "agentWallet", + "agent_wallet_address", + "agentWalletAddress", + "walletAddress", + "providerWalletAddress", + "provider_address", + ]).toLowerCase(); + return { + candidate, + relevance: scoreCandidateRelevance(candidate), + price: candidatePrice(candidate), + wallet, + }; + }) + .filter( + (row) => + Number.isFinite(row.price) && + row.price <= candidateCap && + row.relevance > 0 && + row.wallet && + row.wallet !== myWallet + ) + .sort((a, b) => b.relevance - a.relevance || a.price - b.price); + + let selected = false; + let selectedCandidateId: number | undefined; + let spentUsdc = 0; + + if (relevantCandidates.length > 0) { + const best = relevantCandidates[0]; + const candidate = best.candidate; + const candidateId = Number(candidate.id); + const wallet = candidateField(candidate, [ + "agent_wallet", + "agentWallet", + "agent_wallet_address", + "agentWalletAddress", + "walletAddress", + "providerWalletAddress", + "provider_address", + ]); + const offering = candidateField(candidate, [ + "job_offering", + "jobOffering", + "offeringName", + "jobOfferingName", + "offering_name", + "name", + ]); + + if (!wallet || !offering || !Number.isFinite(candidateId)) { + setHalt("relevant candidate missing wallet/offering/id"); + appendLedger({ + at: nowIso, + dateKey: dayKey, + created: true, + selected: false, + bountyId, + spentUsdc: 0, + reason: "candidate missing required fields", + halted: true, + }); + throw new Error("Candidate missing required fields for auto-select"); + } + + try { + const jobResponse = await apiPostJson(`${ACP_API_URL}/acp/jobs`, apiKey, { + providerWalletAddress: wallet, + jobOfferingName: offering, + serviceRequirements: { + goal: "Collect demand phrases for timeout/validation/rejected/retry payload offers", + output_format: "top 5 phrases + top 5 pain patterns + top 3 objection lines", + }, + }); + const jobData = jobResponse?.data ?? jobResponse; + const acpJobId = String(jobData?.data?.jobId ?? jobData?.jobId ?? ""); + if (!acpJobId) { + throw new Error("failed to create ACP job for selected candidate"); + } + + await apiPostJson( + `${BOUNTY_API_URL}/bounties/${encodeURIComponent(bountyId)}/confirm-match`, + apiKey, + { + poster_secret: posterSecret, + candidate_id: candidateId, + acp_job_id: acpJobId, + } + ); + + selected = true; + selectedCandidateId = candidateId; + spentUsdc = Number(best.price.toFixed(6)); + } catch (error) { + setHalt("auto-select job creation failed"); + appendLedger({ + at: nowIso, + dateKey: dayKey, + created: true, + selected: false, + bountyId, + spentUsdc: 0, + reason: + error instanceof Error + ? `auto-select failed: ${error.message}` + : "auto-select failed: unknown error", + halted: true, + }); + process.stdout.write( + `${JSON.stringify( + { + created: true, + selected: false, + bountyId, + halted: true, + reason: error instanceof Error ? error.message : String(error), + }, + null, + 2 + )}\n` + ); + return; + } + } + + const row: LedgerRow = { + at: nowIso, + dateKey: dayKey, + created: true, + selected, + bountyId, + candidateId: selectedCandidateId, + spentUsdc, + reason: selected ? "auto-selected" : "no relevant candidate within cap", + }; + appendLedger(row); + + process.stdout.write( + `${JSON.stringify( + { + created: true, + selected, + bountyId, + candidateId: selectedCandidateId ?? null, + spentUsdc, + budgetCap: options.dailyBudgetCapUsdc, + remainingCap, + }, + null, + 2 + )}\n` + ); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/scripts/rapid_recovery_profile_daily_update.ts b/scripts/rapid_recovery_profile_daily_update.ts new file mode 100644 index 0000000..e43b25d --- /dev/null +++ b/scripts/rapid_recovery_profile_daily_update.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env npx tsx + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +type KpiReport = { + external_jobs_24h?: number; + avg_processing_seconds_24h?: number; + representative_success_case_24h?: { + offering?: string; + priceUsdc?: number; + recommendedNextTier?: string; + } | null; +}; + +type CliOptions = { + kpiJsonPath?: string; + dryRun: boolean; +}; + +const DEFAULT_API_URL = process.env.ACP_API_URL || "https://claw-api.virtuals.io"; +const LOG_PATH = path.resolve(process.cwd(), "logs", "rapid_recovery_profile_updates.jsonl"); + +function parseArgs(argv: string[]): CliOptions { + const out: CliOptions = { dryRun: false }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--kpi-json") { + const next = argv[i + 1]; + if (!next) throw new Error("--kpi-json requires a file path"); + out.kpiJsonPath = path.resolve(process.cwd(), next); + i += 1; + continue; + } + if (token === "--dry-run") { + out.dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + return out; +} + +function readApiKey(): string { + if (process.env.LITE_AGENT_API_KEY?.trim()) return process.env.LITE_AGENT_API_KEY.trim(); + const configPath = path.resolve(process.cwd(), "config.json"); + if (!fs.existsSync(configPath)) { + throw new Error("LITE_AGENT_API_KEY missing and config.json not found"); + } + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + const key = String(config?.LITE_AGENT_API_KEY || "").trim(); + if (!key) throw new Error("LITE_AGENT_API_KEY is missing"); + return key; +} + +function loadKpi(options: CliOptions): KpiReport { + if (options.kpiJsonPath) { + const raw = JSON.parse(fs.readFileSync(options.kpiJsonPath, "utf8")); + return raw as KpiReport; + } + const output = execFileSync( + "npx", + ["tsx", "scripts/rapid_recovery_kpi_report.ts", "--window-hours", "24"], + { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + } + ); + return JSON.parse(output) as KpiReport; +} + +function buildDescription(kpi: KpiReport): string { + const externalJobs = Number(kpi.external_jobs_24h || 0); + const avgSeconds = Number(kpi.avg_processing_seconds_24h || 0); + const success = kpi.representative_success_case_24h; + const successLine = success + ? `${success.offering || "unknown"} ${Number(success.priceUsdc || 0).toFixed(2)} USDC / next=${success.recommendedNextTier || "none"}` + : "none"; + + return [ + "timeout | validation | rejected | retry payload", + "입력 1줄 -> 복구결과 3종(JSON) 즉시 반환", + `최근 24h 외부 유료건수: ${externalJobs}건`, + `평균 처리시간: ${avgSeconds}s`, + `대표 성공 케이스: ${successLine}`, + "CTA: 0.02 진입 -> 0.05 Turbo -> 0.12 Guardrail", + ].join("\n"); +} + +async function updateProfileDescription(apiKey: string, description: string) { + const response = await fetch(`${DEFAULT_API_URL}/acp/me`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ description }), + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to update profile: ${response.status} ${body.slice(0, 200)}`); + } + return response.json(); +} + +function appendLog(record: Record) { + fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true }); + fs.appendFileSync(LOG_PATH, `${JSON.stringify(record)}\n`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const kpi = loadKpi(options); + const description = buildDescription(kpi); + const apiKey = readApiKey(); + + if (options.dryRun) { + process.stdout.write(`${JSON.stringify({ dryRun: true, description }, null, 2)}\n`); + return; + } + + const result = await updateProfileDescription(apiKey, description); + appendLog({ + at: new Date().toISOString(), + external_jobs_24h: kpi.external_jobs_24h ?? 0, + avg_processing_seconds_24h: kpi.avg_processing_seconds_24h ?? 0, + representative_success_case_24h: kpi.representative_success_case_24h ?? null, + updated: true, + }); + + process.stdout.write( + `${JSON.stringify( + { + updated: true, + description, + result, + }, + null, + 2 + )}\n` + ); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/scripts/rapid_recovery_telegram_outbound.ts b/scripts/rapid_recovery_telegram_outbound.ts new file mode 100644 index 0000000..6322085 --- /dev/null +++ b/scripts/rapid_recovery_telegram_outbound.ts @@ -0,0 +1,367 @@ +#!/usr/bin/env npx tsx + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { + checkTelegramGlobalStop, + decideTelegramSend, + type TelegramGuardrailPolicy, + type TelegramSendRecord, + type TelegramState, +} from "../src/seller/runtime/revenueSprintGuards.js"; + +type CliOptions = { + targetsFile: string; + dailySendCap: number; + cooldownHours: number; + maxConsecutiveFailures: number; + rejectRateThreshold: number; + rejectRateSampleMin: number; + bannedKeywords: string[]; + dryRun: boolean; +}; + +type TargetRow = { + chatId: string; + name?: string; +}; + +type SendLogRow = { + who: string; + when: string; + template: string; + version: string; + result: string; + reason?: string; +}; + +const DEFAULT_TARGETS = path.resolve(process.cwd(), "data", "rapid_recovery_telegram_targets.json"); +const STATE_PATH = path.resolve(process.cwd(), "logs", "rapid_recovery_telegram_state.json"); +const LOG_PATH = path.resolve(process.cwd(), "logs", "rapid_recovery_telegram_send_log.jsonl"); +const TEMPLATE_ID = "rapid_recovery_outbound_v1"; +const TEMPLATE_VERSION = "2026-03-05"; + +function parseArgs(argv: string[]): CliOptions { + const out: CliOptions = { + targetsFile: DEFAULT_TARGETS, + dailySendCap: Number(process.env.TELEGRAM_DAILY_SEND_CAP || 15), + cooldownHours: Number(process.env.TELEGRAM_TARGET_COOLDOWN_HOURS || 48), + maxConsecutiveFailures: Number(process.env.TELEGRAM_MAX_CONSECUTIVE_FAILURES || 3), + rejectRateThreshold: Number(process.env.TELEGRAM_REJECT_RATE_THRESHOLD || 0.35), + rejectRateSampleMin: Number(process.env.TELEGRAM_REJECT_RATE_SAMPLE_MIN || 6), + bannedKeywords: String( + process.env.TELEGRAM_BANNED_KEYWORDS || "airdrop,free money,gamble,casino,profit guaranteed" + ) + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + dryRun: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--targets-file") { + const next = argv[i + 1]; + if (!next) throw new Error("--targets-file requires a path"); + out.targetsFile = path.resolve(process.cwd(), next); + i += 1; + continue; + } + if (token === "--daily-send-cap") { + out.dailySendCap = Number(argv[i + 1]); + i += 1; + continue; + } + if (token === "--cooldown-hours") { + out.cooldownHours = Number(argv[i + 1]); + i += 1; + continue; + } + if (token === "--max-consecutive-failures") { + out.maxConsecutiveFailures = Number(argv[i + 1]); + i += 1; + continue; + } + if (token === "--reject-rate-threshold") { + out.rejectRateThreshold = Number(argv[i + 1]); + i += 1; + continue; + } + if (token === "--reject-rate-sample-min") { + out.rejectRateSampleMin = Number(argv[i + 1]); + i += 1; + continue; + } + if (token === "--banned-keywords") { + out.bannedKeywords = String(argv[i + 1] || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + i += 1; + continue; + } + if (token === "--dry-run") { + out.dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + + if (!Number.isFinite(out.dailySendCap) || out.dailySendCap <= 0) { + throw new Error("--daily-send-cap must be > 0"); + } + if (!Number.isFinite(out.cooldownHours) || out.cooldownHours < 0) { + throw new Error("--cooldown-hours must be >= 0"); + } + return out; +} + +function readJson(filePath: string, fallback: T): T { + if (!fs.existsSync(filePath)) return fallback; + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; + } catch { + return fallback; + } +} + +function writeJson(filePath: string, data: unknown) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`); +} + +function appendJsonLine(filePath: string, row: Record) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, `${JSON.stringify(row)}\n`); +} + +function loadState(): TelegramState { + return readJson(STATE_PATH, { + halted: false, + consecutiveFailures: 0, + totalAttempts: 0, + totalRejectedSignals: 0, + sendHistory: [], + }); +} + +function statePolicy(options: CliOptions): TelegramGuardrailPolicy { + return { + dailySendCap: options.dailySendCap, + cooldownHoursPerTarget: options.cooldownHours, + maxConsecutiveFailures: options.maxConsecutiveFailures, + rejectRateThreshold: options.rejectRateThreshold, + rejectRateSampleMin: options.rejectRateSampleMin, + bannedKeywords: options.bannedKeywords, + }; +} + +function buildMessage(target: TargetRow): string { + return [ + `Hi ${target.name || "there"},`, + "timeout/validation/rejected 이슈를 1줄 입력으로 즉시 복구해드립니다.", + "결과: 원인 분류 + retry payload + 실행 next actions(JSON).", + "CTA: 0.02 진입(Hotfix) -> 0.05 Turbo -> 0.12 Guardrail", + ].join("\n"); +} + +function hashMessage(message: string): string { + return crypto.createHash("sha256").update(message).digest("hex"); +} + +function isRejectionSignal(rawText: string): boolean { + const text = rawText.toLowerCase(); + return ( + text.includes("blocked by the user") || + text.includes("user is deactivated") || + text.includes("too many requests") || + text.includes("spam") || + text.includes("forbidden") + ); +} + +async function sendTelegramMessage( + token: string, + chatId: string, + message: string +): Promise<{ ok: boolean; reason?: string; rejectedSignal?: boolean }> { + const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: chatId, + text: message, + disable_web_page_preview: true, + }), + }); + + const body = await response.text(); + if (!response.ok) { + return { + ok: false, + reason: `${response.status} ${body.slice(0, 200)}`, + rejectedSignal: isRejectionSignal(body), + }; + } + + try { + const parsed = JSON.parse(body) as { ok?: boolean; description?: string }; + if (parsed.ok === false) { + const reason = parsed.description || "telegram api rejected"; + return { ok: false, reason, rejectedSignal: isRejectionSignal(reason) }; + } + } catch { + // best effort + } + + return { ok: true }; +} + +function updateStateAfterSend(state: TelegramState, record: TelegramSendRecord): TelegramState { + const nextHistory = [...state.sendHistory, record].slice(-5000); + const totalAttempts = state.totalAttempts + 1; + const totalRejectedSignals = state.totalRejectedSignals + (record.rejectionSignal ? 1 : 0); + + let consecutiveFailures = 0; + if (record.result === "sent") { + consecutiveFailures = 0; + } else { + consecutiveFailures = state.consecutiveFailures + 1; + } + + return { + ...state, + consecutiveFailures, + totalAttempts, + totalRejectedSignals, + sendHistory: nextHistory, + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const targets = readJson(options.targetsFile, []); + const policy = statePolicy(options); + const token = String(process.env.TELEGRAM_BOT_TOKEN || "").trim(); + + if (!options.dryRun && !token && targets.length > 0) { + throw new Error("TELEGRAM_BOT_TOKEN is required unless --dry-run is used"); + } + + let state = loadState(); + const logs: SendLogRow[] = []; + + for (const target of targets) { + const nowIso = new Date().toISOString(); + const global = checkTelegramGlobalStop(state, policy); + if (!global.allowed) { + state = { + ...state, + halted: global.shouldHaltNow || state.halted, + haltReason: global.reason || state.haltReason, + }; + logs.push({ + who: target.chatId, + when: nowIso, + template: TEMPLATE_ID, + version: TEMPLATE_VERSION, + result: "blocked", + reason: global.reason, + }); + break; + } + + const message = buildMessage(target); + const messageHash = hashMessage(message); + const precheck = decideTelegramSend(state, policy, { + chatId: target.chatId, + messageText: message, + messageHash, + nowIso, + }); + if (!precheck.allowed) { + logs.push({ + who: target.chatId, + when: nowIso, + template: TEMPLATE_ID, + version: TEMPLATE_VERSION, + result: "skipped", + reason: precheck.reason, + }); + if (precheck.shouldHaltNow) { + state = { + ...state, + halted: true, + haltReason: precheck.reason, + }; + break; + } + continue; + } + + let sendResult: { ok: boolean; reason?: string; rejectedSignal?: boolean }; + if (options.dryRun) { + sendResult = { ok: true }; + } else { + sendResult = await sendTelegramMessage(token, target.chatId, message); + } + + const record: TelegramSendRecord = { + chatId: target.chatId, + whenIso: nowIso, + messageHash, + result: sendResult.ok ? "sent" : sendResult.rejectedSignal ? "rejected" : "failed", + rejectionSignal: Boolean(sendResult.rejectedSignal), + }; + state = updateStateAfterSend(state, record); + + logs.push({ + who: target.chatId, + when: nowIso, + template: TEMPLATE_ID, + version: TEMPLATE_VERSION, + result: sendResult.ok ? (options.dryRun ? "dry_run_sent" : "sent") : "failed", + reason: sendResult.reason, + }); + + const postGlobal = checkTelegramGlobalStop(state, policy); + if (!postGlobal.allowed && postGlobal.shouldHaltNow) { + state = { + ...state, + halted: true, + haltReason: postGlobal.reason || "guardrail stop", + }; + break; + } + } + + writeJson(STATE_PATH, state); + for (const row of logs) { + appendJsonLine(LOG_PATH, row as Record); + } + + process.stdout.write( + `${JSON.stringify( + { + processedTargets: targets.length, + logs, + finalState: { + halted: state.halted, + haltReason: state.haltReason || null, + consecutiveFailures: state.consecutiveFailures, + totalAttempts: state.totalAttempts, + totalRejectedSignals: state.totalRejectedSignals, + }, + }, + null, + 2 + )}\n` + ); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/src/seller/offerings/rapid-recovery-router/ops_recovery_guardrail_v1/handlers.ts b/src/seller/offerings/rapid-recovery-router/ops_recovery_guardrail_v1/handlers.ts new file mode 100644 index 0000000..f316fdb --- /dev/null +++ b/src/seller/offerings/rapid-recovery-router/ops_recovery_guardrail_v1/handlers.ts @@ -0,0 +1,46 @@ +import type { ExecuteJobResult, ValidationResult } from "../../../runtime/offeringTypes.js"; +import { buildRecoveryPack } from "../../../runtime/openrouterRecovery.js"; +import { + buildRecoveryDeliverable, + normalizeRecoveryRequest, + toRecoveryPackInput, + validateRecoveryRequest, +} from "../../../runtime/recoveryRouterOfferings.js"; + +const OFFERING_NAME = "ops_recovery_guardrail_v1"; + +export async function executeJob(request: any): Promise { + const input = normalizeRecoveryRequest(request); + const recoveryInput = toRecoveryPackInput({ + ...input, + // Guardrail tier focuses on deterministic recovery and incident context. + persona_mode: "completion", + }); + const recoveryPack = await buildRecoveryPack(recoveryInput); + + const enrichedInput = { + ...input, + persona_mode: "completion", + }; + + return { + deliverable: buildRecoveryDeliverable({ + offering: OFFERING_NAME, + tier: "guardrail", + input: enrichedInput, + recovery: recoveryPack, + forcedNextTier: "none", + }), + }; +} + +export function validateRequirements(request: any): ValidationResult { + return validateRecoveryRequest(request, { + requireIncidentContext: true, + allowPersonaMode: false, + }); +} + +export function requestPayment(): string { + return "Guardrail recovery request accepted. Returning incident-safe remediation plan now."; +} diff --git a/src/seller/offerings/rapid-recovery-router/ops_recovery_guardrail_v1/offering.json b/src/seller/offerings/rapid-recovery-router/ops_recovery_guardrail_v1/offering.json new file mode 100644 index 0000000..004cf19 --- /dev/null +++ b/src/seller/offerings/rapid-recovery-router/ops_recovery_guardrail_v1/offering.json @@ -0,0 +1,34 @@ +{ + "name": "ops_recovery_guardrail_v1", + "description": "timeout | validation | rejected | retry payload\n입력 1줄 -> 복구결과 3종(원인 분류, retry payload, 실행 next actions) + incident_context 기반 재발 방지 가드레일을 제공합니다. 0.12 최상위 티어입니다.", + "jobFee": 0.12, + "jobFeeType": "fixed", + "deliverable": "json", + "requiredFunds": false, + "requirement": { + "type": "object", + "required": ["error_text", "incident_context"], + "properties": { + "error_text": { + "type": "string", + "description": "Latest rejection/timeout/validation error text." + }, + "incident_context": { + "type": "string", + "description": "Required. Incident context, constraints, and impact scope." + }, + "failed_payload": { + "type": "string", + "description": "Optional failed request payload or body text." + }, + "target_system": { + "type": "string", + "description": "Optional target (acp/x/browser/api). Default: acp." + }, + "buyer_goal": { + "type": "string", + "description": "Optional one-line buyer outcome to optimize for." + } + } + } +} diff --git a/src/seller/offerings/rapid-recovery-router/ops_recovery_hotfix_openrouter_v1/handlers.ts b/src/seller/offerings/rapid-recovery-router/ops_recovery_hotfix_openrouter_v1/handlers.ts new file mode 100644 index 0000000..1d052c6 --- /dev/null +++ b/src/seller/offerings/rapid-recovery-router/ops_recovery_hotfix_openrouter_v1/handlers.ts @@ -0,0 +1,34 @@ +import type { ExecuteJobResult, ValidationResult } from "../../../runtime/offeringTypes.js"; +import { buildRecoveryPack } from "../../../runtime/openrouterRecovery.js"; +import { + buildRecoveryDeliverable, + normalizeRecoveryRequest, + toRecoveryPackInput, + validateRecoveryRequest, +} from "../../../runtime/recoveryRouterOfferings.js"; + +const OFFERING_NAME = "ops_recovery_hotfix_openrouter_v1"; + +export async function executeJob(request: any): Promise { + const input = normalizeRecoveryRequest(request); + const recoveryInput = toRecoveryPackInput(input); + const recoveryPack = await buildRecoveryPack(recoveryInput); + + return { + deliverable: buildRecoveryDeliverable({ + offering: OFFERING_NAME, + tier: "hotfix", + input, + recovery: recoveryPack, + includeRecommendedNextTier: true, + }), + }; +} + +export function validateRequirements(request: any): ValidationResult { + return validateRecoveryRequest(request, { allowPersonaMode: true }); +} + +export function requestPayment(): string { + return "Ops recovery request accepted. Generating retry-safe hotfix pack now."; +} diff --git a/src/seller/offerings/rapid-recovery-router/ops_recovery_hotfix_openrouter_v1/offering.json b/src/seller/offerings/rapid-recovery-router/ops_recovery_hotfix_openrouter_v1/offering.json new file mode 100644 index 0000000..8506ebd --- /dev/null +++ b/src/seller/offerings/rapid-recovery-router/ops_recovery_hotfix_openrouter_v1/offering.json @@ -0,0 +1,34 @@ +{ + "name": "ops_recovery_hotfix_openrouter_v1", + "description": "timeout | validation | rejected | retry payload\n입력 1줄 -> 복구결과 3종(원인 분류, retry payload, 실행 next actions)으로 바로 반환합니다. 0.02 진입 티어이며 결과에 recommended_next_tier( none|turbo|guardrail )를 포함합니다.", + "jobFee": 0.02, + "jobFeeType": "fixed", + "deliverable": "json", + "requiredFunds": false, + "requirement": { + "type": "object", + "required": ["error_text"], + "properties": { + "error_text": { + "type": "string", + "description": "Latest rejection/timeout/validation error text." + }, + "failed_payload": { + "type": "string", + "description": "Optional failed request payload or body text." + }, + "target_system": { + "type": "string", + "description": "Optional target (acp/x/browser/api). Default: acp." + }, + "persona_mode": { + "type": "string", + "description": "Optional lane preference: price, speed, completion." + }, + "buyer_goal": { + "type": "string", + "description": "Optional one-line buyer outcome to optimize for." + } + } + } +} diff --git a/src/seller/offerings/rapid-recovery-router/ops_recovery_turbo_v1/handlers.ts b/src/seller/offerings/rapid-recovery-router/ops_recovery_turbo_v1/handlers.ts new file mode 100644 index 0000000..404265e --- /dev/null +++ b/src/seller/offerings/rapid-recovery-router/ops_recovery_turbo_v1/handlers.ts @@ -0,0 +1,33 @@ +import type { ExecuteJobResult, ValidationResult } from "../../../runtime/offeringTypes.js"; +import { buildRecoveryPack } from "../../../runtime/openrouterRecovery.js"; +import { + buildRecoveryDeliverable, + normalizeRecoveryRequest, + toRecoveryPackInput, + validateRecoveryRequest, +} from "../../../runtime/recoveryRouterOfferings.js"; + +const OFFERING_NAME = "ops_recovery_turbo_v1"; + +export async function executeJob(request: any): Promise { + const input = normalizeRecoveryRequest(request); + const recoveryInput = toRecoveryPackInput(input); + const recoveryPack = await buildRecoveryPack(recoveryInput); + + return { + deliverable: buildRecoveryDeliverable({ + offering: OFFERING_NAME, + tier: "turbo", + input, + recovery: recoveryPack, + }), + }; +} + +export function validateRequirements(request: any): ValidationResult { + return validateRecoveryRequest(request, { allowPersonaMode: true }); +} + +export function requestPayment(): string { + return "Turbo recovery request accepted. Returning fast retry pack with escalation path."; +} diff --git a/src/seller/offerings/rapid-recovery-router/ops_recovery_turbo_v1/offering.json b/src/seller/offerings/rapid-recovery-router/ops_recovery_turbo_v1/offering.json new file mode 100644 index 0000000..05695f9 --- /dev/null +++ b/src/seller/offerings/rapid-recovery-router/ops_recovery_turbo_v1/offering.json @@ -0,0 +1,34 @@ +{ + "name": "ops_recovery_turbo_v1", + "description": "timeout | validation | rejected | retry payload\n입력 1줄 -> 복구결과 3종(원인 분류, retry payload, 실행 next actions) + turbo 우선 복구 루트와 즉시 재시도 메시지를 제공합니다. 0.05 업셀 티어입니다.", + "jobFee": 0.05, + "jobFeeType": "fixed", + "deliverable": "json", + "requiredFunds": false, + "requirement": { + "type": "object", + "required": ["error_text"], + "properties": { + "error_text": { + "type": "string", + "description": "Latest rejection/timeout/validation error text." + }, + "failed_payload": { + "type": "string", + "description": "Optional failed request payload or body text." + }, + "target_system": { + "type": "string", + "description": "Optional target (acp/x/browser/api). Default: acp." + }, + "persona_mode": { + "type": "string", + "description": "Optional lane preference: price, speed, completion." + }, + "buyer_goal": { + "type": "string", + "description": "Optional one-line buyer outcome to optimize for." + } + } + } +} diff --git a/src/seller/runtime/openrouterRecovery.ts b/src/seller/runtime/openrouterRecovery.ts new file mode 100644 index 0000000..7613807 --- /dev/null +++ b/src/seller/runtime/openrouterRecovery.ts @@ -0,0 +1,278 @@ +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +export interface RecoveryPackInput { + error_text: string; + failed_payload?: string; + target_system?: string; + persona_mode?: string; + buyer_goal?: string; +} + +export interface RecoveryPack { + service: string; + provider: "openrouter" | "fallback"; + model: string; + lane: "budget" | "turbo" | "guardrail"; + classification: "validation" | "timeout" | "rejected" | "unknown"; + summary: string; + retry_payload: Record; + next_actions: string[]; + message_templates: { + buyer_update: string; + internal_note: string; + }; + confidence: number; +} + +type OpenRouterEnv = Record; + +const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const DEFAULT_OPENROUTER_FREE_MODEL = "openrouter/free"; + +function cleanText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function classifyError(errorText: string): RecoveryPack["classification"] { + const lower = errorText.toLowerCase(); + if (lower.includes("required") || lower.includes("validation") || lower.includes("schema")) { + return "validation"; + } + if (lower.includes("timeout") || lower.includes("timed out") || lower.includes("expired")) { + return "timeout"; + } + if (lower.includes("reject") || lower.includes("rejected") || lower.includes("declined")) { + return "rejected"; + } + return "unknown"; +} + +function laneFromPersona( + personaMode: string, + classification: RecoveryPack["classification"] +): RecoveryPack["lane"] { + const persona = personaMode.toLowerCase(); + if (persona === "price") return "budget"; + if (persona === "speed") return "turbo"; + if (persona === "completion") return "guardrail"; + if (classification === "timeout") return "turbo"; + if (classification === "validation") return "guardrail"; + return "budget"; +} + +export function fallbackRecoveryPack(input: RecoveryPackInput): RecoveryPack { + const errorText = cleanText(input.error_text); + const targetSystem = cleanText(input.target_system) || "acp"; + const classification = classifyError(errorText); + const lane = laneFromPersona(cleanText(input.persona_mode), classification); + const retryPayload: Record = { + target_system: targetSystem, + error_text: errorText || "unknown error", + }; + + if (classification === "validation") { + retryPayload.target_agent_name = ""; + } + if (cleanText(input.failed_payload)) { + retryPayload.failed_payload = cleanText(input.failed_payload); + } + + const summaryMap: Record = { + validation: "입력 스키마 누락/불일치가 핵심 원인입니다.", + timeout: "타임아웃/지연으로 트랜잭션이 완료되지 못했습니다.", + rejected: "상대 에이전트 정책 또는 조건 불일치로 거절되었습니다.", + unknown: "원인 신호가 약해 보수적 재시도 절차가 필요합니다.", + }; + + return { + service: "acp-ops-recovery", + provider: "fallback", + model: "rule-based", + lane, + classification, + summary: summaryMap[classification], + retry_payload: retryPayload, + next_actions: [ + "요구 필드(필수값) 확인 후 재시도 payload를 1회 생성합니다.", + "같은 실패가 반복되면 lane을 guardrail로 고정하고 타겟을 교체합니다.", + "재시도 후 5분 내 phase 변화를 확인하고 없으면 즉시 에스컬레이션합니다.", + ], + message_templates: { + buyer_update: `현재 ${classification} 이슈를 복구 중입니다. ${lane} 경로로 재시도 후 결과를 공유드리겠습니다.`, + internal_note: `[${targetSystem}] ${classification} classified -> ${lane} lane retry`, + }, + confidence: classification === "unknown" ? 0.62 : 0.78, + }; +} + +export function isFreeModelId(modelId: string): boolean { + const model = cleanText(modelId).toLowerCase(); + if (!model) return false; + return model === "openrouter/free" || model.endsWith(":free"); +} + +export function resolveOpenRouterModel( + env: OpenRouterEnv = process.env, + modelOverride?: string +): string { + const override = cleanText(modelOverride); + if (isFreeModelId(override)) return override; + const freeModel = cleanText(env.OPENROUTER_FREE_MODEL); + if (isFreeModelId(freeModel)) return freeModel; + return DEFAULT_OPENROUTER_FREE_MODEL; +} + +function resolveOpenRouterBaseUrl(env: OpenRouterEnv = process.env): string { + const configured = cleanText(env.OPENROUTER_BASE_URL); + if (configured) return configured.replace(/\/$/, ""); + return DEFAULT_OPENROUTER_BASE_URL; +} + +export function extractFirstJsonObject(raw: string): Record | null { + const text = cleanText(raw); + if (!text) return null; + + try { + const direct = JSON.parse(text); + if (direct && typeof direct === "object" && !Array.isArray(direct)) return direct; + } catch { + // continue + } + + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced?.[1]) { + try { + const parsed = JSON.parse(fenced[1].trim()); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch { + // continue + } + } + + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + const parsed = JSON.parse(text.slice(start, end + 1)); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch { + return null; + } + } + + return null; +} + +function normalizeOpenRouterPack(candidate: Record, base: RecoveryPack): RecoveryPack { + const classification = classifyError(cleanText(candidate.classification || base.classification)); + const laneCandidate = cleanText(candidate.lane).toLowerCase(); + const lane: RecoveryPack["lane"] = ["budget", "turbo", "guardrail"].includes(laneCandidate) + ? (laneCandidate as RecoveryPack["lane"]) + : laneFromPersona("", classification); + + const retryPayload = + candidate.retry_payload && typeof candidate.retry_payload === "object" + ? (candidate.retry_payload as Record) + : base.retry_payload; + + const actions = Array.isArray(candidate.next_actions) + ? candidate.next_actions.map((item) => String(item).trim()).filter(Boolean) + : base.next_actions; + + return { + ...base, + classification, + lane, + summary: cleanText(candidate.summary) || base.summary, + retry_payload: retryPayload, + next_actions: actions.length > 0 ? actions : base.next_actions, + message_templates: { + buyer_update: + cleanText(candidate?.message_templates?.buyer_update) || + base.message_templates.buyer_update, + internal_note: + cleanText(candidate?.message_templates?.internal_note) || + base.message_templates.internal_note, + }, + confidence: Number.isFinite(Number(candidate.confidence)) + ? Math.max(0, Math.min(1, Number(candidate.confidence))) + : base.confidence, + }; +} + +export async function buildRecoveryPack(input: RecoveryPackInput): Promise { + const fallback = fallbackRecoveryPack(input); + const env: OpenRouterEnv = process.env; + const apiKey = cleanText(env.OPENROUTER_API_KEY); + + if (!apiKey) return fallback; + + const model = resolveOpenRouterModel(env); + const baseUrl = resolveOpenRouterBaseUrl(env); + const endpoint = `${baseUrl}/chat/completions`; + + const requestBody = { + model, + temperature: 0.2, + response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: + "You are an ACP runtime recovery agent. Return strict JSON with keys: classification, lane, summary, retry_payload, next_actions, message_templates, confidence.", + }, + { + role: "user", + content: JSON.stringify({ + input, + fallback_reference: fallback, + constraints: { + classification: ["validation", "timeout", "rejected", "unknown"], + lane: ["budget", "turbo", "guardrail"], + max_actions: 4, + }, + }), + }, + ], + }; + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": cleanText(env.OPENROUTER_SITE_URL) || "https://app.virtuals.io", + "X-Title": cleanText(env.OPENROUTER_APP_NAME) || "acp-ops-recovery-router", + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + return fallback; + } + + const json = (await response.json()) as Record; + const content = cleanText(json?.choices?.[0]?.message?.content); + const parsed = extractFirstJsonObject(content); + if (!parsed) { + return fallback; + } + + const normalized = normalizeOpenRouterPack(parsed, fallback); + return { + ...normalized, + provider: "openrouter", + model, + }; + } catch { + return fallback; + } +} + +export const __testables = { + fallbackRecoveryPack, + isFreeModelId, + resolveOpenRouterModel, + extractFirstJsonObject, +}; diff --git a/src/seller/runtime/recoveryRouterOfferings.ts b/src/seller/runtime/recoveryRouterOfferings.ts new file mode 100644 index 0000000..787d1bb --- /dev/null +++ b/src/seller/runtime/recoveryRouterOfferings.ts @@ -0,0 +1,182 @@ +import type { ValidationResult } from "./offeringTypes.js"; +import type { RecoveryPack, RecoveryPackInput } from "./openrouterRecovery.js"; + +export type RecommendedNextTier = "none" | "turbo" | "guardrail"; +export type RecoveryTier = "hotfix" | "turbo" | "guardrail"; + +export interface RecoveryRequirementInput { + error_text: string; + failed_payload?: string; + target_system?: string; + persona_mode?: string; + buyer_goal?: string; + incident_context?: string; +} + +export interface ValidateRecoveryRequestOptions { + requireIncidentContext?: boolean; + allowPersonaMode?: boolean; +} + +export interface BuildDeliverableInput { + offering: string; + tier: RecoveryTier; + input: RecoveryRequirementInput; + recovery: RecoveryPack; + includeRecommendedNextTier?: boolean; + forcedNextTier?: RecommendedNextTier; +} + +const MAX_ERROR_TEXT = 4000; +const MAX_CONTEXT_TEXT = 8000; +const MAX_PAYLOAD_TEXT = 12000; +const MAX_TARGET_SYSTEM = 120; +const MAX_BUYER_GOAL = 600; +const VALID_PERSONA = new Set(["price", "speed", "completion"]); + +function asRecord(input: unknown): Record { + if (!input || typeof input !== "object" || Array.isArray(input)) return {}; + return input as Record; +} + +function cleanText(input: unknown): string { + return typeof input === "string" ? input.trim() : ""; +} + +function clipNonEmpty>(record: T): Partial { + const out: Partial = {}; + for (const [key, value] of Object.entries(record)) { + if (value === undefined || value === null) continue; + if (typeof value === "string" && value.trim() === "") continue; + out[key as keyof T] = value as T[keyof T]; + } + return out; +} + +export function normalizeRecoveryRequest(request: unknown): RecoveryRequirementInput { + const normalized = asRecord(request); + return { + error_text: cleanText(normalized.error_text), + failed_payload: cleanText(normalized.failed_payload) || undefined, + target_system: cleanText(normalized.target_system) || undefined, + persona_mode: cleanText(normalized.persona_mode).toLowerCase() || undefined, + buyer_goal: cleanText(normalized.buyer_goal) || undefined, + incident_context: cleanText(normalized.incident_context) || undefined, + }; +} + +export function toRecoveryPackInput(input: RecoveryRequirementInput): RecoveryPackInput { + return { + error_text: cleanText(input.error_text), + failed_payload: cleanText(input.failed_payload) || undefined, + target_system: cleanText(input.target_system) || undefined, + persona_mode: cleanText(input.persona_mode) || undefined, + buyer_goal: cleanText(input.buyer_goal) || undefined, + }; +} + +export function validateRecoveryRequest( + request: unknown, + options: ValidateRecoveryRequestOptions = {} +): ValidationResult { + const normalized = normalizeRecoveryRequest(request); + + if (!normalized.error_text) { + return { valid: false, reason: "error_text is required" }; + } + if (normalized.error_text.length > MAX_ERROR_TEXT) { + return { valid: false, reason: `error_text is too long (max ${MAX_ERROR_TEXT})` }; + } + if (normalized.failed_payload && normalized.failed_payload.length > MAX_PAYLOAD_TEXT) { + return { valid: false, reason: `failed_payload is too long (max ${MAX_PAYLOAD_TEXT})` }; + } + if (normalized.target_system && normalized.target_system.length > MAX_TARGET_SYSTEM) { + return { valid: false, reason: `target_system is too long (max ${MAX_TARGET_SYSTEM})` }; + } + if (normalized.buyer_goal && normalized.buyer_goal.length > MAX_BUYER_GOAL) { + return { valid: false, reason: `buyer_goal is too long (max ${MAX_BUYER_GOAL})` }; + } + if (options.requireIncidentContext) { + if (!normalized.incident_context) { + return { valid: false, reason: "incident_context is required" }; + } + if (normalized.incident_context.length > MAX_CONTEXT_TEXT) { + return { valid: false, reason: `incident_context is too long (max ${MAX_CONTEXT_TEXT})` }; + } + } else if (normalized.incident_context && normalized.incident_context.length > MAX_CONTEXT_TEXT) { + return { valid: false, reason: `incident_context is too long (max ${MAX_CONTEXT_TEXT})` }; + } + + const allowPersonaMode = options.allowPersonaMode !== false; + if (!allowPersonaMode && normalized.persona_mode) { + return { valid: false, reason: "persona_mode is not supported for this offering" }; + } + if (normalized.persona_mode && !VALID_PERSONA.has(normalized.persona_mode)) { + return { valid: false, reason: "persona_mode must be one of: price, speed, completion" }; + } + + return { valid: true }; +} + +export function recommendNextTier(recovery: RecoveryPack): RecommendedNextTier { + const confidence = Number(recovery.confidence || 0); + + if (recovery.classification === "validation") return "guardrail"; + + if (recovery.classification === "timeout") { + return confidence >= 0.72 ? "turbo" : "guardrail"; + } + + if (recovery.classification === "rejected") { + if (confidence >= 0.85 && recovery.lane === "budget") return "none"; + return confidence >= 0.65 ? "turbo" : "guardrail"; + } + + if (recovery.classification === "unknown") { + return confidence >= 0.82 ? "turbo" : "guardrail"; + } + + return recovery.lane === "guardrail" ? "guardrail" : recovery.lane === "turbo" ? "turbo" : "none"; +} + +function buildCta(tier: RecoveryTier, recommended: RecommendedNextTier) { + const upsellPath = + tier === "hotfix" + ? ["ops_recovery_turbo_v1", "ops_recovery_guardrail_v1"] + : tier === "turbo" + ? ["ops_recovery_guardrail_v1"] + : []; + + return { + entry_offer: "ops_recovery_hotfix_openrouter_v1", + current_tier: tier, + upsell_path: upsellPath, + recommended_next_tier: recommended, + message: + "0.02 진입 -> 0.05 Turbo -> 0.12 Guardrail. timeout/validation/rejected 케이스는 상위 티어로 즉시 전환 가능합니다.", + }; +} + +export function buildRecoveryDeliverable(input: BuildDeliverableInput): { + type: "json"; + value: any; +} { + const recommended = input.forcedNextTier ?? recommendNextTier(input.recovery); + const value: Record = { + service: "rapid-recovery-router", + offering: input.offering, + input: clipNonEmpty(input.input), + recovery: input.recovery, + next_actions: input.recovery.next_actions, + cta: buildCta(input.tier, recommended), + }; + + if (input.includeRecommendedNextTier) { + value.recommended_next_tier = recommended; + } + + return { + type: "json", + value, + }; +} diff --git a/src/seller/runtime/revenueSprintGuards.ts b/src/seller/runtime/revenueSprintGuards.ts new file mode 100644 index 0000000..1c301ed --- /dev/null +++ b/src/seller/runtime/revenueSprintGuards.ts @@ -0,0 +1,224 @@ +export interface RevenueJobRecord { + id: number | string; + offeringName: string; + clientAddress: string; + priceUsdc: number; + completedAtIso?: string; + recommendedNextTier?: string; +} + +export interface LeadBountyDecisionInput { + yesterdayExternalJobs: number; + dailyBudgetUsedUsdc: number; + dailyBudgetCapUsdc: number; + dailyCreatedCount: number; + maxDailyBounties: number; +} + +export interface LeadBountyDecision { + enabled: boolean; + reason?: string; +} + +export interface UpsellConversionStats { + candidates: number; + conversions: number; + conversionRate: number; +} + +export interface OfferingConversionRow { + offering: string; + jobs: number; + usdc: number; +} + +export interface TelegramGuardrailPolicy { + dailySendCap: number; + cooldownHoursPerTarget: number; + maxConsecutiveFailures: number; + rejectRateThreshold: number; + rejectRateSampleMin: number; + bannedKeywords: string[]; +} + +export interface TelegramSendRecord { + chatId: string; + whenIso: string; + messageHash: string; + result: "sent" | "failed" | "rejected"; + rejectionSignal?: boolean; +} + +export interface TelegramState { + halted: boolean; + haltReason?: string; + consecutiveFailures: number; + totalAttempts: number; + totalRejectedSignals: number; + sendHistory: TelegramSendRecord[]; +} + +export interface TelegramSendDecision { + allowed: boolean; + reason?: string; + shouldHaltNow?: boolean; +} + +function lowerAddress(value: string): string { + return String(value || "") + .trim() + .toLowerCase(); +} + +export function isExternalClient(clientAddress: string, internalWallets: Set): boolean { + const normalized = lowerAddress(clientAddress); + if (!normalized) return false; + return !internalWallets.has(normalized); +} + +export function offeringConversions( + jobs: RevenueJobRecord[], + startTimeMs: number +): OfferingConversionRow[] { + const counters = new Map(); + for (const job of jobs) { + const completedAtMs = Date.parse(job.completedAtIso || ""); + if (!Number.isFinite(completedAtMs) || completedAtMs < startTimeMs) continue; + const offering = job.offeringName || "unknown"; + const row = counters.get(offering) ?? { offering, jobs: 0, usdc: 0 }; + row.jobs += 1; + row.usdc += Number(job.priceUsdc || 0); + counters.set(offering, row); + } + + return [...counters.values()] + .map((row) => ({ ...row, usdc: Number(row.usdc.toFixed(6)) })) + .sort((a, b) => b.jobs - a.jobs || b.usdc - a.usdc); +} + +export function calculateUpsellConversion( + jobs: RevenueJobRecord[], + startTimeMs: number +): UpsellConversionStats { + let candidates = 0; + let conversions = 0; + + for (const job of jobs) { + const completedAtMs = Date.parse(job.completedAtIso || ""); + if (!Number.isFinite(completedAtMs) || completedAtMs < startTimeMs) continue; + + if ( + job.offeringName === "ops_recovery_hotfix_openrouter_v1" && + (job.recommendedNextTier === "turbo" || job.recommendedNextTier === "guardrail") + ) { + candidates += 1; + } + + if ( + job.offeringName === "ops_recovery_turbo_v1" || + job.offeringName === "ops_recovery_guardrail_v1" + ) { + conversions += 1; + } + } + + return { + candidates, + conversions, + conversionRate: candidates > 0 ? Number((conversions / candidates).toFixed(6)) : 0, + }; +} + +export function decideLeadBountyActivation(input: LeadBountyDecisionInput): LeadBountyDecision { + if (input.yesterdayExternalJobs >= 1) { + return { enabled: false, reason: "yesterday external paid jobs >= 1" }; + } + if (input.dailyBudgetUsedUsdc >= input.dailyBudgetCapUsdc) { + return { enabled: false, reason: "daily budget cap reached" }; + } + if (input.dailyCreatedCount >= input.maxDailyBounties) { + return { enabled: false, reason: "daily bounty creation cap reached" }; + } + return { enabled: true }; +} + +export function checkTelegramGlobalStop( + state: TelegramState, + policy: TelegramGuardrailPolicy +): TelegramSendDecision { + if (state.halted) { + return { allowed: false, reason: state.haltReason || "outbound halted" }; + } + if (state.consecutiveFailures >= policy.maxConsecutiveFailures) { + return { + allowed: false, + reason: "max consecutive failures reached", + shouldHaltNow: true, + }; + } + if (state.totalAttempts >= policy.rejectRateSampleMin) { + const rejectRate = state.totalRejectedSignals / Math.max(1, state.totalAttempts); + if (rejectRate > policy.rejectRateThreshold) { + return { + allowed: false, + reason: "reject rate threshold exceeded", + shouldHaltNow: true, + }; + } + } + return { allowed: true }; +} + +export function decideTelegramSend( + state: TelegramState, + policy: TelegramGuardrailPolicy, + candidate: { + chatId: string; + messageText: string; + messageHash: string; + nowIso: string; + } +): TelegramSendDecision { + const global = checkTelegramGlobalStop(state, policy); + if (!global.allowed) return global; + + const now = Date.parse(candidate.nowIso); + if (!Number.isFinite(now)) { + return { allowed: false, reason: "invalid send timestamp" }; + } + + const dayKey = candidate.nowIso.slice(0, 10); + const sentToday = state.sendHistory.filter((row) => row.whenIso.slice(0, 10) === dayKey).length; + if (sentToday >= policy.dailySendCap) { + return { allowed: false, reason: "daily send cap reached" }; + } + + const lowerMessage = candidate.messageText.toLowerCase(); + const blockedKeyword = policy.bannedKeywords.find((word) => + lowerMessage.includes(word.toLowerCase()) + ); + if (blockedKeyword) { + return { allowed: false, reason: `blocked keyword: ${blockedKeyword}` }; + } + + if ( + state.sendHistory.some( + (row) => row.messageHash === candidate.messageHash && row.chatId === candidate.chatId + ) + ) { + return { allowed: false, reason: "duplicate message hash blocked" }; + } + + const cooldownMs = policy.cooldownHoursPerTarget * 60 * 60 * 1000; + const recentTargetSend = [...state.sendHistory] + .reverse() + .find((row) => row.chatId === candidate.chatId); + if (recentTargetSend) { + const lastTs = Date.parse(recentTargetSend.whenIso); + if (Number.isFinite(lastTs) && now - lastTs < cooldownMs) { + return { allowed: false, reason: "target cooldown active" }; + } + } + + return { allowed: true }; +} diff --git a/tests/openrouter_recovery.test.ts b/tests/openrouter_recovery.test.ts new file mode 100644 index 0000000..4d60bf7 --- /dev/null +++ b/tests/openrouter_recovery.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { __testables } from "../src/seller/runtime/openrouterRecovery.js"; + +test("model resolution ignores non-free OPENROUTER_MODEL and uses OPENROUTER_FREE_MODEL", () => { + const model = __testables.resolveOpenRouterModel({ + OPENROUTER_MODEL: "openai/gpt-4.1-mini", + OPENROUTER_FREE_MODEL: "meta-llama/llama-3.1-8b-instruct:free", + }); + + assert.equal(model, "meta-llama/llama-3.1-8b-instruct:free"); +}); + +test("model resolution falls back to OPENROUTER_FREE_MODEL", () => { + const model = __testables.resolveOpenRouterModel({ + OPENROUTER_FREE_MODEL: "meta-llama/llama-3.1-8b-instruct:free", + }); + + assert.equal(model, "meta-llama/llama-3.1-8b-instruct:free"); +}); + +test("model resolution falls back to openrouter/free when configured model is not free", () => { + const model = __testables.resolveOpenRouterModel({ + OPENROUTER_FREE_MODEL: "openai/gpt-4.1-mini", + }); + + assert.equal(model, "openrouter/free"); +}); + +test("free model id detector accepts :free suffix and openrouter/free", () => { + assert.equal(__testables.isFreeModelId("openrouter/free"), true); + assert.equal(__testables.isFreeModelId("google/gemma-3-27b-it:free"), true); + assert.equal(__testables.isFreeModelId("openai/gpt-4.1-mini"), false); +}); + +test("json extraction can parse fenced response", () => { + const parsed = __testables.extractFirstJsonObject(`\n\`\`\`json\n{"a":1,"b":"ok"}\n\`\`\`\n`); + + assert.deepEqual(parsed, { a: 1, b: "ok" }); +}); + +test("fallback pack returns actionable recovery payload", () => { + const pack = __testables.fallbackRecoveryPack({ + error_text: "validation failed: target_agent_name required", + target_system: "acp", + }); + + assert.equal(pack.classification, "validation"); + assert.equal(pack.lane, "guardrail"); + assert.equal(pack.retry_payload.target_agent_name, ""); + assert.equal(Array.isArray(pack.next_actions), true); + assert.equal(pack.next_actions.length > 0, true); +}); diff --git a/tests/recovery_offerings_smoke.test.ts b/tests/recovery_offerings_smoke.test.ts new file mode 100644 index 0000000..bfdc3c4 --- /dev/null +++ b/tests/recovery_offerings_smoke.test.ts @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { executeJob as executeHotfix } from "../src/seller/offerings/rapid-recovery-router/ops_recovery_hotfix_openrouter_v1/handlers.js"; +import { executeJob as executeTurbo } from "../src/seller/offerings/rapid-recovery-router/ops_recovery_turbo_v1/handlers.js"; +import { executeJob as executeGuardrail } from "../src/seller/offerings/rapid-recovery-router/ops_recovery_guardrail_v1/handlers.js"; + +function assertFreeModelIfOpenRouter(deliverable: any) { + const model = String(deliverable?.value?.recovery?.model || ""); + const provider = String(deliverable?.value?.recovery?.provider || ""); + if (provider === "openrouter") { + assert.equal(model === "openrouter/free" || model.endsWith(":free"), true); + } +} + +test("hotfix offering returns standardized JSON with recommended_next_tier", async () => { + const prevKey = process.env.OPENROUTER_API_KEY; + delete process.env.OPENROUTER_API_KEY; + try { + const result = await executeHotfix({ + error_text: "timeout while waiting for response from target", + target_system: "acp", + persona_mode: "speed", + }); + const deliverable = result.deliverable as any; + assert.equal(deliverable.type, "json"); + assert.equal(deliverable.value.offering, "ops_recovery_hotfix_openrouter_v1"); + assert.equal( + ["none", "turbo", "guardrail"].includes(deliverable.value.recommended_next_tier), + true + ); + assert.equal(Array.isArray(deliverable.value.next_actions), true); + assertFreeModelIfOpenRouter(deliverable); + } finally { + if (prevKey) process.env.OPENROUTER_API_KEY = prevKey; + } +}); + +test("turbo offering returns standardized JSON payload", async () => { + const prevKey = process.env.OPENROUTER_API_KEY; + delete process.env.OPENROUTER_API_KEY; + try { + const result = await executeTurbo({ + error_text: "request rejected by remote policy", + failed_payload: '{"job":"retry"}', + target_system: "acp", + }); + const deliverable = result.deliverable as any; + assert.equal(deliverable.type, "json"); + assert.equal(deliverable.value.offering, "ops_recovery_turbo_v1"); + assert.equal(typeof deliverable.value.cta, "object"); + assertFreeModelIfOpenRouter(deliverable); + } finally { + if (prevKey) process.env.OPENROUTER_API_KEY = prevKey; + } +}); + +test("guardrail offering forces completion lane and returns no further upsell", async () => { + const prevKey = process.env.OPENROUTER_API_KEY; + delete process.env.OPENROUTER_API_KEY; + try { + const result = await executeGuardrail({ + error_text: "validation failed: countryCode missing", + incident_context: "high-value order queue, repeated failed runs", + target_system: "acp", + }); + const deliverable = result.deliverable as any; + assert.equal(deliverable.type, "json"); + assert.equal(deliverable.value.offering, "ops_recovery_guardrail_v1"); + assert.equal(deliverable.value.input.persona_mode, "completion"); + assert.equal(deliverable.value.cta.recommended_next_tier, "none"); + assertFreeModelIfOpenRouter(deliverable); + } finally { + if (prevKey) process.env.OPENROUTER_API_KEY = prevKey; + } +}); diff --git a/tests/recovery_router_offerings.test.ts b/tests/recovery_router_offerings.test.ts new file mode 100644 index 0000000..2960410 --- /dev/null +++ b/tests/recovery_router_offerings.test.ts @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + buildRecoveryDeliverable, + recommendNextTier, + validateRecoveryRequest, +} from "../src/seller/runtime/recoveryRouterOfferings.js"; +import { fallbackRecoveryPack } from "../src/seller/runtime/openrouterRecovery.js"; + +test("hotfix validator rejects missing error_text", () => { + const result = validateRecoveryRequest({}, { allowPersonaMode: true }); + assert.equal(typeof result === "object" ? result.valid : result, false); +}); + +test("guardrail validator requires incident_context and blocks persona_mode", () => { + const missingContext = validateRecoveryRequest( + { error_text: "timeout", persona_mode: "speed" }, + { requireIncidentContext: true, allowPersonaMode: false } + ); + assert.equal(typeof missingContext === "object" ? missingContext.valid : missingContext, false); + + const withContextAndPersona = validateRecoveryRequest( + { + error_text: "timeout", + incident_context: "peak traffic incident", + persona_mode: "speed", + }, + { requireIncidentContext: true, allowPersonaMode: false } + ); + assert.equal( + typeof withContextAndPersona === "object" ? withContextAndPersona.valid : withContextAndPersona, + false + ); +}); + +test("recommended_next_tier routes by classification and confidence", () => { + const validationPack = fallbackRecoveryPack({ + error_text: "validation failed: target_agent_name required", + }); + assert.equal(recommendNextTier(validationPack), "guardrail"); + + const timeoutPack = { + ...fallbackRecoveryPack({ error_text: "timeout while waiting for target" }), + confidence: 0.8, + }; + assert.equal(recommendNextTier(timeoutPack), "turbo"); + + const rejectedPack = { + ...fallbackRecoveryPack({ error_text: "request rejected by policy" }), + confidence: 0.92, + lane: "budget" as const, + classification: "rejected" as const, + }; + assert.equal(recommendNextTier(rejectedPack), "none"); +}); + +test("deliverable includes standardized fields and optional recommended_next_tier", () => { + const recovery = fallbackRecoveryPack({ + error_text: "timeout while waiting for target", + target_system: "acp", + }); + const deliverable = buildRecoveryDeliverable({ + offering: "ops_recovery_hotfix_openrouter_v1", + tier: "hotfix", + input: { error_text: "timeout while waiting for target" }, + recovery, + includeRecommendedNextTier: true, + }); + + assert.equal(deliverable.type, "json"); + assert.equal(deliverable.value.service, "rapid-recovery-router"); + assert.equal(typeof deliverable.value.cta, "object"); + assert.equal( + ["none", "turbo", "guardrail"].includes(String(deliverable.value.recommended_next_tier)), + true + ); +}); diff --git a/tests/revenue_sprint_guards.test.ts b/tests/revenue_sprint_guards.test.ts new file mode 100644 index 0000000..2b57df3 --- /dev/null +++ b/tests/revenue_sprint_guards.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + checkTelegramGlobalStop, + decideLeadBountyActivation, + decideTelegramSend, +} from "../src/seller/runtime/revenueSprintGuards.js"; + +test("lead bounty activation runs only when yesterday external jobs are below threshold", () => { + const blocked = decideLeadBountyActivation({ + yesterdayExternalJobs: 1, + dailyBudgetUsedUsdc: 0, + dailyBudgetCapUsdc: 0.1, + dailyCreatedCount: 0, + maxDailyBounties: 1, + }); + assert.equal(blocked.enabled, false); + + const allowed = decideLeadBountyActivation({ + yesterdayExternalJobs: 0, + dailyBudgetUsedUsdc: 0.02, + dailyBudgetCapUsdc: 0.1, + dailyCreatedCount: 0, + maxDailyBounties: 1, + }); + assert.equal(allowed.enabled, true); +}); + +test("telegram pre-send guard blocks banned keywords and target cooldown", () => { + const state = { + halted: false, + consecutiveFailures: 0, + totalAttempts: 1, + totalRejectedSignals: 0, + sendHistory: [ + { + chatId: "1001", + whenIso: "2026-03-05T00:00:00.000Z", + messageHash: "abc", + result: "sent" as const, + }, + ], + }; + const policy = { + dailySendCap: 10, + cooldownHoursPerTarget: 48, + maxConsecutiveFailures: 3, + rejectRateThreshold: 0.5, + rejectRateSampleMin: 6, + bannedKeywords: ["airdrop"], + }; + + const banned = decideTelegramSend(state, policy, { + chatId: "1002", + messageText: "Free airdrop now", + messageHash: "hash-1", + nowIso: "2026-03-05T01:00:00.000Z", + }); + assert.equal(banned.allowed, false); + assert.match(String(banned.reason), /blocked keyword/i); + + const cooldown = decideTelegramSend(state, policy, { + chatId: "1001", + messageText: "Normal recovery CTA", + messageHash: "hash-2", + nowIso: "2026-03-05T02:00:00.000Z", + }); + assert.equal(cooldown.allowed, false); + assert.match(String(cooldown.reason), /cooldown/i); +}); + +test("telegram global stop triggers on consecutive failures and reject-rate overflow", () => { + const policy = { + dailySendCap: 10, + cooldownHoursPerTarget: 24, + maxConsecutiveFailures: 3, + rejectRateThreshold: 0.3, + rejectRateSampleMin: 4, + bannedKeywords: [], + }; + + const consecutiveFailureState = { + halted: false, + consecutiveFailures: 3, + totalAttempts: 3, + totalRejectedSignals: 0, + sendHistory: [], + }; + const failureStop = checkTelegramGlobalStop(consecutiveFailureState, policy); + assert.equal(failureStop.allowed, false); + assert.equal(failureStop.shouldHaltNow, true); + + const rejectRateState = { + halted: false, + consecutiveFailures: 0, + totalAttempts: 10, + totalRejectedSignals: 5, + sendHistory: [], + }; + const rejectStop = checkTelegramGlobalStop(rejectRateState, policy); + assert.equal(rejectStop.allowed, false); + assert.equal(rejectStop.shouldHaltNow, true); +});