diff --git a/.env.example b/.env.example index 9def281..90699ba 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,52 @@ -# ClawOSS Environment — copy to .env and fill in values -# DO NOT COMMIT .env — it is gitignored +# ClawOSS Environment - copy to .env and fill in values +# DO NOT COMMIT .env - it is gitignored # === Required === -# GitHub Personal Access Token — needs public_repo scope at minimum +# GitHub Personal Access Token - needs public_repo scope at minimum GITHUB_TOKEN=ghp_your-token-here -# Kimi Code direct API key (required — OpenRouter is NOT supported due to content filter) -KIMI_API_KEY=sk-kimi-your-key-here +# === LLM Runtime (Option A: single provider, recommended) === +# Cost fields are USD per 1M tokens +CLAWOSS_PROVIDER_ID=openrouter +CLAWOSS_PROVIDER_BASE_URL=https://openrouter.ai/api/v1 +CLAWOSS_PROVIDER_API_FORMAT=openai-completions +CLAWOSS_PROVIDER_AUTH_HEADER=true +CLAWOSS_PROVIDER_API_KEY_ENV=OPENROUTER_API_KEY +OPENROUTER_API_KEY=sk-or-your-key-here + +CLAWOSS_MODEL_ID=moonshotai/kimi-k2.5 +CLAWOSS_MODEL_NAME=Kimi K2.5 +CLAWOSS_MODEL_REASONING=true +CLAWOSS_MODEL_CONTEXT_WINDOW=262144 +CLAWOSS_MODEL_MAX_TOKENS=65536 +CLAWOSS_MODEL_INPUT_COST=0.45 +CLAWOSS_MODEL_OUTPUT_COST=2.20 +CLAWOSS_MODEL_CACHE_READ_COST=0 +CLAWOSS_MODEL_CACHE_WRITE_COST=0 + +# Optional, comma-separated +CLAWOSS_FALLBACK_MODELS= + +# === LLM Runtime (Option B: advanced, pass raw OpenClaw providers JSON) === +# If set, this takes priority over the single-provider fields above. +# CLAWOSS_MODEL_PROVIDERS_JSON={"openai":{"baseUrl":"https://api.openai.com/v1","apiKey":"${OPENAI_API_KEY}","api":"openai-completions","authHeader":true,"models":[{"id":"gpt-4.1","name":"GPT-4.1","reasoning":true,"input":["text"],"cost":{"input":2,"output":8,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":32768}]}} +# CLAWOSS_PRIMARY_MODEL=openai/gpt-4.1 + +# === Runtime Controls === +CLAWOSS_HEARTBEAT_INTERVAL_MINUTES=5 +CLAWOSS_TOKEN_BUDGET_TOTAL=2000000 +CLAWOSS_COST_BUDGET_USD_TOTAL=50 +CLAWOSS_MVP_CYCLES=3 +CLAWOSS_MVP_MAX_CANDIDATES=20 +CLAWOSS_MVP_DISCOVERY_REPOS=cli/cli,vitest-dev/vitest,astral-sh/ruff # === Optional === -# GitHub identity for PRs (defaults shown) -GITHUB_USERNAME=BillionClaw -GITHUB_EMAIL=billionclaw+clawoss@users.noreply.github.com +# GitHub identity for PRs +GITHUB_USERNAME=your-github-username +GITHUB_EMAIL=your-github-email-or-noreply -# Dashboard (for dashboard-reporter skill) +# Dashboard DASHBOARD_URL=https://clawoss-dashboard.vercel.app CLAW_API_KEY=your-shared-secret-here diff --git a/.gitignore b/.gitignore index 7ef7cb0..5cdbedb 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,27 @@ config/auth-profiles.json workspace/EOF workspace/subagent-work/ dashboard/local.db +dashboard/demo-local.db +dashboard/demo-local.db.bak-* +.idea/ +tmp/ +208184 + +# Local agent/runtime artifacts +/memory/ +/workspace/.work/ +/workspace/.sync-state/ +/workspace/*.bak +/workspace/*.swp +/workspace/tmp_*.py +/workspace/tmp_*.json +/workspace/[0-9]* +/workspace/=* +/workspace/{*} +/workspace/railwayapp-cli/ +/workspace/subagent-implementation.md +/reports/mvp-run-*.json +/reports/mvp-run-*.md + +# Generated local service units may contain machine paths or secrets +/config/systemd/dashboard-sync.service diff --git a/README.md b/README.md index 1014e90..05914c4 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,34 @@ An OpenClaw agent that autono ### Quick Start ```bash -git clone https://github.com/billion-token-one-task/ClawOSS.git +git clone https://github.com/onthebed/ClawOSS.git cd ClawOSS cp .env.example .env # edit with your API keys bash scripts/setup.sh bash scripts/restart.sh ``` +### Continuous Run MVP Dry-Run + +Use this path when validating the continuous-run MVP without creating a live PR: + +```bash +cp .env.example .env # fill model, GitHub, dashboard, and budget values +npm run mvp:dry-run +``` + +The runner completes 3 heartbeat cycles by default, checks dashboard pause and budget guardrails before work, discovers candidate GitHub issues, applies CLA / duplicate / already-fixed / blocklist / avoidRepos filters, generates PR title/body/creation command, and writes a report under `reports/mvp-run-*.md`. +`CLAWOSS_MVP_DISCOVERY_REPOS` can be used to pin discovery to a controlled repo set during validation. + +Useful options: + +```bash +node scripts/mvp-runner.mjs --cycles 3 --max-candidates 20 +node scripts/mvp-runner.mjs --cycles 3 --issue owner/repo#123 +``` + +If `/api/agent/health-check` returns `pauseAgent: true` or `budget.paused: true`, the runner stops before spawning, commenting, pushing, or preparing PR creation. + --- ## First Run Stats @@ -125,7 +146,7 @@ bash scripts/restart.sh ░ ▼ ▼ ▼ ░ ░ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ ░ ░ │ GitHub │ │ Vercel Dashboard │ │ Telemetry Hooks │ ░ -░ │ (BillionClaw) │ │ /api/ingest/* │ │ dashboard-reporter │ ░ +░ │ (configured user)│ │ /api/ingest/* │ │ dashboard-reporter │ ░ ░ │ │ │ │ │ audit-logger │ ░ ░ │ PRs · Commits │ │ heartbeat │ │ pii-sanitizer │ ░ ░ │ Reviews │ │ metrics │ │ dashboard-sync.sh │ ░ @@ -316,7 +337,7 @@ The `plugins/pii-sanitizer/index.js` (101 lines) performs bidirectional `@` swap ░ ░ ░ pr-ledger-sync.sh (185 lines) — runs every 60s via launchd ░ ░ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ ░ -░ Source 1: gh search prs --author BillionClaw --limit 200 ░ +░ Source 1: gh search prs --author "$GITHUB_USERNAME" --limit 200 ░ ░ Source 2: grep subagent-result-*.md for PR URLs ░ ░ Python merger: pr_map keyed by URL, GH is authoritative for status ░ ░ Result files fill in issue numbers, existing ledger preserves mappings ░ @@ -377,7 +398,7 @@ The `plugins/pii-sanitizer/index.js` (101 lines) performs bidirectional `@` swap ░ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ░ ░ ░ ░ Step 1 ░░ source .env ░ -░ Step 2 ░░░ git config user.name BillionClaw ░ +░ Step 2 ░░░ git config user.name "$GITHUB_USERNAME" ░ ░ Step 3 ░░░░ gh auth login --with-token ░ ░ Step 4 ▒▒▒▒ ln -sf workspace → ~/.openclaw/workspace ░ ░ Step 5 ▒▒▒▒▒ sed __WORKSPACE_PATH__ → deploy config ░ @@ -441,7 +462,7 @@ clawOSS/ ├── AGENTS.md ················ 163 lines — prime directive + rules ├── HEARTBEAT.md ············· 244 lines — 8-step autonomous loop ├── SOUL.md ·················· persona, tone, boundaries - ├── IDENTITY.md ·············· @BillionClaw + ├── IDENTITY.md ·············· configured GitHub identity ├── USER.md ·················· operator profile ├── hooks/ │ ├── dashboard-reporter/ ·· 628 lines — telemetry to Vercel diff --git a/config/openclaw.json b/config/openclaw.json index 9f6f312..4b7ec10 100644 --- a/config/openclaw.json +++ b/config/openclaw.json @@ -1,6 +1,13 @@ { "agents": { "defaults": { + "bootstrapMaxChars": 12000, + "bootstrapTotalMaxChars": 40000, + "contextInjection": "continuation-skip", + "timeoutSeconds": 900, + "llm": { + "idleTimeoutSeconds": 300 + }, "compaction": { "mode": "safeguard", "reserveTokens": 130000, @@ -24,11 +31,11 @@ ] }, "model": { - "primary": "minimax/MiniMax-M2.7", - "fallbacks": ["kimi-coding/k2p5"] + "primary": "__PRIMARY_MODEL__", + "fallbacks": [] }, "subagents": { - "model": "minimax/MiniMax-M2.7", + "model": "__PRIMARY_MODEL__", "maxConcurrent": 14, "archiveAfterMinutes": 1440, "maxChildrenPerAgent": 15, @@ -42,7 +49,7 @@ "default": true, "name": "ClawOSS", "workspace": "__WORKSPACE_PATH__", - "model": "minimax/MiniMax-M2.7", + "model": "__PRIMARY_MODEL__", "tools": { "profile": "coding" }, @@ -51,10 +58,10 @@ }, "heartbeat": { "every": "5m", - "model": "minimax/MiniMax-M2.7", + "model": "__PRIMARY_MODEL__", "session": "main", "target": "none", - "prompt": "You are autonomous. Read HEARTBEAT.md and execute EVERY step 0-7. Do NOT just reply HEARTBEAT_OK.\n\nGOAL: MERGED PRs. Not submitted PRs \u2014 MERGED. A PR that never gets reviewed is zero output.\n\nPRIORITY ORDER (follow this EVERY cycle):\n1. NEW PRs FIRST: Fill all 10 impl slots with new implementations. Discover issues, triage, spawn.\n2. FOLLOW-UPS ONLY AFTER all 10 slots are full or no new work exists.\n3. The PR Monitor (always-on) handles simple follow-ups automatically. Main agent focuses on NEW work.\n\nTRUST-BUILDING: Stop spray-and-pray. Focus on 10-15 repos where we build reputation. Return to repos that merged our PRs. A repo that merged your PR is 10x more likely to merge the next one.\n\nFINDING REPOS: Discover target repos using CRITERIA, not a hardcoded list. Stars >= 200, active development, merge velocity > 0, review rate > 50%, open PRs < 50. Search: 'topic:llm/agent/rag/ai' + 'label:bug/help-wanted'. Check trusted repos FIRST.\n\nPR TYPES: Bug fixes, docs fixes, typo fixes, test additions. NOT features or refactors.\nMIX: 60% easy wins (docs, typos, tests) + 40% bug fixes. A merged typo fix > an unreviewed bug fix.\n\nCLA REPOS: CLAs require manual signing. Do not attempt to sign CLAs.\n\nQUALITY: Understand codebase deeply. Trace root causes. Read .github/workflows/. Target 25-100 LOC (max 200). PR descriptions: write like a human developer, not an AI. No 'This PR addresses...', 'Upon investigation...', 'I identified...'. Jump straight to what's broken and what you did.\n\nSUPERSESSION CHECK: Before starting ANY issue, check: is it assigned? Does it have linked PRs from other contributors? If yes, SKIP \u2014 working on superseded issues wastes cycles and annoys maintainers.\n\nDEDUP: Check for spawned_pending and lock files before spawning. Multiple PRs per repo is fine. ALWAYS use 'BillionClaw' explicitly \u2014 NEVER use @me. New PRs get priority over follow-ups. No daily caps \u2014 ship as many quality PRs as possible.\n\nFOLLOW UP on open PRs \u2014 bump stale ones, respond to reviews, merge approved ones.\n\nSCORING: Use P(merge) 0-100 weighted formula for prioritization. P(merge) >= 30 to attempt, >= 60 for priority spawning. Sort work queue by P(merge) descending. 10 impl/followup slots + 4 always-on (scout, PR monitor scan, PR monitor deep, PR analyst) = 14 total. NEVER reply HEARTBEAT_OK — there is ALWAYS work to do. If queue is empty, run discovery. If discovery finds nothing, check follow-ups. If no follow-ups, expand to new niches. The agent must ALWAYS be working on something.\n\nWEB SEARCH: You have web_search and web_fetch tools (Perplexity). Use them aggressively — search before implementing, search when stuck, search to validate approaches. 5-10 searches per cycle minimum.\n\nIf work exists, DO IT. NEVER idle. NEVER reply HEARTBEAT_OK. There is ALWAYS something to do.", + "prompt": "Resume the ClawOSS heartbeat loop. Read workspace/HEARTBEAT.md from disk and execute it. Goal: merged PRs. Prioritize new implementation work before follow-ups, obey dashboard pause and budget controls immediately, use the configured GitHub username explicitly instead of @me, and never idle while valid work exists. If bootstrap files look truncated, re-read the needed files from disk before acting.", "lightContext": false } } @@ -62,25 +69,7 @@ }, "models": { "mode": "merge", - "providers": { - "minimax": { - "baseUrl": "https://api.minimaxi.com/v1", - "apiKey": "${MINIMAX_API_KEY}", - "api": "openai-completions", - "authHeader": true, - "models": [ - { - "id": "MiniMax-M2.7", - "name": "MiniMax M2.7", - "reasoning": true, - "input": ["text"], - "cost": { "input": 0.5, "output": 1.5, "cacheRead": 0.125, "cacheWrite": 0.5 }, - "contextWindow": 204800, - "maxTokens": 131072 - } - ] - } - } + "providers": {} }, "gateway": { "port": 18789, diff --git a/dashboard/app/api/agent/health-check/route.ts b/dashboard/app/api/agent/health-check/route.ts index 1618de5..b4d0d5a 100644 --- a/dashboard/app/api/agent/health-check/route.ts +++ b/dashboard/app/api/agent/health-check/route.ts @@ -2,8 +2,11 @@ export const dynamic = "force-dynamic"; import { NextResponse } from "next/server"; import { db, ensureDb } from "@/lib/db"; -import { pullRequests, prReviews, agentLogs } from "@/lib/schema"; -import { eq, sql, gte } from "drizzle-orm"; +import { pullRequests, prReviews, agentLogs, heartbeats, metricsTokens, settings } from "@/lib/schema"; +import { eq, sql, gte, desc } from "drizzle-orm"; +import { preferAccurateMetrics } from "@/lib/metrics-source"; +import { computeBudgetStatus, extractRuntimeSnapshot } from "@/lib/runtime"; +import type { DashboardSettings } from "@/lib/types"; /** * Hard blocklist — repos where submitting PRs risks bans or reputation damage. @@ -47,6 +50,32 @@ export async function GET() { await ensureDb(); const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const latestHeartbeat = await db + .select() + .from(heartbeats) + .orderBy(desc(heartbeats.timestamp)) + .limit(1); + const runtime = extractRuntimeSnapshot(latestHeartbeat[0]?.metadata); + const metricRows = preferAccurateMetrics( + await db.select().from(metricsTokens).orderBy(desc(metricsTokens.timestamp)) + ); + const settingsRow = await db.query.settings.findFirst({ + where: eq(settings.key, "dashboard_settings"), + }); + const dashboardSettings = (settingsRow?.value || {}) as Partial; + const budget = computeBudgetStatus( + runtime, + metricRows.reduce( + (acc, metric) => { + acc.inputTokens += metric.inputTokens || 0; + acc.outputTokens += metric.outputTokens || 0; + acc.costUsd += metric.costUsd || 0; + return acc; + }, + { inputTokens: 0, outputTokens: 0, costUsd: 0 } + ), + Boolean(dashboardSettings.agentPaused) + ); // Basic stats const [totalResult, mergedResult, openResult] = await Promise.all([ @@ -166,6 +195,12 @@ export async function GET() { // Quick directives const directives: string[] = []; + if (budget.paused) { + directives.unshift( + `PAUSE NOW: ${budget.pauseReason}. Do not spawn, comment, or submit until budget is increased or reset.` + ); + } + if (approvedPRs.length > 0) { directives.unshift("MERGE NOW: " + approvedPRs.length + " approved PR(s) ready to merge: " + approvedPRs.map((pr) => pr.repo + "#" + pr.number).join(", ") + ". Run `gh pr merge --squash` if CI passes, or comment asking maintainer to trigger CI."); } @@ -203,7 +238,11 @@ export async function GET() { } return NextResponse.json({ - healthy: directives.length === 0, + healthy: directives.length === 0 && !budget.paused, + pauseAgent: budget.paused, + pauseReason: budget.pauseReason, + manuallyPaused: Boolean(dashboardSettings.agentPaused), + budget, stats: { total, merged, diff --git a/dashboard/app/api/connection-status/route.ts b/dashboard/app/api/connection-status/route.ts index 96a8226..6d3908f 100644 --- a/dashboard/app/api/connection-status/route.ts +++ b/dashboard/app/api/connection-status/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from "next/server"; import { db, ensureDb } from "@/lib/db"; import { heartbeats, metricsTokens, agentLogs } from "@/lib/schema"; import { desc, gte, sql, eq, and } from "drizzle-orm"; +import { preferAccurateMetrics } from "@/lib/metrics-source"; +import { computeBudgetStatus, extractRuntimeSnapshot } from "@/lib/runtime"; export async function GET() { try { @@ -59,6 +61,24 @@ export async function GET() { .orderBy(desc(metricsTokens.timestamp)) .limit(1); + const allMetrics = preferAccurateMetrics( + await db.select().from(metricsTokens) + ); + const totals = allMetrics.reduce( + (acc, metric) => { + acc.inputTokens += metric.inputTokens || 0; + acc.outputTokens += metric.outputTokens || 0; + acc.costUsd += metric.costUsd || 0; + return acc; + }, + { inputTokens: 0, outputTokens: 0, costUsd: 0 } + ); + const runtime = extractRuntimeSnapshot(hb?.metadata); + const budget = computeBudgetStatus(runtime, totals); + if (budget.paused) { + connectionMessage = budget.pauseReason || "Agent paused by budget guardrail"; + } + // Data pipeline status const hasHeartbeats = (recentHeartbeats[0]?.count || 0) > 0; const hasMetrics = !!lastMetric[0]; @@ -80,6 +100,8 @@ export async function GET() { errorsLastHour: recentErrors[0]?.count || 0, lastMetricAt: lastMetric[0]?.timestamp || null, }, + runtime, + budget, hasAnyData: hasHeartbeats || hasMetrics, }); } catch (error) { diff --git a/dashboard/app/api/github/sync/route.ts b/dashboard/app/api/github/sync/route.ts index 31a6a68..7c46ec5 100644 --- a/dashboard/app/api/github/sync/route.ts +++ b/dashboard/app/api/github/sync/route.ts @@ -3,7 +3,22 @@ export const dynamic = "force-dynamic"; import { NextResponse } from "next/server"; import { syncPRsFromGitHub } from "@/lib/github"; +const MANUAL_GITHUB_SYNC = + process.env.CLAWOSS_DASHBOARD_MANUAL_GITHUB_SYNC === "true"; + export async function GET() { + if (!MANUAL_GITHUB_SYNC) { + return NextResponse.json( + { + ok: false, + disabled: true, + reason: + "GitHub PR backfill is disabled for this deployment. Set CLAWOSS_DASHBOARD_MANUAL_GITHUB_SYNC=true to re-enable.", + }, + { status: 403 } + ); + } + try { const result = await syncPRsFromGitHub(); return NextResponse.json(result); diff --git a/dashboard/app/api/ingest/heartbeat/route.ts b/dashboard/app/api/ingest/heartbeat/route.ts index c7c8ad8..8f3807e 100644 --- a/dashboard/app/api/ingest/heartbeat/route.ts +++ b/dashboard/app/api/ingest/heartbeat/route.ts @@ -11,6 +11,8 @@ let _lastPrune = 0; let _lastSync = 0; const VALID_STATUSES = ["alive", "degraded", "offline"] as const; +const AUTO_GITHUB_SYNC = + process.env.CLAWOSS_DASHBOARD_AUTO_GITHUB_SYNC === "true"; export async function POST(request: Request) { if (!validateApiKey(request)) return unauthorizedResponse(); @@ -55,8 +57,11 @@ export async function POST(request: Request) { ); } - // GitHub PR sync: sync PRs at most once every 5 minutes - if (now - _lastSync > 300_000) { + // Optional GitHub PR sync: disabled by default for fresh deployments. + // Historical PR backfills can pollute a clean startup state and interfere + // with health-check routing. Use /api/github/sync manually, or set + // CLAWOSS_DASHBOARD_AUTO_GITHUB_SYNC=true to re-enable periodic syncing. + if (AUTO_GITHUB_SYNC && now - _lastSync > 300_000) { _lastSync = now; syncPRsFromGitHub().catch((err) => console.error("[heartbeat] GitHub sync error:", err) diff --git a/dashboard/app/api/metrics/alerts/route.ts b/dashboard/app/api/metrics/alerts/route.ts index 0130718..54f15d5 100644 --- a/dashboard/app/api/metrics/alerts/route.ts +++ b/dashboard/app/api/metrics/alerts/route.ts @@ -2,8 +2,10 @@ export const dynamic = "force-dynamic"; import { NextResponse } from "next/server"; import { db, ensureDb } from "@/lib/db"; -import { pullRequests, prReviews, autonomySnapshots, agentState } from "@/lib/schema"; +import { pullRequests, prReviews, autonomySnapshots, agentState, heartbeats, metricsTokens } from "@/lib/schema"; import { eq, sql, desc, gte, and } from "drizzle-orm"; +import { preferAccurateMetrics } from "@/lib/metrics-source"; +import { computeBudgetStatus, extractRuntimeSnapshot } from "@/lib/runtime"; interface Alert { id: string; @@ -27,6 +29,48 @@ export async function GET() { await ensureDb(); const alerts: Alert[] = []; const now = new Date(); + const latestHeartbeat = await db + .select() + .from(heartbeats) + .orderBy(desc(heartbeats.timestamp)) + .limit(1); + const runtime = extractRuntimeSnapshot(latestHeartbeat[0]?.metadata); + const metricRows = preferAccurateMetrics( + await db.select().from(metricsTokens).orderBy(desc(metricsTokens.timestamp)) + ); + const budget = computeBudgetStatus( + runtime, + metricRows.reduce( + (acc, metric) => { + acc.inputTokens += metric.inputTokens || 0; + acc.outputTokens += metric.outputTokens || 0; + acc.costUsd += metric.costUsd || 0; + return acc; + }, + { inputTokens: 0, outputTokens: 0, costUsd: 0 } + ) + ); + + if (budget.paused) { + alerts.push({ + id: "budget-paused", + severity: "critical", + title: "Budget exhausted - agent paused", + detail: budget.pauseReason || "累计预算已触发暂停保护。", + metric: "budget", + value: + budget.tokenBudgetTotal != null + ? `${budget.usedTokensTotal}/${budget.tokenBudgetTotal} tokens` + : `$${budget.usedCostTotalUsd.toFixed(2)}`, + threshold: + budget.tokenBudgetTotal != null + ? `${budget.tokenBudgetTotal} tokens` + : budget.costBudgetUsdTotal != null + ? `$${budget.costBudgetUsdTotal.toFixed(2)}` + : null, + timestamp: now.toISOString(), + }); + } // 1. Merge rate check const [totalResult, mergedResult] = await Promise.all([ diff --git a/dashboard/app/api/metrics/cost/route.ts b/dashboard/app/api/metrics/cost/route.ts index 75e8c87..0fcbd6a 100644 --- a/dashboard/app/api/metrics/cost/route.ts +++ b/dashboard/app/api/metrics/cost/route.ts @@ -5,6 +5,7 @@ import { db, ensureDb } from "@/lib/db"; import { metricsTokens } from "@/lib/schema"; import { gte, desc } from "drizzle-orm"; import { format, subWeeks, subMonths } from "date-fns"; +import { preferAccurateMetrics } from "@/lib/metrics-source"; export async function GET(request: Request) { try { @@ -24,11 +25,13 @@ export async function GET(request: Request) { since = subWeeks(new Date(), 1); } - const metrics = await db + const metrics = preferAccurateMetrics( + await db .select() .from(metricsTokens) .where(gte(metricsTokens.timestamp, since)) - .orderBy(desc(metricsTokens.timestamp)); + .orderBy(desc(metricsTokens.timestamp)) + ); const grouped = new Map(); for (const m of metrics) { diff --git a/dashboard/app/api/metrics/health/route.ts b/dashboard/app/api/metrics/health/route.ts index 844232e..501bc9b 100644 --- a/dashboard/app/api/metrics/health/route.ts +++ b/dashboard/app/api/metrics/health/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import { db, ensureDb } from "@/lib/db"; import { heartbeats, agentLogs } from "@/lib/schema"; import { desc, gte, eq, and, sql } from "drizzle-orm"; +import { extractRuntimeSnapshot } from "@/lib/runtime"; export async function GET() { try { @@ -19,6 +20,7 @@ export async function GET() { .limit(200); const latestBeat = recentHeartbeats[0]; + const runtime = extractRuntimeSnapshot(latestBeat?.metadata); let streak = 0; for (const beat of recentHeartbeats) { if (beat.status === "alive") streak++; @@ -31,7 +33,7 @@ export async function GET() { const uptimePercentage = totalBeats > 0 ? (aliveBeats / totalBeats) * 100 : 0; const firstBeat = recentHeartbeats[recentHeartbeats.length - 1]; const offlineBeats = totalBeats - aliveBeats; - const downtimeMinutes = offlineBeats * 5; // assuming 5 min intervals + const downtimeMinutes = offlineBeats * runtime.heartbeatIntervalMinutes; // Error rate const recentErrors = await db @@ -68,7 +70,7 @@ export async function GET() { return NextResponse.json({ heartbeat: { lastBeat: latestBeat?.timestamp || null, - intervalMinutes: 5, + intervalMinutes: runtime.heartbeatIntervalMinutes, streak, }, uptime: { diff --git a/dashboard/app/api/metrics/overview/route.ts b/dashboard/app/api/metrics/overview/route.ts index 113a7af..6c7995e 100644 --- a/dashboard/app/api/metrics/overview/route.ts +++ b/dashboard/app/api/metrics/overview/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from "next/server"; import { db, ensureDb } from "@/lib/db"; import { heartbeats, pullRequests, prReviews, metricsTokens, agentLogs, conversationMessages, subagentRuns } from "@/lib/schema"; import { desc, gte, sql, eq } from "drizzle-orm"; +import { preferAccurateMetrics } from "@/lib/metrics-source"; +import { computeBudgetStatus, extractRuntimeSnapshot } from "@/lib/runtime"; export async function GET() { try { @@ -20,6 +22,7 @@ export async function GET() { .limit(1); const hb = latestHeartbeat[0]; + const runtime = extractRuntimeSnapshot(hb?.metadata); const isOnline = hb ? hb.timestamp.getTime() > fiveMinutesAgo.getTime() : false; @@ -78,19 +81,28 @@ export async function GET() { } // Today's token usage and cost from metrics_tokens table - const todayMetrics = await db - .select({ - totalInput: sql`COALESCE(SUM(input_tokens), 0)`, - totalOutput: sql`COALESCE(SUM(output_tokens), 0)`, - totalCost: sql`COALESCE(SUM(cost_usd), 0)`, - }) - .from(metricsTokens) - .where(gte(metricsTokens.timestamp, todayStart)); + const todayMetricRows = preferAccurateMetrics( + await db + .select() + .from(metricsTokens) + .where(gte(metricsTokens.timestamp, todayStart)) + .orderBy(desc(metricsTokens.timestamp)) + ); - let inputTokensToday = todayMetrics[0]?.totalInput || 0; - let outputTokensToday = todayMetrics[0]?.totalOutput || 0; + let inputTokensToday = todayMetricRows.reduce( + (sum, metric) => sum + (metric.inputTokens || 0), + 0 + ); + let outputTokensToday = todayMetricRows.reduce( + (sum, metric) => sum + (metric.outputTokens || 0), + 0 + ); let tokensUsedToday = inputTokensToday + outputTokensToday; - let costToday = todayMetrics[0]?.totalCost || 0; + let costToday = + Math.round( + todayMetricRows.reduce((sum, metric) => sum + (metric.costUsd || 0), 0) * + 1_000_000 + ) / 1_000_000; // Fallback: estimate from conversation messages if metrics_tokens is empty if (tokensUsedToday === 0) { @@ -108,9 +120,12 @@ export async function GET() { // Estimate 70/30 input/output split for fallback inputTokensToday = Math.round(tokensUsedToday * 0.7); outputTokensToday = tokensUsedToday - inputTokensToday; - // Estimate cost using Kimi K2.5 average ($1.8/M tokens) + const averageCostPerMillionTokens = + ((runtime.pricing.inputUsdPerMillionTokens ?? 0) + + (runtime.pricing.outputUsdPerMillionTokens ?? 0)) / + 2; if (tokensUsedToday > 0 && costToday === 0) { - costToday = tokensUsedToday * (1.8 / 1_000_000); + costToday = tokensUsedToday * (averageCostPerMillionTokens / 1_000_000); } } @@ -189,31 +204,45 @@ export async function GET() { // No reviews yet } - // Total cost for cost-per-merge calculation - const totalCostResult = await db - .select({ total: sql`COALESCE(SUM(cost_usd), 0)` }) - .from(metricsTokens); - const totalCostAllTime = totalCostResult[0]?.total || 0; + const totalMetricRows = preferAccurateMetrics( + await db.select().from(metricsTokens).orderBy(desc(metricsTokens.timestamp)) + ); + const totalInputAllTime = totalMetricRows.reduce( + (sum, metric) => sum + (metric.inputTokens || 0), + 0 + ); + const totalOutputAllTime = totalMetricRows.reduce( + (sum, metric) => sum + (metric.outputTokens || 0), + 0 + ); + const totalCostAllTime = + Math.round( + totalMetricRows.reduce((sum, metric) => sum + (metric.costUsd || 0), 0) * + 1_000_000 + ) / 1_000_000; const costPerMerge = mergedPRs > 0 ? totalCostAllTime / mergedPRs : 0; // Total tokens for cost-per-merge - const totalTokensResult = await db - .select({ - input: sql`COALESCE(SUM(input_tokens), 0)`, - output: sql`COALESCE(SUM(output_tokens), 0)`, - }) - .from(metricsTokens); - const totalTokensAllTime = (totalTokensResult[0]?.input || 0) + (totalTokensResult[0]?.output || 0); + const totalTokensAllTime = totalInputAllTime + totalOutputAllTime; const tokensPerMerge = mergedPRs > 0 ? Math.round(totalTokensAllTime / mergedPRs) : 0; + const budget = computeBudgetStatus(runtime, { + inputTokens: totalInputAllTime, + outputTokens: totalOutputAllTime, + costUsd: totalCostAllTime, + }); return NextResponse.json({ agentStatus: { isOnline, + isPaused: budget.paused, + pauseReason: budget.pauseReason, lastHeartbeat: hb?.timestamp || new Date(0), - currentTask: hb?.currentTask || null, + currentTask: budget.paused ? budget.pauseReason : hb?.currentTask || null, uptimeSeconds: hb?.uptimeSeconds || 0, heartbeatStreak: streak, }, + runtime, + budget, stats: { totalPRs, mergedPRs, diff --git a/dashboard/app/api/metrics/tokens/route.ts b/dashboard/app/api/metrics/tokens/route.ts index a7d0a1e..d7bd3ef 100644 --- a/dashboard/app/api/metrics/tokens/route.ts +++ b/dashboard/app/api/metrics/tokens/route.ts @@ -5,6 +5,7 @@ import { db, ensureDb } from "@/lib/db"; import { metricsTokens } from "@/lib/schema"; import { gte, desc } from "drizzle-orm"; import { format, subDays, subWeeks, subMonths } from "date-fns"; +import { preferAccurateMetrics } from "@/lib/metrics-source"; export async function GET(request: Request) { try { @@ -24,11 +25,13 @@ export async function GET(request: Request) { since = subWeeks(new Date(), 1); } - const metrics = await db + const metrics = preferAccurateMetrics( + await db .select() .from(metricsTokens) .where(gte(metricsTokens.timestamp, since)) - .orderBy(desc(metricsTokens.timestamp)); + .orderBy(desc(metricsTokens.timestamp)) + ); // Group by day const grouped = new Map(); diff --git a/dashboard/app/live/page.tsx b/dashboard/app/live/page.tsx index 3243ac6..65601b8 100644 --- a/dashboard/app/live/page.tsx +++ b/dashboard/app/live/page.tsx @@ -59,6 +59,15 @@ export default function LivePage() { const errorsLastHour = connectionData?.pipeline?.errorsLastHour || 0; const heartbeatsLastHour = connectionData?.pipeline?.heartbeatsLastHour || 0; const connectionState = connectionData?.connection?.state || "unknown"; + const runtimeLabel = + connectionData?.runtime?.primaryModelName || + connectionData?.runtime?.primaryModel || + "llm-unset"; + const pricingLabel = + connectionData?.runtime?.pricing?.inputUsdPerMillionTokens != null || + connectionData?.runtime?.pricing?.outputUsdPerMillionTokens != null + ? `$${(connectionData?.runtime?.pricing?.inputUsdPerMillionTokens ?? 0).toFixed(2)}/$${(connectionData?.runtime?.pricing?.outputUsdPerMillionTokens ?? 0).toFixed(2)}/M` + : "n/a"; const activeSession = selectedSession ? sessions.find((s) => s.sessionId === selectedSession) @@ -156,6 +165,14 @@ export default function LivePage() { isConnected={isConnected} lastHeartbeat={lastHeartbeat} errorsLastHour={errorsLastHour} + inputCostPerToken={ + (connectionData?.runtime?.pricing?.inputUsdPerMillionTokens ?? 0) / + 1_000_000 + } + outputCostPerToken={ + (connectionData?.runtime?.pricing?.outputUsdPerMillionTokens ?? 0) / + 1_000_000 + } /> {/* Session tabs - prominent horizontal tab bar */} @@ -317,6 +334,12 @@ export default function LivePage() { heartbeatsLastHour={heartbeatsLastHour} errorsLastHour={errorsLastHour} sessions={sessions} + runtimeLabel={runtimeLabel} + pricingLabel={pricingLabel} + heartbeatIntervalMinutes={ + connectionData?.runtime?.heartbeatIntervalMinutes || 5 + } + budgetPaused={connectionData?.budget?.paused || false} /> ) : ( <> diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx index 8f37be2..7954caa 100644 --- a/dashboard/app/page.tsx +++ b/dashboard/app/page.tsx @@ -89,6 +89,22 @@ export default function OverviewPage() { const { data, isLoading } = useAgentStatus(); const { data: connectionData } = useConnectionStatus(); const { data: stateData, isLoading: stateLoading } = useAgentState(); + const tokenBudgetPercent = data?.budget?.tokenUsagePercent ?? null; + const costBudgetPercent = data?.budget?.costUsagePercent ?? null; + const runtimeLabel = + data?.runtime?.primaryModelName || data?.runtime?.primaryModel || "llm-unset"; + const pricingLabel = + data?.runtime?.pricing?.inputUsdPerMillionTokens != null || + data?.runtime?.pricing?.outputUsdPerMillionTokens != null + ? `$${(data?.runtime?.pricing?.inputUsdPerMillionTokens ?? 0).toFixed(2)}/$${(data?.runtime?.pricing?.outputUsdPerMillionTokens ?? 0).toFixed(2)}/M` + : "n/a"; + const budgetLabel = data?.budget?.paused + ? "paused" + : tokenBudgetPercent != null + ? `${tokenBudgetPercent.toFixed(1)}%` + : costBudgetPercent != null + ? `${costBudgetPercent.toFixed(1)}%` + : "--"; const hasData = connectionData?.hasAnyData || (data?.stats && (data.stats.totalPRs > 0 || data.stats.inputTokensToday > 0 || data.stats.outputTokensToday > 0)) || @@ -126,7 +142,7 @@ export default function OverviewPage() {
- kimi-k2.5 + {runtimeLabel} | parallel-agents | @@ -175,10 +191,12 @@ export default function OverviewPage() { inputTokensToday={data?.stats?.inputTokensToday || 0} outputTokensToday={data?.stats?.outputTokensToday || 0} costToday={data?.stats?.costToday || 0} + runtimeLabel={runtimeLabel} funnel={data?.funnel} costPerMerge={data?.stats?.costPerMerge || 0} tokensPerMerge={data?.stats?.tokensPerMerge || 0} avgHoursToReview={data?.stats?.avgHoursToReview} + budget={data?.budget} /> {/* PR portfolio scoreboard + repo health */} @@ -221,8 +239,9 @@ export default function OverviewPage() { {connectionData.pipeline.errorsLastHour} | - model kimi-k2.5 - cost $0.60/$3.00/M + model {runtimeLabel} + cost {pricingLabel} + budget {budgetLabel} | pii off
diff --git a/dashboard/components/live/gateway-status.tsx b/dashboard/components/live/gateway-status.tsx index 8363123..dda197c 100644 --- a/dashboard/components/live/gateway-status.tsx +++ b/dashboard/components/live/gateway-status.tsx @@ -10,6 +10,10 @@ interface GatewayStatusProps { heartbeatsLastHour: number; errorsLastHour: number; sessions: ConversationSession[]; + runtimeLabel?: string | null; + pricingLabel?: string | null; + heartbeatIntervalMinutes?: number; + budgetPaused?: boolean; } const SKILLS = [ @@ -35,6 +39,10 @@ export function GatewayStatus({ heartbeatsLastHour, errorsLastHour, sessions, + runtimeLabel, + pricingLabel, + heartbeatIntervalMinutes = 5, + budgetPaused = false, }: GatewayStatusProps) { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); @@ -100,7 +108,7 @@ export function GatewayStatus({
Model - kimi-coding/k2p5 + {runtimeLabel || "unknown"}
Auth @@ -108,7 +116,7 @@ export function GatewayStatus({
HB interval - 10m + {heartbeatIntervalMinutes}m
HBs/hr @@ -126,6 +134,12 @@ export function GatewayStatus({ MaxConc 5
+
+ Pricing + + {pricingLabel || "n/a"} + +
{/* Heartbeat timing */} diff --git a/dashboard/components/live/live-stats-bar.tsx b/dashboard/components/live/live-stats-bar.tsx index 8fa012c..396193a 100644 --- a/dashboard/components/live/live-stats-bar.tsx +++ b/dashboard/components/live/live-stats-bar.tsx @@ -2,17 +2,15 @@ import { useMemo, useState, useEffect } from "react"; import { formatTokens } from "@/lib/utils"; -import { DEFAULT_COST_MODEL } from "@/lib/cost-models"; import type { ConversationMessage } from "@/lib/types"; -const INPUT_COST_PER_TOKEN = DEFAULT_COST_MODEL.inputCostPerToken; -const OUTPUT_COST_PER_TOKEN = DEFAULT_COST_MODEL.outputCostPerToken; - interface LiveStatsBarProps { messages: ConversationMessage[]; isConnected: boolean; lastHeartbeat?: string | null; errorsLastHour?: number; + inputCostPerToken?: number; + outputCostPerToken?: number; } export function LiveStatsBar({ @@ -20,6 +18,8 @@ export function LiveStatsBar({ isConnected, lastHeartbeat, errorsLastHour = 0, + inputCostPerToken = 0, + outputCostPerToken = 0, }: LiveStatsBarProps) { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); @@ -48,8 +48,8 @@ export function LiveStatsBar({ const inputTokens = Math.round(totalTokens * 0.6); const outputTokens = totalTokens - inputTokens; const estimatedCost = - inputTokens * INPUT_COST_PER_TOKEN + - outputTokens * OUTPUT_COST_PER_TOKEN; + inputTokens * inputCostPerToken + + outputTokens * outputCostPerToken; let msgsPerMin = 0; if (totalMessages >= 2) { @@ -114,7 +114,7 @@ export function LiveStatsBar({ tokenBurnRate, sanitizedCount, }; - }, [messages]); + }, [inputCostPerToken, messages, outputCostPerToken]); // Compute staleness — only after mount to avoid hydration mismatch from Date.now() const stalenessLabel = useMemo(() => { diff --git a/dashboard/components/overview/agent-status-card.tsx b/dashboard/components/overview/agent-status-card.tsx index aa3f6e7..0bc5b4f 100644 --- a/dashboard/components/overview/agent-status-card.tsx +++ b/dashboard/components/overview/agent-status-card.tsx @@ -9,26 +9,54 @@ interface AgentStatusCardProps { } export function AgentStatusCard({ status }: AgentStatusCardProps) { + const tone = status.isPaused + ? { + dot: "bg-amber-500", + ping: "bg-amber-500", + text: "text-amber-400/80", + label: "paused", + } + : status.isOnline + ? { + dot: "bg-emerald-500", + ping: "bg-emerald-500", + text: "text-emerald-400/80", + label: "online", + } + : { + dot: "bg-red-500", + ping: "bg-red-500", + text: "text-red-400/80", + label: "offline", + }; + return (
- {status.isOnline && ( - + {(status.isOnline || status.isPaused) && ( + )} - + Agent - - {status.isOnline ? "online" : "offline"} + + {tone.label}
- {status.isOnline && ( - active + {(status.isOnline || status.isPaused) && ( + + {status.isPaused ? "budget-stop" : "active"} + )}
+ {status.pauseReason && ( +
+ {status.pauseReason} +
+ )}
Uptime
diff --git a/dashboard/components/overview/metric-cards.tsx b/dashboard/components/overview/metric-cards.tsx index c4bd6e4..ec8b442 100644 --- a/dashboard/components/overview/metric-cards.tsx +++ b/dashboard/components/overview/metric-cards.tsx @@ -17,10 +17,24 @@ interface MetricCardsProps { inputTokensToday: number; outputTokensToday: number; costToday: number; + runtimeLabel?: string | null; funnel?: FunnelData; costPerMerge?: number; tokensPerMerge?: number; avgHoursToReview?: number | null; + budget?: { + tokenBudgetTotal: number | null; + costBudgetUsdTotal: number | null; + usedTokensTotal: number; + usedCostTotalUsd: number; + remainingTokens: number | null; + remainingCostUsd: number | null; + tokenUsagePercent: number | null; + costUsagePercent: number | null; + exhausted: boolean; + paused: boolean; + pauseReason: string | null; + }; } function MiniBar({ @@ -105,10 +119,12 @@ export function MetricCards({ inputTokensToday, outputTokensToday, costToday, + runtimeLabel, funnel, costPerMerge, tokensPerMerge, avgHoursToReview, + budget, }: MetricCardsProps) { const mergeColor = mergeRate >= 50 @@ -119,6 +135,23 @@ export function MetricCards({ ? "text-red-400" : "text-muted-foreground/40"; + const budgetPercent = + budget?.tokenUsagePercent ?? budget?.costUsagePercent ?? null; + const tokenBudgetTotal = budget?.tokenBudgetTotal ?? null; + const costBudgetUsdTotal = budget?.costBudgetUsdTotal ?? null; + const budgetBarColor = + budget?.exhausted + ? "bg-red-500/50" + : (budgetPercent ?? 0) >= 80 + ? "bg-amber-500/40" + : "bg-emerald-500/40"; + const budgetSub = + tokenBudgetTotal != null + ? `${formatTokens(budget?.usedTokensTotal || 0)} / ${formatTokens(tokenBudgetTotal)}` + : costBudgetUsdTotal != null + ? `${formatCost(budget?.usedCostTotalUsd || 0)} / ${formatCost(costBudgetUsdTotal)}` + : budget?.pauseReason || "no cap"; + const cards = [ { label: "Input/24h", @@ -137,7 +170,7 @@ export function MetricCards({ { label: "Cost/24h", value: formatCost(costToday), - sub: costToday > 0 ? "kimi k2.5" : null, + sub: runtimeLabel || null, bar: { value: costToday, max: 5 }, }, { @@ -163,7 +196,18 @@ export function MetricCards({ ? "bg-emerald-500/40" : avgHoursToReview != null && avgHoursToReview <= 72 ? "bg-amber-500/40" - : "bg-red-500/40", + : "bg-red-500/40", + }, + { + label: "Budget", + value: budget?.paused + ? "paused" + : budgetPercent != null + ? `${budgetPercent.toFixed(1)}%` + : "--", + sub: budgetSub, + bar: { value: budgetPercent || 0, max: 100 }, + barColor: budgetBarColor, }, ]; @@ -307,7 +351,7 @@ export function MetricCards({
{/* Secondary metrics */} -
+
{cards.map((card) => ( diff --git a/dashboard/lib/cost-models.ts b/dashboard/lib/cost-models.ts index 1184259..88a0bf6 100644 --- a/dashboard/lib/cost-models.ts +++ b/dashboard/lib/cost-models.ts @@ -60,7 +60,7 @@ export const COST_MODELS: Record = { }, }; -// Default model for the ClawOSS agent (switched to MiniMax M2.7 direct API) +// Default fallback model only. Real runtime pricing should come from telemetry metadata. export const DEFAULT_MODEL = "minimax/MiniMax-M2.7"; export const DEFAULT_COST_MODEL = COST_MODELS[DEFAULT_MODEL]; @@ -73,7 +73,8 @@ export function computeTokenCost( outputTokens: number, model?: string ): number { - const costModel = (model && COST_MODELS[model]) || DEFAULT_COST_MODEL; + const costModel = model ? COST_MODELS[model] : DEFAULT_COST_MODEL; + if (!costModel) return 0; return ( inputTokens * costModel.inputCostPerToken + outputTokens * costModel.outputCostPerToken diff --git a/dashboard/lib/github.ts b/dashboard/lib/github.ts index 842cb77..db05d42 100644 --- a/dashboard/lib/github.ts +++ b/dashboard/lib/github.ts @@ -16,7 +16,10 @@ export async function syncPRsFromGitHub(): Promise<{ }> { await ensureDb(); const octokit = getOctokit(); - const agentUsername = process.env.CLAW_AGENT_USERNAME || "BillionClaw"; + const agentUsername = + process.env.CLAW_AGENT_USERNAME || + process.env.GITHUB_USERNAME || + "clawoss-agent"; // Dynamic discovery: search for ALL PRs by the agent across GitHub // Use raw fetch to avoid Octokit query encoding issues diff --git a/dashboard/lib/hooks/use-connection-status.ts b/dashboard/lib/hooks/use-connection-status.ts index 5ec262d..f3b41c6 100644 --- a/dashboard/lib/hooks/use-connection-status.ts +++ b/dashboard/lib/hooks/use-connection-status.ts @@ -14,6 +14,30 @@ interface ConnectionStatus { errorsLastHour: number; lastMetricAt: string | null; }; + runtime: { + primaryModel: string | null; + primaryModelName: string | null; + primaryProvider: string | null; + fallbackModels: string[]; + heartbeatIntervalMinutes: number; + pricing: { + inputUsdPerMillionTokens: number | null; + outputUsdPerMillionTokens: number | null; + }; + }; + budget: { + tokenBudgetTotal: number | null; + costBudgetUsdTotal: number | null; + usedTokensTotal: number; + usedCostTotalUsd: number; + remainingTokens: number | null; + remainingCostUsd: number | null; + tokenUsagePercent: number | null; + costUsagePercent: number | null; + exhausted: boolean; + paused: boolean; + pauseReason: string | null; + }; hasAnyData: boolean; } diff --git a/dashboard/lib/metrics-source.ts b/dashboard/lib/metrics-source.ts new file mode 100644 index 0000000..76b99ee --- /dev/null +++ b/dashboard/lib/metrics-source.ts @@ -0,0 +1,11 @@ +export function preferAccurateMetrics( + rows: T[] +): T[] { + const jsonlRows = rows.filter((row) => (row.channel || "").startsWith("jsonl:")); + if (jsonlRows.length > 0) return jsonlRows; + + const nonEstimateRows = rows.filter( + (row) => !["agent", "agent_estimate"].includes(row.channel || "") + ); + return nonEstimateRows.length > 0 ? nonEstimateRows : rows; +} diff --git a/dashboard/lib/runtime.ts b/dashboard/lib/runtime.ts new file mode 100644 index 0000000..5c98ff9 --- /dev/null +++ b/dashboard/lib/runtime.ts @@ -0,0 +1,167 @@ +export interface RuntimeSnapshot { + primaryModel: string | null; + primaryModelName: string | null; + primaryProvider: string | null; + fallbackModels: string[]; + heartbeatIntervalMinutes: number; + pricing: { + inputUsdPerMillionTokens: number | null; + outputUsdPerMillionTokens: number | null; + }; + budget: { + tokenBudgetTotal: number | null; + costBudgetUsdTotal: number | null; + }; +} + +export interface BudgetStatus { + tokenBudgetTotal: number | null; + costBudgetUsdTotal: number | null; + usedTokensTotal: number; + usedCostTotalUsd: number; + remainingTokens: number | null; + remainingCostUsd: number | null; + tokenUsagePercent: number | null; + costUsagePercent: number | null; + exhausted: boolean; + paused: boolean; + pauseReason: string | null; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + +function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((item) => asString(item)) + .filter((item): item is string => Boolean(item)); + } + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return []; +} + +export function extractRuntimeSnapshot(metadata: unknown): RuntimeSnapshot { + const root = asRecord(metadata); + const runtime = asRecord(root.runtime); + const pricing = asRecord(runtime.pricing); + const budget = asRecord(runtime.budget); + + return { + primaryModel: asString(runtime.primaryModel) ?? asString(root.model), + primaryModelName: + asString(runtime.primaryModelName) ?? asString(root.modelName), + primaryProvider: + asString(runtime.primaryProvider) ?? asString(root.provider), + fallbackModels: + asStringArray(runtime.fallbackModels).length > 0 + ? asStringArray(runtime.fallbackModels) + : asStringArray(root.fallbackModels), + heartbeatIntervalMinutes: + asNumber(runtime.heartbeatIntervalMinutes) ?? + asNumber(root.heartbeatIntervalMinutes) ?? + 5, + pricing: { + inputUsdPerMillionTokens: + asNumber(pricing.inputUsdPerMillionTokens) ?? + asNumber(root.inputUsdPerMillionTokens), + outputUsdPerMillionTokens: + asNumber(pricing.outputUsdPerMillionTokens) ?? + asNumber(root.outputUsdPerMillionTokens), + }, + budget: { + tokenBudgetTotal: + asNumber(budget.tokenBudgetTotal) ?? asNumber(root.tokenBudgetTotal), + costBudgetUsdTotal: + asNumber(budget.costBudgetUsdTotal) ?? + asNumber(root.costBudgetUsdTotal), + }, + }; +} + +function percent(used: number, total: number | null): number | null { + if (total == null || total <= 0) return null; + return Math.min(100, Math.round((used / total) * 1000) / 10); +} + +export function computeBudgetStatus( + runtime: RuntimeSnapshot, + totals: { + inputTokens: number; + outputTokens: number; + costUsd: number; + }, + manuallyPaused = false +): BudgetStatus { + const usedTokensTotal = (totals.inputTokens || 0) + (totals.outputTokens || 0); + const usedCostTotalUsd = Math.round((totals.costUsd || 0) * 1_000_000) / 1_000_000; + const tokenBudgetTotal = runtime.budget.tokenBudgetTotal; + const costBudgetUsdTotal = runtime.budget.costBudgetUsdTotal; + + const tokenExceeded = + tokenBudgetTotal != null && tokenBudgetTotal > 0 + ? usedTokensTotal >= tokenBudgetTotal + : false; + const costExceeded = + costBudgetUsdTotal != null && costBudgetUsdTotal > 0 + ? usedCostTotalUsd >= costBudgetUsdTotal + : false; + + let pauseReason: string | null = null; + if (manuallyPaused) { + pauseReason = "已手动暂停"; + } else if (tokenExceeded) { + pauseReason = `累计 token 已达到预算上限 (${usedTokensTotal}/${tokenBudgetTotal})`; + } else if (costExceeded) { + pauseReason = `累计成本已达到预算上限 ($${usedCostTotalUsd.toFixed(2)}/$${costBudgetUsdTotal?.toFixed(2)})`; + } + + return { + tokenBudgetTotal, + costBudgetUsdTotal, + usedTokensTotal, + usedCostTotalUsd, + remainingTokens: + tokenBudgetTotal != null ? Math.max(tokenBudgetTotal - usedTokensTotal, 0) : null, + remainingCostUsd: + costBudgetUsdTotal != null + ? Math.max( + Math.round((costBudgetUsdTotal - usedCostTotalUsd) * 1_000_000) / 1_000_000, + 0 + ) + : null, + tokenUsagePercent: percent(usedTokensTotal, tokenBudgetTotal), + costUsagePercent: percent(usedCostTotalUsd, costBudgetUsdTotal), + exhausted: tokenExceeded || costExceeded, + paused: manuallyPaused || tokenExceeded || costExceeded, + pauseReason, + }; +} + +export function formatPricingLabel(runtime: RuntimeSnapshot): string | null { + const input = runtime.pricing.inputUsdPerMillionTokens; + const output = runtime.pricing.outputUsdPerMillionTokens; + if (input == null && output == null) return null; + return `$${(input ?? 0).toFixed(2)}/$${(output ?? 0).toFixed(2)}/M`; +} diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index f7f82ac..afac81a 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -1,5 +1,7 @@ export interface AgentStatus { isOnline: boolean; + isPaused: boolean; + pauseReason: string | null; lastHeartbeat: Date; currentTask: string | null; uptimeSeconds: number; @@ -189,6 +191,30 @@ export interface AgentState { export interface DashboardOverview { agentStatus: AgentStatus; + runtime: { + primaryModel: string | null; + primaryModelName: string | null; + primaryProvider: string | null; + fallbackModels: string[]; + heartbeatIntervalMinutes: number; + pricing: { + inputUsdPerMillionTokens: number | null; + outputUsdPerMillionTokens: number | null; + }; + }; + budget: { + tokenBudgetTotal: number | null; + costBudgetUsdTotal: number | null; + usedTokensTotal: number; + usedCostTotalUsd: number; + remainingTokens: number | null; + remainingCostUsd: number | null; + tokenUsagePercent: number | null; + costUsagePercent: number | null; + exhausted: boolean; + paused: boolean; + pauseReason: string | null; + }; stats: { totalPRs: number; mergedPRs: number; diff --git a/dashboard/vercel.json b/dashboard/vercel.json index bb4c959..f6218dd 100644 --- a/dashboard/vercel.json +++ b/dashboard/vercel.json @@ -1,8 +1,3 @@ { - "crons": [ - { - "path": "/api/github/sync", - "schedule": "*/2 * * * *" - } - ] + "crons": [] } diff --git a/package.json b/package.json index 740b58e..3814cb8 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,13 @@ "health": "bash scripts/health-check.sh", "dashboard:dev": "cd dashboard && npm run dev", "dashboard:build": "cd dashboard && npm run build", + "mvp:dry-run": "node scripts/mvp-runner.mjs --cycles 3", + "mvp:verify-budget-pause": "bash scripts/verify-budget-pause.sh", "validate": "node scripts/validate-config.mjs" }, "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/billion-token-one-task/ClawOSS.git" + "url": "https://github.com/onthebed/ClawOSS.git" } } diff --git a/scripts/check-blocklist.sh b/scripts/check-blocklist.sh index 683afb4..e2e666f 100755 --- a/scripts/check-blocklist.sh +++ b/scripts/check-blocklist.sh @@ -4,7 +4,8 @@ # Exit 0 = clear, Exit 1 = blocklisted (reason in JSON output) REPO="${1:?Usage: check-blocklist.sh }" -PROJECT_DIR="${PROJECT_DIR:-/Users/kevinlin/clawOSS}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-${CLAWOSS_PROJECT_DIR:-$(dirname "$SCRIPT_DIR")}}" TRUST_FILE="$PROJECT_DIR/workspace/memory/trust-repos.md" if [ ! -f "$TRUST_FILE" ]; then diff --git a/scripts/check-supersession.sh b/scripts/check-supersession.sh index d42ea09..365802f 100755 --- a/scripts/check-supersession.sh +++ b/scripts/check-supersession.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -# check-supersession.sh — Check if someone else is already working on an issue +# check-supersession.sh 鈥?Check if someone else is already working on an issue # Usage: check-supersession.sh # Exit 0 = clear, Exit 1 = superseded REPO="${1:?Usage: check-supersession.sh }" ISSUE="${2:?Usage: check-supersession.sh }" +AGENT_USER="${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}" # 1. Linked open PRs LINKED=$(gh api "repos/${REPO}/issues/${ISSUE}/timeline" --jq '[.[] | select(.event=="cross-referenced") | .source.issue | select(.pull_request != null and .state == "open")] | length' 2>/dev/null || echo 0) @@ -34,7 +35,7 @@ if [ "$CLAIMED" -gt 0 ]; then fi # 4. Competing open PRs -COMPETING=$(gh pr list --repo "$REPO" --state open --search "$ISSUE" --json number,author --jq '[.[] | select(.author.login != "BillionClaw")] | length' 2>/dev/null || echo 0) +COMPETING=$(gh pr list --repo "$REPO" --state open --search "$ISSUE" --json number,author --jq "[.[] | select(.author.login != \"$AGENT_USER\")] | length" 2>/dev/null || echo 0) [[ "$COMPETING" =~ ^[0-9]+$ ]] || COMPETING=0 if [ "$COMPETING" -gt 0 ]; then echo "{\"superseded\": true, \"repo\": \"$REPO\", \"issue\": $ISSUE, \"reason\": \"${COMPETING} competing PR(s)\"}" diff --git a/scripts/cleanup-stale-sessions.sh b/scripts/cleanup-stale-sessions.sh index db84308..ba46763 100755 --- a/scripts/cleanup-stale-sessions.sh +++ b/scripts/cleanup-stale-sessions.sh @@ -5,7 +5,9 @@ # Resets spawned_pending entries in state files # Exit 0 always, outputs JSON summary -WORKSPACE_DIR="${WORKSPACE_DIR:-/Users/kevinlin/clawOSS/workspace}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${CLAWOSS_PROJECT_DIR:-$(dirname "$SCRIPT_DIR")}" +WORKSPACE_DIR="${WORKSPACE_DIR:-${CLAWOSS_WORKSPACE_DIR:-$PROJECT_DIR/workspace}}" LOCK_DIR="${WORKSPACE_DIR}/memory/locks" IMPL_STATE="${WORKSPACE_DIR}/memory/impl-spawn-state.md" FOLLOWUP_STATE="${WORKSPACE_DIR}/memory/pr-followup-state.md" diff --git a/scripts/compute-merge-probability.sh b/scripts/compute-merge-probability.sh index ef95b5c..4a34752 100755 --- a/scripts/compute-merge-probability.sh +++ b/scripts/compute-merge-probability.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash -# compute-merge-probability.sh — Calculate P(merge) score for an issue/repo pair +# compute-merge-probability.sh 鈥?Calculate P(merge) score for an issue/repo pair # Usage: compute-merge-probability.sh [--type fix|docs|test|typo] # Outputs JSON with score 0-100 and component breakdown # Exit 0 always (score=0 means skip) REPO="${1:?Usage: compute-merge-probability.sh [--type TYPE]}" ISSUE="${2:?Usage: compute-merge-probability.sh }" -PROJECT_DIR="${PROJECT_DIR:-/Users/kevinlin/clawOSS}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-${CLAWOSS_PROJECT_DIR:-$(dirname "$SCRIPT_DIR")}}" TYPE="fix" shift 2 @@ -19,11 +20,12 @@ done OWNER="${REPO%%/*}" REPO_NAME="${REPO##*/}" +AGENT_USER="${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}" # Weights: 15% task_type + 20% size + 15% responsiveness + 25% trust + 10% freshness + 10% contributor_fit + 5% competition W_TYPE=15 W_SIZE=20 W_RESPONSIVE=15 W_TRUST=25 W_FRESH=10 W_FIT=10 W_COMP=5 -# ─── 1. Task type score (0-100) ─── +# 鈹€鈹€鈹€ 1. Task type score (0-100) 鈹€鈹€鈹€ case "$TYPE" in docs|typo) S_TYPE=90 ;; test) S_TYPE=80 ;; @@ -31,7 +33,7 @@ case "$TYPE" in *) S_TYPE=50 ;; esac -# ─── 2. Size estimate from issue body length (0-100, smaller = better) ─── +# 鈹€鈹€鈹€ 2. Size estimate from issue body length (0-100, smaller = better) 鈹€鈹€鈹€ BODY_LEN=$(gh api "repos/${REPO}/issues/${ISSUE}" --jq '.body | length' 2>/dev/null || echo 500) if [ "$BODY_LEN" -lt 200 ]; then S_SIZE=90 @@ -43,7 +45,7 @@ else S_SIZE=30 fi -# ─── 3. Repo responsiveness (0-100) ─── +# 鈹€鈹€鈹€ 3. Repo responsiveness (0-100) 鈹€鈹€鈹€ AVG_COMMENTS=$(gh api "repos/${REPO}/issues?state=closed&per_page=5&sort=updated" --jq '[.[].comments] | add / length' 2>/dev/null || echo 0) if python3 -c "exit(0 if float('${AVG_COMMENTS:-0}') > 3 else 1)" 2>/dev/null; then S_RESPONSIVE=85 @@ -55,7 +57,7 @@ else S_RESPONSIVE=20 fi -# ─── 4. Trust score (0-100) ─── +# 鈹€鈹€鈹€ 4. Trust score (0-100) 鈹€鈹€鈹€ TRUST_FILE="$PROJECT_DIR/workspace/memory/trust-repos.md" S_TRUST=50 if [ -f "$TRUST_FILE" ]; then @@ -69,7 +71,7 @@ if [ -f "$TRUST_FILE" ]; then [ -n "$DEPRI" ] && S_TRUST=5 fi -# ─── 5. Freshness score (0-100) ─── +# 鈹€鈹€鈹€ 5. Freshness score (0-100) 鈹€鈹€鈹€ CREATED_AT=$(gh api "repos/${REPO}/issues/${ISSUE}" --jq '.created_at' 2>/dev/null || echo "") S_FRESH=50 if [ -n "$CREATED_AT" ]; then @@ -83,19 +85,19 @@ if [ -n "$CREATED_AT" ]; then else S_FRESH=15; fi fi -# ─── 6. Contributor fit (0-100) ─── -PREV_PRS=$(gh search prs --author BillionClaw --repo "$REPO" "is:merged" --json number --jq 'length' 2>/dev/null || echo 0) +# 鈹€鈹€鈹€ 6. Contributor fit (0-100) 鈹€鈹€鈹€ +PREV_PRS=$(gh search prs --author "$AGENT_USER" --repo "$REPO" "is:merged" --json number --jq 'length' 2>/dev/null || echo 0) if [ "$PREV_PRS" -gt 2 ]; then S_FIT=95 elif [ "$PREV_PRS" -gt 0 ]; then S_FIT=80 else S_FIT=50; fi -# ─── 7. Competition (0-100) ─── +# 鈹€鈹€鈹€ 7. Competition (0-100) 鈹€鈹€鈹€ OPEN_PRS=$(gh api "repos/${REPO}/issues/${ISSUE}/timeline" --jq '[.[] | select(.event=="cross-referenced") | .source.issue | select(.pull_request != null and .state == "open")] | length' 2>/dev/null || echo 0) if [ "$OPEN_PRS" -eq 0 ]; then S_COMP=95 elif [ "$OPEN_PRS" -eq 1 ]; then S_COMP=40 else S_COMP=10; fi -# ─── Calculate weighted score ─── +# 鈹€鈹€鈹€ Calculate weighted score 鈹€鈹€鈹€ SCORE=$(python3 -c " t=$S_TYPE; sz=$S_SIZE; r=$S_RESPONSIVE; tr=$S_TRUST; f=$S_FRESH; ft=$S_FIT; c=$S_COMP wt=$W_TYPE; wsz=$W_SIZE; wr=$W_RESPONSIVE; wtr=$W_TRUST; wf=$W_FRESH; wft=$W_FIT; wc=$W_COMP diff --git a/scripts/dashboard-sync.sh b/scripts/dashboard-sync.sh index d6886bf..c2ab46a 100755 --- a/scripts/dashboard-sync.sh +++ b/scripts/dashboard-sync.sh @@ -13,6 +13,14 @@ URL="${DASHBOARD_URL:-https://clawoss-dashboard.vercel.app}" KEY="${CLAW_API_KEY:?Set CLAW_API_KEY env var}" +PRIMARY_MODEL="${CLAWOSS_PRIMARY_MODEL:-unknown/unknown}" +PRIMARY_MODEL_NAME="${CLAWOSS_PRIMARY_MODEL_NAME:-$PRIMARY_MODEL}" +PRIMARY_PROVIDER="${CLAWOSS_PRIMARY_PROVIDER:-${PRIMARY_MODEL%%/*}}" +PRIMARY_INPUT_COST_PER_MTOKENS="${CLAWOSS_PRIMARY_INPUT_COST_PER_MTOKENS:-0}" +PRIMARY_OUTPUT_COST_PER_MTOKENS="${CLAWOSS_PRIMARY_OUTPUT_COST_PER_MTOKENS:-0}" +HEARTBEAT_INTERVAL_MINUTES="${CLAWOSS_HEARTBEAT_INTERVAL_MINUTES:-5}" +TOKEN_BUDGET_TOTAL="${CLAWOSS_TOKEN_BUDGET_TOTAL:-}" +COST_BUDGET_USD_TOTAL="${CLAWOSS_COST_BUDGET_USD_TOTAL:-}" # Sessions dir: check for the clawoss agent sessions, with fallback if [ -d "$HOME/.openclaw/agents/clawoss/sessions" ]; then DIR="$HOME/.openclaw/agents/clawoss/sessions" @@ -23,7 +31,7 @@ else fi INTERVAL=10 # Use persistent offset dir under workspace to survive reboots (not /tmp) -SYNC_STATE_DIR="${CLAWOSS_WORKSPACE:-$HOME/clawOSS/workspace}/.sync-state" +SYNC_STATE_DIR="${CLAWOSS_WORKSPACE_DIR:-${CLAWOSS_WORKSPACE:-$HOME/clawOSS/workspace}}/.sync-state" OFFSET_DIR="${SYNC_STATE_DIR}/offsets" SESSION_MAP="${SYNC_STATE_DIR}/session-map.json" LOCK_FILE="${SYNC_STATE_DIR}/dashboard-sync.pid" @@ -32,6 +40,14 @@ mkdir -p "$OFFSET_DIR" "$SYNC_STATE_DIR" log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; } +json_number_or_null() { + if [ -n "${1:-}" ]; then + printf '%s' "$1" + else + printf 'null' + fi +} + # --- PID lock: prevent duplicate instances --- if [ -f "$LOCK_FILE" ]; then OLD_PID=$(cat "$LOCK_FILE" 2>/dev/null) @@ -131,7 +147,15 @@ while true; do --argjson active "$LOCKS" \ --argjson totalBytes "${BYTES:-0}" \ --arg source "dashboard-sync.sh" \ - '{sessionCount:$sessions, activeCount:$active, totalBytes:$totalBytes, source:$source}' 2>/dev/null || echo '{}') + --arg model "$PRIMARY_MODEL" \ + --arg modelName "$PRIMARY_MODEL_NAME" \ + --arg provider "$PRIMARY_PROVIDER" \ + --argjson heartbeatIntervalMinutes "$(json_number_or_null "$HEARTBEAT_INTERVAL_MINUTES")" \ + --argjson inputUsdPerMillionTokens "$(json_number_or_null "$PRIMARY_INPUT_COST_PER_MTOKENS")" \ + --argjson outputUsdPerMillionTokens "$(json_number_or_null "$PRIMARY_OUTPUT_COST_PER_MTOKENS")" \ + --argjson tokenBudgetTotal "$(json_number_or_null "$TOKEN_BUDGET_TOTAL")" \ + --argjson costBudgetUsdTotal "$(json_number_or_null "$COST_BUDGET_USD_TOTAL")" \ + '{sessionCount:$sessions, activeCount:$active, totalBytes:$totalBytes, source:$source, model:$model, modelName:$modelName, provider:$provider, runtime:{primaryModel:$model, primaryModelName:$modelName, primaryProvider:$provider, heartbeatIntervalMinutes:$heartbeatIntervalMinutes, pricing:{inputUsdPerMillionTokens:$inputUsdPerMillionTokens, outputUsdPerMillionTokens:$outputUsdPerMillionTokens}, budget:{tokenBudgetTotal:$tokenBudgetTotal, costBudgetUsdTotal:$costBudgetUsdTotal}}}' 2>/dev/null || echo '{}') curl -s -m 8 -X POST "$URL/api/ingest/heartbeat" \ -H "Authorization: Bearer $KEY" \ @@ -166,9 +190,13 @@ while true; do NEW_COUNT=$((TOTAL_LINES - TOKEN_PREV)) [ "$NEW_COUNT" -gt 200 ] && NEW_COUNT=200 && TOKEN_PREV=$((TOTAL_LINES - 200)) - METRICS_PAYLOAD=$(tail -n "$NEW_COUNT" "$f" 2>/dev/null | _SID="$SID" python3 -c " + METRICS_PAYLOAD=$(tail -n "$NEW_COUNT" "$f" 2>/dev/null | _SID="$SID" _DEFAULT_MODEL="$PRIMARY_MODEL" _PRIMARY_PROVIDER="$PRIMARY_PROVIDER" _INPUT_PRICE="$PRIMARY_INPUT_COST_PER_MTOKENS" _OUTPUT_PRICE="$PRIMARY_OUTPUT_COST_PER_MTOKENS" python3 -c " import json, sys, os sid = os.environ['_SID'] +default_model = os.environ.get('_DEFAULT_MODEL', '') +provider = os.environ.get('_PRIMARY_PROVIDER', '') +input_price = float(os.environ.get('_INPUT_PRICE', '0') or 0) +output_price = float(os.environ.get('_OUTPUT_PRICE', '0') or 0) metrics = [] for line in sys.stdin: line = line.strip() @@ -190,12 +218,17 @@ for line in sys.stdin: out = usage.get('output', 0) or usage.get('output_tokens', 0) or usage.get('outputTokens', 0) or 0 if inp == 0 and out == 0: continue - model = m.get('model', e.get('model', '')) + model = m.get('model', e.get('model', '')) or default_model + cost = 0 + if model == default_model and (input_price > 0 or output_price > 0): + cost = round((inp * input_price + out * output_price) / 1_000_000, 6) metrics.append({ 'inputTokens': inp, 'outputTokens': out, - 'model': model or 'kimi-coding/k2p5', - 'channel': sid + 'model': model, + 'provider': provider, + 'costUsd': cost, + 'channel': f'jsonl:{sid}' }) if metrics: print(json.dumps({'metrics': metrics})) diff --git a/scripts/deploy-openclaw-config.sh b/scripts/deploy-openclaw-config.sh new file mode 100644 index 0000000..4eeb164 --- /dev/null +++ b/scripts/deploy-openclaw-config.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +DEPLOYED_CONFIG="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}" + +if [ -f "$PROJECT_DIR/.env" ]; then + set -a + # shellcheck disable=SC1091 + source "$PROJECT_DIR/.env" + set +a + echo "[ClawOSS配置] 已加载 .env" +else + echo "[ClawOSS配置] 未找到 .env,继续使用当前环境变量" +fi + +mkdir -p "$(dirname "$DEPLOYED_CONFIG")" + +REPO_CONFIG_RESOLVED="$(node "$PROJECT_DIR/scripts/render-openclaw-config.mjs")" + +_REPO_CONFIG="$REPO_CONFIG_RESOLVED" \ +_DEPLOYED="$DEPLOYED_CONFIG" \ +python3 - <<'PY' +import json +import os +from pathlib import Path + + +def deep_merge(base, override): + result = dict(base) + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +repo_config = json.loads(os.environ["_REPO_CONFIG"]) +deployed_path = Path(os.environ["_DEPLOYED"]) + +try: + deployed = json.loads(deployed_path.read_text(encoding="utf-8")) +except (FileNotFoundError, json.JSONDecodeError): + deployed = {} + +merged = deep_merge(deployed, repo_config) +deployed_path.write_text(json.dumps(merged, indent=2) + "\n", encoding="utf-8") +PY + +echo "[ClawOSS配置] 已深度合并到 $DEPLOYED_CONFIG" diff --git a/scripts/heartbeat-status.sh b/scripts/heartbeat-status.sh index 3363176..891d0dc 100755 --- a/scripts/heartbeat-status.sh +++ b/scripts/heartbeat-status.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# heartbeat-status.sh — Quick status dump for the agent +# heartbeat-status.sh 鈥?Quick status dump for the agent # Usage: heartbeat-status.sh # Outputs JSON with: active sessions, open PRs, queue depth, lock files, # wake state, scout status, PR monitor status, PR analyst status @@ -10,8 +10,30 @@ if [ "${1:-}" = "--help" ]; then exit 0 fi -PROJECT_DIR="${PROJECT_DIR:-/Users/kevinlin/clawOSS}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-${CLAWOSS_PROJECT_DIR:-$(dirname "$SCRIPT_DIR")}}" MEMORY_DIR="$PROJECT_DIR/workspace/memory" +AGENT_USER="${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}" + +file_mtime_epoch() { + local path="$1" + if [ ! -e "$path" ]; then + echo 0 + return 0 + fi + + if stat -c %Y "$path" >/dev/null 2>&1; then + stat -c %Y "$path" + return 0 + fi + + if stat -f %m "$path" >/dev/null 2>&1; then + stat -f %m "$path" + return 0 + fi + + echo 0 +} # Wake state (macOS grep doesn't support -P, use sed instead) WAKE_STATE=$(cat "$MEMORY_DIR/wake-state.md" 2>/dev/null || echo "unavailable") @@ -32,14 +54,14 @@ STAGING_DEPTH=$(grep -c '^\- \[' "$MEMORY_DIR/work-queue-staging.md" 2>/dev/null STAGING_DEPTH=${STAGING_DEPTH:-0} # Open PRs -OPEN_PRS=$(gh search prs --author BillionClaw --state open --json number --jq 'length' 2>/dev/null || echo 0) +OPEN_PRS=$(gh search prs --author "$AGENT_USER" --state open --json number --jq 'length' 2>/dev/null || echo 0) # Scout status SCOUT_STATUS="unknown" SCOUT_REPORT="" LATEST_SCOUT=$(ls -t "$MEMORY_DIR"/scout-report-*.md 2>/dev/null | head -1) if [ -n "$LATEST_SCOUT" ]; then - MTIME=$(stat -f %m "$LATEST_SCOUT" 2>/dev/null || stat -c %Y "$LATEST_SCOUT" 2>/dev/null || echo 0) + MTIME=$(file_mtime_epoch "$LATEST_SCOUT") [ "$MTIME" -eq 0 ] 2>/dev/null && SCOUT_AGE_MIN=9999 || SCOUT_AGE_MIN=$(( ($(date +%s) - MTIME) / 60 )) if [ "$SCOUT_AGE_MIN" -lt 30 ]; then SCOUT_STATUS="active" @@ -54,7 +76,7 @@ fi # PR Monitor status MONITOR_STATUS="unknown" if [ -f "$MEMORY_DIR/pr-monitor-report.md" ]; then - MTIME=$(stat -f %m "$MEMORY_DIR/pr-monitor-report.md" 2>/dev/null || stat -c %Y "$MEMORY_DIR/pr-monitor-report.md" 2>/dev/null || echo 0) + MTIME=$(file_mtime_epoch "$MEMORY_DIR/pr-monitor-report.md") [ "$MTIME" -eq 0 ] 2>/dev/null && MONITOR_AGE_MIN=9999 || MONITOR_AGE_MIN=$(( ($(date +%s) - MTIME) / 60 )) if [ "$MONITOR_AGE_MIN" -lt 30 ]; then MONITOR_STATUS="active" @@ -68,7 +90,7 @@ fi # PR Analyst status ANALYST_STATUS="unknown" if [ -f "$MEMORY_DIR/pr-strategy.md" ]; then - MTIME=$(stat -f %m "$MEMORY_DIR/pr-strategy.md" 2>/dev/null || stat -c %Y "$MEMORY_DIR/pr-strategy.md" 2>/dev/null || echo 0) + MTIME=$(file_mtime_epoch "$MEMORY_DIR/pr-strategy.md") [ "$MTIME" -eq 0 ] 2>/dev/null && ANALYST_AGE_MIN=9999 || ANALYST_AGE_MIN=$(( ($(date +%s) - MTIME) / 60 )) if [ "$ANALYST_AGE_MIN" -lt 30 ]; then ANALYST_STATUS="active" diff --git a/scripts/lock-repo.sh b/scripts/lock-repo.sh index 97a5ca9..952a74c 100755 --- a/scripts/lock-repo.sh +++ b/scripts/lock-repo.sh @@ -6,7 +6,8 @@ REPO="${1:?Usage: lock-repo.sh [reason]}" ISSUE="${2:?Usage: lock-repo.sh }" REASON="${3:-workspace-setup}" -PROJECT_DIR="${PROJECT_DIR:-/Users/kevinlin/clawOSS}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-${CLAWOSS_PROJECT_DIR:-$(dirname "$SCRIPT_DIR")}}" OWNER="${REPO%%/*}" REPO_NAME="${REPO##*/}" diff --git a/scripts/mvp-runner.mjs b/scripts/mvp-runner.mjs new file mode 100755 index 0000000..774a8d7 --- /dev/null +++ b/scripts/mvp-runner.mjs @@ -0,0 +1,734 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { spawnSync } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, ".."); +const WORKSPACE = process.env.CLAWOSS_WORKSPACE_DIR || join(ROOT, "workspace"); +const MEMORY = join(WORKSPACE, "memory"); +const REPORTS = join(ROOT, "reports"); + +loadEnv(join(ROOT, ".env")); + +const args = parseArgs(process.argv.slice(2)); +const cycleTarget = numberFrom(args.cycles, process.env.CLAWOSS_MVP_CYCLES, 3); +const maxCandidates = numberFrom(args["max-candidates"], process.env.CLAWOSS_MVP_MAX_CANDIDATES, 20); +const dryRun = !args["real-pr"]; +const dashboardUrl = stripTrailingSlash( + process.env.DASHBOARD_URL || "https://clawoss-dashboard.vercel.app" +); +const dashboardKey = process.env.CLAW_API_KEY || ""; +const provider = process.env.CLAWOSS_PRIMARY_PROVIDER || process.env.CLAWOSS_PROVIDER_ID || null; +const model = + process.env.CLAWOSS_PRIMARY_MODEL || + (process.env.CLAWOSS_PROVIDER_ID && process.env.CLAWOSS_MODEL_ID + ? `${process.env.CLAWOSS_PROVIDER_ID}/${process.env.CLAWOSS_MODEL_ID}` + : process.env.CLAWOSS_MODEL_ID || null); +const modelName = process.env.CLAWOSS_MODEL_NAME || model; +let childEnv = { + ...process.env, + GH_TOKEN: process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "", + CLAWOSS_PROJECT_DIR: process.env.CLAWOSS_PROJECT_DIR || ROOT, + CLAWOSS_WORKSPACE_DIR: WORKSPACE, +}; +const githubUser = + process.env.CLAW_AGENT_USERNAME || + process.env.GITHUB_USERNAME || + runText("gh", ["api", "user", "--jq", ".login"], { allowFailure: true }).trim() || + "unknown"; +const discoveryRepos = parseCsv( + process.env.CLAWOSS_MVP_DISCOVERY_REPOS || + "cli/cli,vitest-dev/vitest,astral-sh/ruff,expressjs/express,pallets/flask,psf/requests" +); + +childEnv = { + ...childEnv, + CLAW_AGENT_USERNAME: githubUser, +}; + +const runId = new Date().toISOString().replace(/[:.]/g, "-"); +const report = { + runId, + startedAt: new Date().toISOString(), + finishedAt: null, + mode: dryRun ? "dry-run" : "real-pr", + cyclesRequested: cycleTarget, + cyclesCompleted: 0, + provider, + model, + modelName, + githubAccount: githubUser, + dashboardUrl, + dashboardTelemetry: dashboardKey ? "enabled" : "disabled", + candidatesDiscovered: 0, + candidatesAfterFilters: 0, + attemptedTasks: 0, + createdPrs: 0, + dryRunStage: null, + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + costUsd: 0, + }, + budget: { + tokenBudgetTotal: numberOrNull(process.env.CLAWOSS_TOKEN_BUDGET_TOTAL), + costBudgetUsdTotal: numberOrNull(process.env.CLAWOSS_COST_BUDGET_USD_TOTAL), + latestDashboardBudget: null, + }, + pauseEvents: [], + failures: [], + discovered: [], + filtered: [], + attempts: [], +}; + +const attemptedKeys = new Set(); +let dashboardPolicy = { avoidRepos: [], reposWithOpenPRs: [], directives: [] }; + +main().catch((error) => { + recordFailure("runner", error?.message || String(error)); + finish(1); +}); + +async function main() { + if (!Number.isFinite(cycleTarget) || cycleTarget < 1) { + throw new Error("--cycles must be a positive number"); + } + mkdirSync(REPORTS, { recursive: true }); + + log("info", `MVP runner started: mode=${report.mode} cycles=${cycleTarget}`); + await postHeartbeat("alive", "mvp-runner started", { phase: "start" }); + await postMetric("start"); + + for (let cycle = 1; cycle <= cycleTarget; cycle += 1) { + const pauseState = await getPauseState(); + dashboardPolicy = pauseState.policy || dashboardPolicy; + if (pauseState.budget) report.budget.latestDashboardBudget = pauseState.budget; + + if (pauseState.paused) { + const event = { + cycle, + at: new Date().toISOString(), + source: pauseState.source, + reason: pauseState.reason || "dashboard requested pause", + }; + report.pauseEvents.push(event); + await postHeartbeat("degraded", `paused: ${event.reason}`, { + phase: "paused", + cycle, + pause: event, + }); + await postState("paused", null, [], { cycle, pause: event }); + log("warn", `pause active before cycle ${cycle}: ${event.reason}`); + break; + } + + report.cyclesCompleted += 1; + await postHeartbeat("alive", `mvp cycle ${cycle}/${cycleTarget}`, { + phase: "cycle", + cycle, + }); + + const candidates = discoverCandidates(maxCandidates); + report.candidatesDiscovered += candidates.length; + report.discovered.push( + ...candidates.map((candidate) => ({ cycle, ...candidateRef(candidate) })) + ); + log("info", `cycle ${cycle}: discovered ${candidates.length} candidate issue(s)`); + + const filtered = filterCandidates(candidates, cycle); + report.candidatesAfterFilters += filtered.length; + report.filtered.push( + ...filtered.map((candidate) => ({ cycle, ...candidateRef(candidate) })) + ); + + await postState("oss-triage", filtered[0] || null, filtered, { + phase: "filtered", + cycle, + discovered: candidates.length, + filtered: filtered.length, + policy: dashboardPolicy, + }); + + if (filtered.length === 0) { + recordFailure("filter", "no candidates survived MVP safety filters", { cycle }); + continue; + } + + const attempt = createPrPreflight(filtered[0], cycle); + attemptedKeys.add(`${attempt.repo}#${attempt.issue}`); + report.attempts.push(attempt); + report.attemptedTasks += 1; + report.dryRunStage = dryRun + ? "PR title/body/command generated; stopped before gh pr create" + : report.dryRunStage; + + await postState("oss-submit", filtered[0], filtered, { + phase: "pr-preflight", + cycle, + attempt, + }); + log("info", `cycle ${cycle}: PR preflight ready for ${attempt.repo}#${attempt.issue}`); + + if (!dryRun) { + recordFailure( + "real-pr", + "real PR mode requires an implementation workspace and verified patch before gh pr create", + { cycle, repo: attempt.repo, issue: attempt.issue } + ); + break; + } + } + + await postMetric("complete"); + await postHeartbeat("alive", "mvp-runner complete", { phase: "complete" }); + finish(0); +} + +function discoverCandidates(limit) { + if (args.issue) { + const parsed = parseIssueArg(args.issue); + if (!parsed) return []; + const issue = fetchIssue(parsed.repo, parsed.number); + return [issue || parsed]; + } + + const queries = [ + 'is:issue is:open label:"good first issue" archived:false', + 'is:issue is:open label:"help wanted" archived:false', + 'is:issue is:open label:bug archived:false', + 'is:issue is:open label:documentation archived:false', + 'is:issue is:open label:test archived:false', + ]; + + const seen = new Set(); + const candidates = []; + for (const query of queries) { + const perQuery = Math.max(5, Math.ceil(limit / queries.length)); + const out = runText( + "gh", + [ + "search", + "issues", + query, + "--limit", + String(perQuery), + "--json", + "repository,number,title,url,labels,createdAt,updatedAt", + ], + { allowFailure: true } + ); + for (const item of parseJson(out, [])) { + const repo = item.repository?.nameWithOwner || item.repository?.fullName; + if (!repo || !item.number) continue; + const key = `${repo}#${item.number}`; + if (seen.has(key)) continue; + seen.add(key); + candidates.push({ + repo, + number: item.number, + title: item.title || "", + url: item.url || `https://github.com/${repo}/issues/${item.number}`, + labels: (item.labels || []).map((label) => label.name || label).filter(Boolean), + createdAt: item.createdAt || null, + updatedAt: item.updatedAt || null, + }); + if (candidates.length >= limit) return candidates; + } + } + + if (candidates.length === 0) { + return discoverCandidatesFromRepos(limit); + } + + return candidates; +} + +function discoverCandidatesFromRepos(limit) { + const seen = new Set(); + const candidates = []; + const perRepo = Math.max(8, Math.ceil(limit * 1.5)); + + for (const repo of discoveryRepos) { + const out = runText( + "gh", + [ + "issue", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + String(perRepo), + "--json", + "number,title,url,labels,createdAt,updatedAt", + ], + { allowFailure: true } + ); + + for (const item of parseJson(out, [])) { + const candidate = { + repo, + number: item.number, + title: item.title || "", + url: item.url || `https://github.com/${repo}/issues/${item.number}`, + labels: (item.labels || []).map((label) => label.name || label).filter(Boolean), + createdAt: item.createdAt || null, + updatedAt: item.updatedAt || null, + }; + if (!isRepoScopedDiscoveryCandidate(candidate)) continue; + const key = `${candidate.repo}#${candidate.number}`; + if (seen.has(key)) continue; + seen.add(key); + candidates.push(candidate); + if (candidates.length >= limit) return candidates; + } + } + + return candidates; +} + +function fetchIssue(repo, number) { + const out = runText( + "gh", + [ + "api", + `repos/${repo}/issues/${number}`, + "--jq", + "{repo:\"" + repo + "\",number:.number,title:.title,url:.html_url,labels:[.labels[].name]}", + ], + { allowFailure: true } + ); + const parsed = parseJson(out, null); + if (!parsed || !parsed.number) return null; + return { + repo, + number: parsed.number, + title: parsed.title || "", + url: parsed.url || `https://github.com/${repo}/issues/${number}`, + labels: parsed.labels || [], + }; +} + +function filterCandidates(candidates, cycle) { + const ledger = existsSync(join(MEMORY, "pr-ledger.md")) + ? readFileSync(join(MEMORY, "pr-ledger.md"), "utf8") + : ""; + const filtered = []; + for (const candidate of candidates) { + const reason = rejectCandidate(candidate, ledger); + if (reason) { + recordFailure("filter", reason, { cycle, ...candidateRef(candidate) }); + continue; + } + filtered.push(candidate); + } + return filtered; +} + +function rejectCandidate(candidate, ledger) { + const key = `${candidate.repo}#${candidate.number}`; + if (!candidate.repo || !candidate.repo.includes("/")) return "invalid repository name"; + if (attemptedKeys.has(key)) return "duplicate: already attempted in this MVP run"; + if (ledger.includes(candidate.url) || ledger.includes(key)) { + return "duplicate: issue already appears in pr-ledger"; + } + if (dashboardPolicy.avoidRepos?.includes(candidate.repo)) { + return "dashboard avoidRepos policy"; + } + + const title = (candidate.title || "").toLowerCase(); + if (/\b(add|extend|enable|improve|enhance|feature|request|implement|support|introduce|create|propose|migrate|upgrade|refactor|redesign|optimize|allow|provide)\b/.test(title)) { + return "title suggests feature/refactor scope"; + } + + const labelText = (candidate.labels || []).join(" ").toLowerCase(); + if (/\b(enhancement|feature|feature-request|improvement|refactor|discussion|question|proposal|rfc|design|meta|chore|performance|optimization)\b/.test(labelText)) { + return "labels indicate non-MVP-safe scope"; + } + + const block = runJson("bash", [join(ROOT, "scripts/check-blocklist.sh"), candidate.repo]); + if (block?.blocked) return `blocklisted: ${block.reason || "repo blocked"}`; + + const contributing = runJson("bash", [join(ROOT, "scripts/check-contributing-guide.sh"), candidate.repo]); + if (contributing?.anti_bot) return "contribution guide rejects bot/AI submissions"; + if (contributing?.has_cla && contributing?.cla_type !== "dco") { + return `CLA required: ${contributing.cla_type || "unknown"}`; + } + + const supersession = runJson("bash", [ + join(ROOT, "scripts/check-supersession.sh"), + candidate.repo, + String(candidate.number), + ]); + if (supersession?.superseded) { + return `superseded: ${supersession.reason || "already claimed"}`; + } + + const fixed = runJson("bash", [ + join(ROOT, "scripts/check-already-fixed.sh"), + candidate.repo, + String(candidate.number), + ]); + if (fixed?.fixed) return `already fixed: ${fixed.reason || "upstream resolved"}`; + + const openByUser = runText( + "gh", + [ + "search", + "prs", + "--author", + githubUser, + "--repo", + candidate.repo, + "--state", + "open", + "--json", + "number", + "--jq", + "length", + ], + { allowFailure: true } + ).trim(); + if (Number(openByUser) > 0) { + return `duplicate risk: ${githubUser} already has an open PR in ${candidate.repo}`; + } + + return null; +} + +function createPrPreflight(candidate, cycle) { + const branch = `clawoss-${candidate.repo.replace("/", "-")}-${candidate.number}`; + const titlePrefix = inferTitlePrefix(candidate); + const prTitle = `${titlePrefix}: ${candidate.title}`.slice(0, 120); + const prBody = [ + `Fixes ${candidate.url}`, + "", + "Summary:", + "- prepared a scoped ClawOSS MVP dry-run contribution plan for the selected issue", + "- completed duplicate, CLA, already-fixed, blocklist, and dashboard avoidRepos checks", + "", + "Verification:", + "- ClawOSS MVP dry-run preflight completed", + "", + "Dry-run note:", + "- stopped before branch push and `gh pr create` because this run is configured as dry-run", + ].join("\n"); + + return { + cycle, + repo: candidate.repo, + issue: candidate.number, + issueUrl: candidate.url, + branch, + prTitle, + prBody, + readyToCreatePr: true, + dryRun, + stoppedBefore: "gh pr create", + blockedReason: dryRun + ? "controlled dry-run mode; no real PR created" + : "implementation patch must be verified before real PR creation", + createCommand: `gh pr create --repo ${candidate.repo} --head ${githubUser}:${branch} --title ${JSON.stringify(prTitle)} --body-file `, + at: new Date().toISOString(), + }; +} + +function inferTitlePrefix(candidate) { + const labels = (candidate.labels || []).join(" ").toLowerCase(); + const title = (candidate.title || "").toLowerCase(); + if (labels.includes("documentation") || title.includes("doc")) return "docs"; + if (labels.includes("test") || title.includes("test")) return "test"; + return "fix"; +} + +function isRepoScopedDiscoveryCandidate(candidate) { + const title = (candidate.title || "").toLowerCase(); + const labels = (candidate.labels || []).join(" ").toLowerCase(); + + if (labels.includes("good first issue")) return true; + if (labels.includes("help wanted")) return true; + if (labels.includes("bug")) return true; + if (labels.includes("documentation")) return true; + if (labels.includes("docs")) return true; + if (labels.includes("test")) return true; + if (labels.includes("tests")) return true; + + return /\b(bug|error|fail|failing|broken|typo|docs?|documentation|test|tests)\b/.test( + title + ); +} + +async function getPauseState() { + if (!dashboardUrl) return { paused: false, source: "no-dashboard-url" }; + const data = await requestDashboard("/api/agent/health-check", "GET"); + if (!data) return { paused: false, source: "dashboard-unreachable" }; + return { + paused: Boolean(data.pauseAgent || data.budget?.paused), + reason: data.pauseReason || data.budget?.pauseReason || null, + source: "dashboard-health-check", + budget: data.budget || null, + policy: { + avoidRepos: Array.isArray(data.avoidRepos) ? data.avoidRepos : [], + reposWithOpenPRs: Array.isArray(data.reposWithOpenPRs) + ? data.reposWithOpenPRs + : [], + directives: Array.isArray(data.directives) ? data.directives : [], + }, + }; +} + +async function postHeartbeat(status, currentTask, metadata) { + return requestDashboard("/api/ingest/heartbeat", "POST", { + status, + currentTask, + metadata: { + source: "mvp-runner", + runId, + model, + modelName, + provider, + runtime: runtimeMetadata(), + ...metadata, + }, + }); +} + +async function postState(currentSkill, candidate, queue, metadata) { + return requestDashboard("/api/ingest/state", "POST", { + currentSkill, + currentRepo: candidate?.repo || null, + currentIssue: candidate ? String(candidate.number) : null, + workQueue: queue.map(candidateRef), + pipelineState: metadata, + activeRepos: [...new Set(queue.map((item) => item.repo))], + metadata: { + source: "mvp-runner", + runId, + ...metadata, + }, + }); +} + +async function postMetric(channel) { + return requestDashboard("/api/ingest/metrics", "POST", { + metrics: [ + { + channel: `mvp-runner:${channel}`, + provider, + model, + inputTokens: 0, + outputTokens: 0, + costUsd: 0, + }, + ], + }); +} + +async function postLog(level, message, metadata = {}) { + return requestDashboard("/api/ingest/logs", "POST", { + entries: [ + { + level, + source: "mvp-runner", + message, + metadata: { runId, ...metadata }, + }, + ], + }); +} + +async function requestDashboard(path, method, body = null) { + if (!dashboardKey && method !== "GET") return null; + try { + const response = await fetch(`${dashboardUrl}${path}`, { + method, + headers: { + ...(dashboardKey ? { Authorization: `Bearer ${dashboardKey}` } : {}), + ...(body ? { "Content-Type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(8000), + }); + if (!response.ok) return null; + return response.json(); + } catch { + return null; + } +} + +function runtimeMetadata() { + return { + primaryModel: model, + primaryModelName: modelName, + primaryProvider: provider, + heartbeatIntervalMinutes: numberFrom( + process.env.CLAWOSS_HEARTBEAT_INTERVAL_MINUTES, + null, + 5 + ), + pricing: { + inputUsdPerMillionTokens: numberOrNull(process.env.CLAWOSS_MODEL_INPUT_COST), + outputUsdPerMillionTokens: numberOrNull(process.env.CLAWOSS_MODEL_OUTPUT_COST), + }, + budget: { + tokenBudgetTotal: report.budget.tokenBudgetTotal, + costBudgetUsdTotal: report.budget.costBudgetUsdTotal, + }, + }; +} + +function recordFailure(stage, reason, metadata = {}) { + const entry = { + stage, + reason, + at: new Date().toISOString(), + ...metadata, + }; + report.failures.push(entry); + postLog(stage === "filter" ? "info" : "warn", `${stage}: ${reason}`, entry).catch(() => {}); +} + +function log(level, message) { + console.log(`[${new Date().toISOString()}] ${level.toUpperCase()} ${message}`); + postLog(level, message).catch(() => {}); +} + +function finish(code) { + report.finishedAt = new Date().toISOString(); + const jsonPath = join(REPORTS, `mvp-run-${runId}.json`); + const mdPath = join(REPORTS, `mvp-run-${runId}.md`); + writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + writeFileSync(mdPath, renderReport(report), "utf8"); + console.log(`MVP report: ${mdPath}`); + process.exit(code); +} + +function renderReport(data) { + return `# ClawOSS Continuous Run MVP Report + +- Run ID: ${data.runId} +- Mode: ${data.mode} +- Runtime: ${data.startedAt} to ${data.finishedAt} +- Heartbeat cycles: ${data.cyclesCompleted}/${data.cyclesRequested} +- Model / provider: ${data.model || "unknown"} / ${data.provider || "unknown"} +- GitHub account: ${data.githubAccount} +- Dashboard telemetry: ${data.dashboardTelemetry} +- Candidate issues discovered: ${data.candidatesDiscovered} +- Candidates after filters: ${data.candidatesAfterFilters} +- Attempted tasks: ${data.attemptedTasks} +- PRs created: ${data.createdPrs} +- Dry-run stage: ${data.dryRunStage || "not reached"} +- Token usage: ${data.tokenUsage.totalTokens} total (${data.tokenUsage.inputTokens} input, ${data.tokenUsage.outputTokens} output) +- Cost usage: $${data.tokenUsage.costUsd.toFixed(6)} +- Pause / budget guardrail triggered: ${data.pauseEvents.length > 0 ? "yes" : "no"} + +## Attempts + +${data.attempts.length === 0 ? "No PR attempts were made." : data.attempts.map((item) => `- ${item.repo}#${item.issue}: ready=${item.readyToCreatePr}, stoppedBefore=${item.stoppedBefore}, branch=${item.branch}`).join("\n")} + +## Failures + +${data.failures.length === 0 ? "No failures recorded." : data.failures.map((item) => `- ${item.stage}${item.repo ? ` ${item.repo}#${item.number || item.issue || ""}` : ""}: ${item.reason}`).join("\n")} +`; +} + +function runJson(cmd, cmdArgs) { + const out = runText(cmd, cmdArgs, { allowFailure: true }).trim(); + return out ? parseJson(out, null) : null; +} + +function runText(cmd, cmdArgs, { allowFailure = false } = {}) { + const result = spawnSync(cmd, cmdArgs, { + cwd: ROOT, + env: childEnv, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + if (result.status !== 0 && !allowFailure) { + throw new Error(`${cmd} ${cmdArgs.join(" ")} failed: ${result.stderr || result.stdout}`); + } + return result.stdout || ""; +} + +function parseIssueArg(value) { + const match = String(value).match(/^([^/\s]+\/[^#\s]+)#(\d+)$/); + if (!match) return null; + return { + repo: match[1], + number: Number(match[2]), + title: `${match[1]} issue ${match[2]}`, + url: `https://github.com/${match[1]}/issues/${match[2]}`, + labels: [], + }; +} + +function candidateRef(candidate) { + return { + repo: candidate.repo, + number: candidate.number, + title: candidate.title, + url: candidate.url, + }; +} + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + if (!item.startsWith("--")) continue; + const key = item.slice(2); + if (index + 1 < argv.length && !argv[index + 1].startsWith("--")) { + parsed[key] = argv[index + 1]; + index += 1; + } else { + parsed[key] = true; + } + } + return parsed; +} + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function parseCsv(value) { + if (!value) return []; + return String(value) + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function loadEnv(path) { + if (!existsSync(path)) return; + for (const line of readFileSync(path, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match || process.env[match[1]] != null) continue; + process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, ""); + } +} + +function numberOrNull(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function numberFrom(value, fallbackValue, defaultValue) { + return numberOrNull(value) ?? numberOrNull(fallbackValue) ?? defaultValue; +} + +function stripTrailingSlash(value) { + return String(value || "").replace(/\/+$/, ""); +} diff --git a/scripts/pr-ledger-sync.sh b/scripts/pr-ledger-sync.sh index 9b0212c..bbae129 100755 --- a/scripts/pr-ledger-sync.sh +++ b/scripts/pr-ledger-sync.sh @@ -4,7 +4,7 @@ set -euo pipefail # pr-ledger-sync.sh — Keeps workspace/memory/pr-ledger.md in sync with GitHub # # Two data sources: -# 1. GitHub API: all PRs authored by BillionClaw (authoritative for status) +# 1. GitHub API: all PRs authored by the configured agent identity (authoritative for status) # 2. Subagent result files: picks up PRs before GitHub search indexes them # # Can run standalone or be called from dashboard-sync.sh every ~60s. @@ -14,7 +14,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" LEDGER="$PROJECT_DIR/workspace/memory/pr-ledger.md" RESULT_DIR="$PROJECT_DIR/workspace/memory" -AGENT_USER="${CLAW_AGENT_USERNAME:-BillionClaw}" +AGENT_USER="${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}" log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] pr-ledger-sync: $*"; } diff --git a/scripts/pr-portfolio-stats.sh b/scripts/pr-portfolio-stats.sh index a15eeb7..799c205 100755 --- a/scripts/pr-portfolio-stats.sh +++ b/scripts/pr-portfolio-stats.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash -# pr-portfolio-stats.sh — Quick stats on BillionClaw's PR portfolio +# pr-portfolio-stats.sh 鈥?Quick stats on onthebed's PR portfolio # Usage: pr-portfolio-stats.sh # Outputs JSON with open/merged/closed counts, merge rate, approved PRs -OPEN=$(gh search prs --author BillionClaw --state open --json number --jq 'length' 2>/dev/null || echo 0) -# gh search prs has no --merged flag — use "is:merged" in the query -MERGED=$(gh search prs --author BillionClaw "is:merged" --json number --jq 'length' 2>/dev/null || echo 0) -CLOSED_UNMERGED=$(gh search prs --author BillionClaw --state closed "is:unmerged" --json number --jq 'length' 2>/dev/null || echo 0) +AGENT_USER="${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}" + +OPEN=$(gh search prs --author "$AGENT_USER" --state open --json number --jq 'length' 2>/dev/null || echo 0) +# gh search prs has no --merged flag 鈥?use "is:merged" in the query +MERGED=$(gh search prs --author "$AGENT_USER" "is:merged" --json number --jq 'length' 2>/dev/null || echo 0) +CLOSED_UNMERGED=$(gh search prs --author "$AGENT_USER" --state closed "is:unmerged" --json number --jq 'length' 2>/dev/null || echo 0) TOTAL=$((OPEN + MERGED + CLOSED_UNMERGED)) if [ "$TOTAL" -gt 0 ]; then diff --git a/scripts/render-openclaw-config.mjs b/scripts/render-openclaw-config.mjs new file mode 100644 index 0000000..e6258a6 --- /dev/null +++ b/scripts/render-openclaw-config.mjs @@ -0,0 +1,371 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, ".."); +const TEMPLATE_PATH = join(ROOT, "config", "openclaw.json"); + +const argv = process.argv.slice(2); +const outputFlagIndex = argv.indexOf("--output"); +const outputPath = outputFlagIndex >= 0 ? argv[outputFlagIndex + 1] : null; +const printPrimaryModelOnly = argv.includes("--print-primary-model"); + +function fail(message) { + console.error(`[ClawOSS配置] ${message}`); + process.exit(1); +} + +function parseNumber(value, fieldName, fallback = null) { + if (value == null || value === "") return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + fail(`${fieldName} 必须是数字,当前值: ${value}`); + } + return parsed; +} + +function parseBoolean(value, fallback = false) { + if (value == null || value === "") return fallback; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + fail(`布尔值无效: ${value}`); +} + +function parseCsv(value) { + if (!value) return []; + return String(value) + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseJsonEnv(name) { + const raw = process.env[name]; + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (error) { + fail(`${name} 不是合法 JSON: ${error.message}`); + } +} + +function substitutePlaceholders(value, replacements) { + if (typeof value === "string") { + let next = value; + for (const [placeholder, replacement] of Object.entries(replacements)) { + next = next.split(placeholder).join(replacement); + } + return next; + } + if (Array.isArray(value)) { + return value.map((item) => substitutePlaceholders(item, replacements)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + substitutePlaceholders(item, replacements), + ]) + ); + } + return value; +} + +function collectEnvPlaceholders(value, bucket = new Set()) { + if (typeof value === "string") { + for (const match of value.matchAll(/\$\{([A-Z0-9_]+)\}/g)) { + bucket.add(match[1]); + } + return bucket; + } + if (Array.isArray(value)) { + value.forEach((item) => collectEnvPlaceholders(item, bucket)); + return bucket; + } + if (value && typeof value === "object") { + Object.values(value).forEach((item) => collectEnvPlaceholders(item, bucket)); + } + return bucket; +} + +function listProviderModels(providers) { + return Object.entries(providers).flatMap(([providerId, provider]) => { + const models = Array.isArray(provider?.models) ? provider.models : []; + return models.map((model) => ({ providerId, model })); + }); +} + +function findPrimaryModelDefinition(providers, primaryModel) { + const [providerId, ...modelParts] = String(primaryModel).split("/"); + const modelId = modelParts.join("/"); + if (!providerId || !modelId) { + fail(`CLAWOSS_PRIMARY_MODEL 格式必须是 provider/model,当前值: ${primaryModel}`); + } + + const provider = providers[providerId]; + if (!provider) { + fail(`找不到主模型 provider: ${providerId}`); + } + + const model = (provider.models || []).find((item) => item?.id === modelId); + if (!model) { + fail(`找不到主模型定义: ${primaryModel}`); + } + + return { providerId, modelId, provider, model }; +} + +function buildProvidersFromSimpleEnv() { + const providerId = process.env.CLAWOSS_PROVIDER_ID || "custom"; + const modelId = process.env.CLAWOSS_MODEL_ID; + const baseUrl = process.env.CLAWOSS_PROVIDER_BASE_URL; + const apiFormat = + process.env.CLAWOSS_PROVIDER_API_FORMAT || "openai-completions"; + const apiKeyEnv = process.env.CLAWOSS_PROVIDER_API_KEY_ENV; + const apiKeyLiteral = process.env.CLAWOSS_PROVIDER_API_KEY; + + if (!modelId) { + fail( + "未配置模型。请设置 CLAWOSS_MODEL_PROVIDERS_JSON,或至少提供 CLAWOSS_MODEL_ID。" + ); + } + if (!baseUrl) { + fail("未配置 CLAWOSS_PROVIDER_BASE_URL。"); + } + + let apiKey = apiKeyLiteral || ""; + if (apiKeyEnv) { + apiKey = `\${${apiKeyEnv}}`; + } + if (!apiKey) { + fail( + "未配置模型 API Key。请设置 CLAWOSS_PROVIDER_API_KEY_ENV 或 CLAWOSS_PROVIDER_API_KEY。" + ); + } + + const modelName = process.env.CLAWOSS_MODEL_NAME || modelId; + const inputCost = parseNumber( + process.env.CLAWOSS_MODEL_INPUT_COST, + "CLAWOSS_MODEL_INPUT_COST", + 0 + ); + const outputCost = parseNumber( + process.env.CLAWOSS_MODEL_OUTPUT_COST, + "CLAWOSS_MODEL_OUTPUT_COST", + 0 + ); + const cacheRead = parseNumber( + process.env.CLAWOSS_MODEL_CACHE_READ_COST, + "CLAWOSS_MODEL_CACHE_READ_COST", + 0 + ); + const cacheWrite = parseNumber( + process.env.CLAWOSS_MODEL_CACHE_WRITE_COST, + "CLAWOSS_MODEL_CACHE_WRITE_COST", + 0 + ); + + const model = { + id: modelId, + name: modelName, + reasoning: parseBoolean(process.env.CLAWOSS_MODEL_REASONING, false), + input: ["text"], + cost: { + input: inputCost, + output: outputCost, + cacheRead, + cacheWrite, + }, + contextWindow: parseNumber( + process.env.CLAWOSS_MODEL_CONTEXT_WINDOW, + "CLAWOSS_MODEL_CONTEXT_WINDOW", + 131072 + ), + maxTokens: parseNumber( + process.env.CLAWOSS_MODEL_MAX_TOKENS, + "CLAWOSS_MODEL_MAX_TOKENS", + 16384 + ), + }; + + return { + providers: { + [providerId]: { + baseUrl, + apiKey, + api: apiFormat, + authHeader: parseBoolean(process.env.CLAWOSS_PROVIDER_AUTH_HEADER, true), + models: [model], + }, + }, + primaryModel: `${providerId}/${modelId}`, + }; +} + +function resolveModelConfig() { + const providersFromEnv = parseJsonEnv("CLAWOSS_MODEL_PROVIDERS_JSON"); + if (providersFromEnv) { + if ( + typeof providersFromEnv !== "object" || + Array.isArray(providersFromEnv) || + Object.keys(providersFromEnv).length === 0 + ) { + fail("CLAWOSS_MODEL_PROVIDERS_JSON 必须是非空对象。"); + } + const models = listProviderModels(providersFromEnv); + if (models.length === 0) { + fail("CLAWOSS_MODEL_PROVIDERS_JSON 中至少要提供一个 models 条目。"); + } + + return { + providers: providersFromEnv, + primaryModel: + process.env.CLAWOSS_PRIMARY_MODEL || + `${models[0].providerId}/${models[0].model.id}`, + }; + } + + return buildProvidersFromSimpleEnv(); +} + +function parseHeartbeatMinutes(config) { + const every = config?.agents?.list?.[0]?.heartbeat?.every || "5m"; + const match = String(every).match(/^(\d+)m$/i); + return match ? Number(match[1]) : 5; +} + +const template = JSON.parse(readFileSync(TEMPLATE_PATH, "utf8")); +const projectDir = ROOT; +const workspaceDir = join(ROOT, "workspace"); +const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + +const config = substitutePlaceholders(template, { + "__PROJECT_DIR__": projectDir, + "__WORKSPACE_PATH__": workspaceDir, + "__HOME_DIR__": homeDir, +}); + +const heartbeatPrompt = config?.agents?.list?.[0]?.heartbeat?.prompt; +if ( + typeof heartbeatPrompt === "string" && + !heartbeatPrompt.includes("SUBAGENT SPAWN COMPATIBILITY:") +) { + config.agents.list[0].heartbeat.prompt = `${heartbeatPrompt}\n\nSUBAGENT SPAWN COMPATIBILITY: For runtime=subagent, NEVER pass streamTo. Current OpenClaw rejects streamTo for subagent runtime. Use only runtime, mode, label, task, attachments, runTimeoutSeconds, timeoutSeconds, and lightContext.`; +} + +const { providers, primaryModel } = resolveModelConfig(); +const fallbackModels = parseCsv(process.env.CLAWOSS_FALLBACK_MODELS).filter( + (model) => model !== primaryModel +); + +const heartbeatIntervalMinutes = parseNumber( + process.env.CLAWOSS_HEARTBEAT_INTERVAL_MINUTES, + "CLAWOSS_HEARTBEAT_INTERVAL_MINUTES", + parseHeartbeatMinutes(config) +); + +const primaryDefinition = findPrimaryModelDefinition(providers, primaryModel); +const primaryModelName = + primaryDefinition.model.name || primaryDefinition.model.id || primaryModel; +const inputCostPerMillionTokens = + parseNumber( + primaryDefinition.model?.cost?.input, + "primaryDefinition.model.cost.input", + 0 + ) ?? 0; +const outputCostPerMillionTokens = + parseNumber( + primaryDefinition.model?.cost?.output, + "primaryDefinition.model.cost.output", + 0 + ) ?? 0; + +config.agents.defaults.model.primary = primaryModel; +config.agents.defaults.model.fallbacks = fallbackModels; +config.agents.defaults.subagents.model = primaryModel; +config.models.providers = providers; + +config.agents.list = (config.agents.list || []).map((agent) => ({ + ...agent, + model: primaryModel, + heartbeat: { + ...agent.heartbeat, + model: primaryModel, + every: `${heartbeatIntervalMinutes}m`, + }, +})); + +const dashboardUrl = + process.env.DASHBOARD_URL || "https://clawoss-dashboard.vercel.app"; +const normalizedDashboardUrl = dashboardUrl.replace(/\/+$/, ""); +const healthCheckUrl = `${normalizedDashboardUrl}/api/agent/health-check`; +const tokenBudgetTotal = parseNumber( + process.env.CLAWOSS_TOKEN_BUDGET_TOTAL, + "CLAWOSS_TOKEN_BUDGET_TOTAL", + null +); +const costBudgetUsdTotal = parseNumber( + process.env.CLAWOSS_COST_BUDGET_USD_TOTAL, + "CLAWOSS_COST_BUDGET_USD_TOTAL", + null +); + +config.env = { + ...(config.env || {}), + DASHBOARD_URL: normalizedDashboardUrl, + CLAWOSS_PROJECT_DIR: projectDir, + CLAWOSS_WORKSPACE_DIR: workspaceDir, + CLAWOSS_PRIMARY_MODEL: primaryModel, + CLAWOSS_PRIMARY_MODEL_NAME: primaryModelName, + CLAWOSS_PRIMARY_PROVIDER: primaryDefinition.providerId, + CLAWOSS_PRIMARY_INPUT_COST_PER_MTOKENS: String(inputCostPerMillionTokens), + CLAWOSS_PRIMARY_OUTPUT_COST_PER_MTOKENS: String(outputCostPerMillionTokens), + CLAWOSS_HEARTBEAT_INTERVAL_MINUTES: String(heartbeatIntervalMinutes), + CLAWOSS_HEALTHCHECK_URL: healthCheckUrl, +}; + +if (fallbackModels.length > 0) { + config.env.CLAWOSS_FALLBACK_MODELS = fallbackModels.join(","); +} +if (tokenBudgetTotal != null) { + config.env.CLAWOSS_TOKEN_BUDGET_TOTAL = String(tokenBudgetTotal); +} +if (costBudgetUsdTotal != null) { + config.env.CLAWOSS_COST_BUDGET_USD_TOTAL = String(costBudgetUsdTotal); +} +if (process.env.CLAW_API_KEY) { + config.env.CLAW_API_KEY = process.env.CLAW_API_KEY; +} +if (process.env.GITHUB_TOKEN) { + config.env.GITHUB_TOKEN = process.env.GITHUB_TOKEN; +} +if (process.env.GITHUB_USERNAME) { + config.env.GITHUB_USERNAME = process.env.GITHUB_USERNAME; + config.env.CLAW_AGENT_USERNAME = process.env.GITHUB_USERNAME; +} +if (process.env.GITHUB_EMAIL) { + config.env.GITHUB_EMAIL = process.env.GITHUB_EMAIL; +} + +for (const envName of collectEnvPlaceholders(config)) { + if (process.env[envName]) { + config.env[envName] = process.env[envName]; + } +} + +if (printPrimaryModelOnly) { + process.stdout.write(primaryModel); + process.exit(0); +} + +const output = `${JSON.stringify(config, null, 2)}\n`; + +if (outputPath) { + writeFileSync(outputPath, output, "utf8"); +} else { + process.stdout.write(output); +} diff --git a/scripts/repo-health-check.sh b/scripts/repo-health-check.sh index caa9bf3..e2f0222 100755 --- a/scripts/repo-health-check.sh +++ b/scripts/repo-health-check.sh @@ -18,6 +18,9 @@ REPO="$1" OWNER="${REPO%%/*}" REPO_NAME="${REPO##*/}" THRESHOLD="${2:-5}" # minimum composite score, default 5 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-${CLAWOSS_PROJECT_DIR:-$(dirname "$SCRIPT_DIR")}}" +TRUST_FILE="${PROJECT_DIR}/workspace/memory/trust-repos.md" # Date calculations (macOS + Linux compatible) if date -v-1d +%Y-%m-%d &>/dev/null; then @@ -32,6 +35,19 @@ score=0 reasons=() warnings=() +is_trusted_repo() { + local repo="$1" + [ -f "$TRUST_FILE" ] || return 1 + grep -qiE "^[[:space:]]*-[[:space:]]*${repo//\//\\/}([[:space:]]|$)" "$TRUST_FILE" +} + +TRUSTED_REPO=false +if is_trusted_repo "$REPO"; then + TRUSTED_REPO=true + score=$((score + 2)) + warnings+=("trusted repo override enabled for ${REPO}") +fi + # Helper: emit JSON and exit with failure fail() { local reason="$1" @@ -234,15 +250,20 @@ if [ "$STARS" -ge 5000 ]; then else REVIEW_MIN=50 fi -if [ "$REVIEW_RATE" -lt "$REVIEW_MIN" ]; then +if [ "$REVIEW_RATE" -lt "$REVIEW_MIN" ] && [ "$TRUSTED_REPO" != "true" ]; then reasons+=("review_rate=${REVIEW_RATE}% (<${REVIEW_MIN}%)") fail "review rate ${REVIEW_RATE}% (<${REVIEW_MIN}%)" "repo_health_fail: review rate ${REVIEW_RATE}% below ${REVIEW_MIN}% minimum" fi +if [ "$REVIEW_RATE" -lt "$REVIEW_MIN" ] && [ "$TRUSTED_REPO" = "true" ]; then + warnings+=("trusted repo ${REPO} bypassed review-rate floor (${REVIEW_RATE}% < ${REVIEW_MIN}%)") +fi if [ "$REVIEW_RATE" -ge 80 ]; then score=$((score + 3)) elif [ "$REVIEW_RATE" -ge 60 ]; then score=$((score + 2)) -else +elif [ "$REVIEW_RATE" -ge "$REVIEW_MIN" ]; then + score=$((score + 1)) +elif [ "$TRUSTED_REPO" = "true" ]; then score=$((score + 1)) fi @@ -285,6 +306,7 @@ done # All other CLA repos are allowed — we sign CLAs via CLA-assistant or DCO. HAS_CLA=false +HAS_DCO=false AUTOMATABLE_CLA_ORGS="deepset-ai iterative Aider-AI milvus-io BerriAI" for org in $AUTOMATABLE_CLA_ORGS; do if [ "$OWNER" = "$org" ]; then HAS_CLA=true; break; fi @@ -295,11 +317,20 @@ if [ "$HAS_CLA" = "false" ]; then fi if [ "$HAS_CLA" = "false" ]; then CLA_ACTION=$(gh api "repos/${REPO}/contents/.github/workflows" \ - --jq '[.[] | select(.name | test("cla|dco"; "i"))] | length' 2>/dev/null || echo "0") + --jq '[.[] | select(.name | test("cla"; "i"))] | length' 2>/dev/null || echo "0") [ "$CLA_ACTION" -gt 0 ] && HAS_CLA=true fi if [ "$HAS_CLA" = "true" ]; then - warnings+=("CLA/DCO required — sign it before submitting PR") + warnings+=("CLA required — manual signing needed before submitting PR") +fi + +if [ "$HAS_CLA" = "false" ]; then + DCO_ACTION=$(gh api "repos/${REPO}/contents/.github/workflows" \ + --jq '[.[] | select(.name | test("dco"; "i"))] | length' 2>/dev/null || echo "0") + [ "$DCO_ACTION" -gt 0 ] && HAS_DCO=true +fi +if [ "$HAS_DCO" = "true" ]; then + warnings+=("DCO required — use signed-off commits when submitting PR") fi # ─── 8. Niche fit (agentic AI) ─── @@ -364,8 +395,11 @@ cat < [--message ] # Actions: merge, bump, identity, close-fixed, close-invalid, thank, comment # Exit 0 = action taken, Exit 1 = failed @@ -8,6 +8,7 @@ REPO="${1:?Usage: respond-to-review.sh }" PR_NUM="${2:?Usage: respond-to-review.sh }" ACTION="${3:?Usage: respond-to-review.sh }" MESSAGE="" +AGENT_USER="${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}" shift 3 while [ $# -gt 0 ]; do @@ -41,14 +42,14 @@ case "$ACTION" in identity) # Respond to "are you a bot?" questions gh api "repos/${REPO}/issues/${PR_NUM}/comments" \ - -f body="${MESSAGE:-This is BillionClaw. Happy to discuss the approach or make adjustments to the fix.}" 2>/dev/null + -f body="${MESSAGE:-This is ${AGENT_USER}. Happy to discuss the approach or make adjustments to the fix.}" 2>/dev/null [ $? -eq 0 ] && echo '{"success": true, "action": "identity"}' || fail "Failed to post identity response" ;; close-fixed) # Close PR because the issue was fixed elsewhere gh pr close "$PR_NUM" --repo "$REPO" \ - --comment "${MESSAGE:-Closing — the underlying issue has been resolved in another PR. Thank you for the review time!}" 2>/dev/null + --comment "${MESSAGE:-Closing 鈥?the underlying issue has been resolved in another PR. Thank you for the review time!}" 2>/dev/null [ $? -eq 0 ] && echo '{"success": true, "action": "close-fixed"}' || fail "Failed to close PR" ;; diff --git a/scripts/restart.sh b/scripts/restart.sh index 7f008ae..6531d9e 100755 --- a/scripts/restart.sh +++ b/scripts/restart.sh @@ -15,6 +15,8 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" WORKSPACE_DIR="$PROJECT_DIR/workspace" DEPLOYED_CONFIG="$HOME/.openclaw/openclaw.json" GATEWAY_PLIST="$HOME/Library/LaunchAgents/ai.openclaw.gateway.plist" +export CLAWOSS_PROJECT_DIR="${CLAWOSS_PROJECT_DIR:-$PROJECT_DIR}" +export CLAWOSS_WORKSPACE_DIR="${CLAWOSS_WORKSPACE_DIR:-$WORKSPACE_DIR}" # ── 0. Preflight checks ────────────────────────────────────────────── MISSING=() @@ -54,8 +56,8 @@ else fi # ── 2. Git identity ────────────────────────────────────────────────── -GITHUB_USERNAME="${GITHUB_USERNAME:-BillionClaw}" -GITHUB_EMAIL="${GITHUB_EMAIL:-267901332+BillionClaw@users.noreply.github.com}" +GITHUB_USERNAME="${GITHUB_USERNAME:-clawoss-agent}" +GITHUB_EMAIL="${GITHUB_EMAIL:-clawoss-agent@users.noreply.github.com}" git config --global user.name "$GITHUB_USERNAME" git config --global user.email "$GITHUB_EMAIL" echo "[OK] Git identity: $GITHUB_USERNAME <$GITHUB_EMAIL>" @@ -91,20 +93,10 @@ fi # Preserves gateway-managed sections (meta, commands, plugins, gateway.auth) # while overlaying all agent/tool/skill settings from the repo config. -REPO_CONFIG_RESOLVED=$(sed \ - -e "s|__WORKSPACE_PATH__|$WORKSPACE_DIR|g" \ - -e "s|__PROJECT_DIR__|$PROJECT_DIR|g" \ - -e "s|__HOME_DIR__|$HOME|g" \ - "$PROJECT_DIR/config/openclaw.json") +REPO_CONFIG_RESOLVED=$(node "$PROJECT_DIR/scripts/render-openclaw-config.mjs") _REPO_CONFIG="$REPO_CONFIG_RESOLVED" \ _DEPLOYED="$DEPLOYED_CONFIG" \ -_KIMI_KEY="${KIMI_API_KEY:-}" \ -_MINIMAX_KEY="${MINIMAX_API_KEY:-}" \ -_GH_TOKEN="${GITHUB_TOKEN:-}" \ -_DASH_URL="${DASHBOARD_URL:-https://clawoss-dashboard.vercel.app}" \ -_CLAW_KEY="${CLAW_API_KEY:-}" \ -_OPENROUTER_KEY="${OPENROUTER_API_KEY:-}" \ python3 -c " import json, os @@ -128,21 +120,6 @@ except (FileNotFoundError, json.JSONDecodeError): merged = deep_merge(deployed, repo_config) -# Inject env vars (non-empty only) -merged.setdefault('env', {}) -env_map = { - 'KIMI_API_KEY': os.environ.get('_KIMI_KEY', ''), - 'MINIMAX_API_KEY': os.environ.get('_MINIMAX_KEY', ''), - 'GITHUB_TOKEN': os.environ.get('_GH_TOKEN', ''), - 'DASHBOARD_URL': os.environ.get('_DASH_URL', ''), - 'CLAW_API_KEY': os.environ.get('_CLAW_KEY', ''), - 'OPENROUTER_API_KEY': os.environ.get('_OPENROUTER_KEY', ''), -} -for k, v in env_map.items(): - if v: - merged['env'][k] = v -merged['env'] = {k: v for k, v in merged['env'].items() if v} - with open(deployed_path, 'w') as f: json.dump(merged, f, indent=2) f.write('\n') @@ -374,13 +351,17 @@ fi # ── 15b. Ensure dual push remotes (CMLKevin + billion-token-one-task) ── cd "$PROJECT_DIR" -# Add billionclaw as second push URL so `git push origin` goes to both repos -PUSH_URLS=$(git remote get-url --push --all origin 2>/dev/null || echo "") -if ! echo "$PUSH_URLS" | grep -q "billion-token-one-task"; then - git remote set-url --add --push origin https://github.com/billion-token-one-task/ClawOSS.git 2>/dev/null || true - echo "[OK] Added billion-token-one-task as second push target" +# Add the canonical upstream mirror as a second push URL so `git push origin` can reach both remotes when desired +if [ -n "${CLAWOSS_EXTRA_PUSH_URL:-}" ]; then + PUSH_URLS=$(git remote get-url --push --all origin 2>/dev/null || echo "") + if ! echo "$PUSH_URLS" | grep -q "^${CLAWOSS_EXTRA_PUSH_URL}$"; then + git remote set-url --add --push origin "$CLAWOSS_EXTRA_PUSH_URL" 2>/dev/null || true + echo "[OK] Added extra push target from CLAWOSS_EXTRA_PUSH_URL" + else + echo "[OK] Extra push target already configured" + fi else - echo "[OK] Dual push remotes already configured" + echo "[INFO] CLAWOSS_EXTRA_PUSH_URL not set - skipping extra push target" fi # ── 16. Kick the agent ─────────────────────────────────────────────── @@ -414,12 +395,12 @@ fi # ── Summary ─────────────────────────────────────────────────────────── echo "" echo "=== ClawOSS V10 Running ===" -echo " Model: minimax/m2.7 (MiniMax M2.7, 204k context) + kimi-coding/k2p5 fallback" -echo " Dashboard: https://clawoss-dashboard.vercel.app" +echo " Model: $(node "$PROJECT_DIR/scripts/render-openclaw-config.mjs" --print-primary-model 2>/dev/null || echo unknown)" +echo " Dashboard: $DASH_URL" echo " Slots: 3 always-on (scout + PR monitor + PR analyst) + 10 impl/followup = 13" echo " Heartbeat: 5m" echo " Logs: openclaw logs" -echo " PRs: gh search prs --author BillionClaw --state open" +echo " PRs: gh search prs --author ${GITHUB_USERNAME} --state open" echo " Stop: openclaw gateway stop && pkill -f dashboard-sync" echo "" echo "V10.1 features: P(merge) scoring, no per-repo PR cap," diff --git a/scripts/scan-pr-reviews.sh b/scripts/scan-pr-reviews.sh index d6e1a75..bf54e5c 100755 --- a/scripts/scan-pr-reviews.sh +++ b/scripts/scan-pr-reviews.sh @@ -52,7 +52,8 @@ has_approval = 'APPROVED' in states has_changes = 'CHANGES_REQUESTED' in states # Unanswered maintainer comments -unanswered = [c for c in comments if c['user'] != 'BillionClaw'][:1] +agent_user = os.environ.get("CLAW_AGENT_USERNAME") or os.environ.get("GITHUB_USERNAME") or "clawoss-agent" +unanswered = [c for c in comments if c['user'] != agent_user][:1] # Determine state + action if has_changes: diff --git a/scripts/setup.sh b/scripts/setup.sh index 16b22dd..5888270 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -3,48 +3,48 @@ set -euo pipefail echo "=== ClawOSS Setup ===" -# Auto-detect paths SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" WORKSPACE_DIR="$PROJECT_DIR/workspace" +export CLAWOSS_PROJECT_DIR="${CLAWOSS_PROJECT_DIR:-$PROJECT_DIR}" +export CLAWOSS_WORKSPACE_DIR="${CLAWOSS_WORKSPACE_DIR:-$WORKSPACE_DIR}" -# Check prerequisites echo "Checking prerequisites..." command -v openclaw >/dev/null 2>&1 || { echo "Error: openclaw not found. Install: npm i -g openclaw"; exit 1; } -command -v gh >/dev/null 2>&1 || { echo "Error: gh not found. Install: brew install gh"; exit 1; } +command -v gh >/dev/null 2>&1 || { echo "Error: gh not found. Install gh first"; exit 1; } command -v node >/dev/null 2>&1 || { echo "Error: node not found. Install Node.js"; exit 1; } command -v python3 >/dev/null 2>&1 || { echo "Error: python3 not found. Install Python 3"; exit 1; } -command -v jq >/dev/null 2>&1 || { echo "Error: jq not found. Install: brew install jq"; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "Error: jq not found. Install jq first"; exit 1; } echo "[OK] All prerequisites found" -# Load .env for API keys if [ -f "$PROJECT_DIR/.env" ]; then - set -a; source "$PROJECT_DIR/.env"; set +a + set -a + source "$PROJECT_DIR/.env" + set +a echo "[OK] Loaded .env" else echo "Error: .env not found. Run: cp .env.example .env && edit .env" exit 1 fi -# Validate required env vars if [ -z "${GITHUB_TOKEN:-}" ]; then echo "Error: GITHUB_TOKEN not set in .env" exit 1 fi -if [ -z "${KIMI_API_KEY:-}" ]; then - echo "Error: KIMI_API_KEY not set in .env (required — OpenRouter is not supported due to content filter)" + +PRIMARY_MODEL="$(node "$PROJECT_DIR/scripts/render-openclaw-config.mjs" --print-primary-model 2>/dev/null || true)" +if [ -z "$PRIMARY_MODEL" ]; then + echo "Error: LLM runtime config is invalid. Check CLAWOSS_MODEL_* or CLAWOSS_MODEL_PROVIDERS_JSON." exit 1 fi -echo "[OK] API keys configured" +echo "[OK] Runtime configured: $PRIMARY_MODEL" -# Configure git identity -GITHUB_USERNAME="${GITHUB_USERNAME:-BillionClaw}" -GITHUB_EMAIL="${GITHUB_EMAIL:-267901332+BillionClaw@users.noreply.github.com}" +GITHUB_USERNAME="${GITHUB_USERNAME:-clawoss-agent}" +GITHUB_EMAIL="${GITHUB_EMAIL:-clawoss-agent@users.noreply.github.com}" git config --global user.name "$GITHUB_USERNAME" git config --global user.email "$GITHUB_EMAIL" echo "[OK] Git identity: $GITHUB_USERNAME <$GITHUB_EMAIL>" -# Authenticate GitHub CLI if gh auth status >/dev/null 2>&1; then echo "[OK] GitHub CLI already authenticated" else @@ -57,7 +57,6 @@ else fi fi -# Create workspace symlink OPENCLAW_DIR="$HOME/.openclaw" mkdir -p "$OPENCLAW_DIR" WORKSPACE_LINK="$OPENCLAW_DIR/workspace" @@ -73,40 +72,10 @@ else echo "[OK] Workspace linked" fi -# Deploy config with path substitution echo "Deploying config..." -sed \ - -e "s|__WORKSPACE_PATH__|$WORKSPACE_DIR|g" \ - -e "s|__PROJECT_DIR__|$PROJECT_DIR|g" \ - -e "s|__HOME_DIR__|$HOME|g" \ - "$PROJECT_DIR/config/openclaw.json" > "$OPENCLAW_DIR/openclaw.json" - -# Inject env vars into deployed config (via env vars, not shell interpolation) -_CONFIG_PATH="$OPENCLAW_DIR/openclaw.json" \ -_KIMI_KEY="${KIMI_API_KEY:-}" \ -_GH_TOKEN="${GITHUB_TOKEN:-}" \ -_DASH_URL="${DASHBOARD_URL:-https://clawoss-dashboard.vercel.app}" \ -_CLAW_KEY="${CLAW_API_KEY:-}" \ -python3 -c " -import json, os -config_path = os.environ['_CONFIG_PATH'] -with open(config_path) as f: c = json.load(f) -c.setdefault('env', {}) -env_vars = { - 'KIMI_API_KEY': os.environ.get('_KIMI_KEY', ''), - 'GITHUB_TOKEN': os.environ.get('_GH_TOKEN', ''), - 'DASHBOARD_URL': os.environ.get('_DASH_URL', ''), - 'CLAW_API_KEY': os.environ.get('_CLAW_KEY', ''), -} -for k, v in env_vars.items(): - if v: - c['env'][k] = v -c['env'] = {k: v for k, v in c['env'].items() if v} -with open(config_path, 'w') as f: json.dump(c, f, indent=2) -" 2>/dev/null -echo "[OK] Config deployed with env vars" - -# Install PR ledger sync launchd plist +node "$PROJECT_DIR/scripts/render-openclaw-config.mjs" --output "$OPENCLAW_DIR/openclaw.json" +echo "[OK] Config deployed for model: $PRIMARY_MODEL" + PLIST_SRC="$PROJECT_DIR/config/com.clawoss.pr-ledger-sync.plist" PLIST_DST="$HOME/Library/LaunchAgents/com.clawoss.pr-ledger-sync.plist" if [ -f "$PLIST_SRC" ]; then @@ -119,7 +88,6 @@ if [ -f "$PLIST_SRC" ]; then echo "[OK] PR ledger sync installed (launchd, 60s interval)" fi -# Install PII sanitizer plugin PLUGIN_SRC="$PROJECT_DIR/plugins/pii-sanitizer" PLUGIN_DST="$OPENCLAW_DIR/extensions/clawoss-pii-sanitizer" if [ -d "$PLUGIN_SRC" ]; then @@ -128,7 +96,6 @@ if [ -d "$PLUGIN_SRC" ]; then echo "[OK] PII sanitizer plugin installed" fi -# Symlink skills echo "Linking skills..." mkdir -p "$OPENCLAW_DIR/skills" for skill in "$WORKSPACE_DIR/skills"/*/; do @@ -138,16 +105,17 @@ for skill in "$WORKSPACE_DIR/skills"/*/; do echo " Linked: $name" done -# Create working directories mkdir -p "$OPENCLAW_DIR/logs" mkdir -p "$WORKSPACE_DIR/memory/repos" mkdir -p "$WORKSPACE_DIR/memory/issues" +mkdir -p "$WORKSPACE_DIR/memory/locks" echo "[OK] Directories ready" echo "" echo "=== Setup Complete ===" echo " Project: $PROJECT_DIR" echo " Workspace: $WORKSPACE_DIR" +echo " Primary model: $PRIMARY_MODEL" echo "" echo "Next steps:" echo " bash scripts/restart.sh # Start the agent" diff --git a/scripts/start.sh b/scripts/start.sh index 310bb33..7629fb7 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -7,32 +7,42 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" AGENT_ID="clawoss" WORKSPACE_DIR="$PROJECT_DIR/workspace" +export CLAWOSS_PROJECT_DIR="${CLAWOSS_PROJECT_DIR:-$PROJECT_DIR}" +export CLAWOSS_WORKSPACE_DIR="${CLAWOSS_WORKSPACE_DIR:-$WORKSPACE_DIR}" + +if [ -f "$PROJECT_DIR/.env" ]; then + set -a + source "$PROJECT_DIR/.env" + set +a +fi -# Verify setup if [ ! -L "$HOME/.openclaw/workspace" ]; then echo "Error: workspace not linked. Run 'npm run setup' first." exit 1 fi -# Register the clawoss agent if it doesn't exist +DEPLOYED_CONFIG="$HOME/.openclaw/openclaw.json" +echo "部署 OpenClaw 配置..." +bash "$PROJECT_DIR/scripts/deploy-openclaw-config.sh" + +PRIMARY_MODEL="$(node "$PROJECT_DIR/scripts/render-openclaw-config.mjs" --print-primary-model)" + if openclaw agents list 2>/dev/null | grep -q "^- $AGENT_ID "; then echo "Agent '$AGENT_ID' already registered" else echo "Registering agent '$AGENT_ID'..." openclaw agents add "$AGENT_ID" \ --workspace "$WORKSPACE_DIR" \ - --model "kimi-coding/k2p5" \ + --model "$PRIMARY_MODEL" \ --non-interactive echo "Agent '$AGENT_ID' registered" fi -# Register cron jobs (skip if already registered to avoid duplicates) echo "Registering cron jobs..." EXISTING_CRONS=$(openclaw cron list --json 2>/dev/null | jq -r '.jobs[] | select(.agentId == "'"$AGENT_ID"'") | .name' 2>/dev/null || true) while IFS= read -r job; do name=$(echo "$job" | jq -r '.id') schedule=$(echo "$job" | jq -r '.schedule.expr') - # Extract message text from payload (agentTurn uses .message, systemEvent uses .text) payload=$(echo "$job" | jq -r '.payload.message // .payload.text // empty') if echo "$EXISTING_CRONS" | grep -q "^${name}$"; then @@ -40,24 +50,29 @@ while IFS= read -r job; do continue fi - # Non-default agents must use isolated sessions with session-key for persistence cmd=(openclaw cron add --name "$name" --agent "$AGENT_ID" --cron "$schedule") cmd+=(--session isolated --session-key "agent:${AGENT_ID}:${name}" --message "$payload") "${cmd[@]}" 2>/dev/null && echo " Added: $name" || echo " Failed: $name" done < <(jq -c '.[]' "$PROJECT_DIR/config/cron-jobs.json") -# Start OpenClaw gateway (if not already running) if openclaw gateway status 2>/dev/null | grep -q "running\|reachable"; then - echo "OpenClaw gateway already running — restarting to pick up config..." + echo "OpenClaw gateway already running - restarting to pick up config..." openclaw gateway restart 2>/dev/null || true else echo "Starting OpenClaw gateway..." openclaw gateway install 2>/dev/null || openclaw gateway run & fi +if [ -n "${CLAW_API_KEY:-}" ] && [ -f "$PROJECT_DIR/scripts/dashboard-sync.sh" ]; then + pkill -f "dashboard-sync" 2>/dev/null || true + nohup bash "$PROJECT_DIR/scripts/dashboard-sync.sh" > /tmp/dashboard-sync.log 2>&1 & + echo "Dashboard sync started (PID $!)" +fi + echo "" echo "=== ClawOSS Running ===" echo "Dashboard: check your Vercel deployment" +echo "Model: $PRIMARY_MODEL" echo "Logs: tail -f $HOME/.openclaw/logs/openclaw-$(date +%Y-%m-%d).log" echo "Stop: npm run stop" diff --git a/scripts/tests/test-all-scripts.sh b/scripts/tests/test-all-scripts.sh index f101533..190c1b3 100644 --- a/scripts/tests/test-all-scripts.sh +++ b/scripts/tests/test-all-scripts.sh @@ -6,8 +6,10 @@ set -u PASSED=0 FAILED=0 -SCRIPTS="/Users/kevinlin/clawOSS/scripts" -MEMORY="/Users/kevinlin/clawOSS/workspace/memory" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +SCRIPTS="${CLAWOSS_PROJECT_DIR:-$PROJECT_DIR}/scripts" +MEMORY="${CLAWOSS_WORKSPACE_DIR:-$PROJECT_DIR/workspace}/memory" pass() { echo " ✅ $1"; PASSED=$((PASSED + 1)); } fail() { echo " ❌ $1"; FAILED=$((FAILED + 1)); } diff --git a/scripts/tmp-cleaner.sh b/scripts/tmp-cleaner.sh index 1d1d460..d8ae0e4 100755 --- a/scripts/tmp-cleaner.sh +++ b/scripts/tmp-cleaner.sh @@ -10,6 +10,9 @@ INTERVAL=300 # 5 minutes MAX_AGE=30 # minutes of inactivity before deletion PID_FILE="/tmp/clawoss-cleaner.pid" LOG_FILE="/tmp/clawoss-cleaner.log" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${CLAWOSS_PROJECT_DIR:-$(dirname "$SCRIPT_DIR")}" +WORKSPACE_DIR="${CLAWOSS_WORKSPACE_DIR:-$PROJECT_DIR/workspace}" # Write PID for stop control echo $$ > "$PID_FILE" @@ -77,7 +80,7 @@ cleanup_cycle() { find /private/tmp -maxdepth 1 -name "test_*.rs" -mmin +${MAX_AGE} -delete 2>/dev/null # 5. Clean workspace/repos/ and workspace/workdir/ if >100MB - for wsdir in /Users/kevinlin/clawOSS/workspace/repos /Users/kevinlin/clawOSS/workspace/workdir; do + for wsdir in "$WORKSPACE_DIR/repos" "$WORKSPACE_DIR/workdir"; do if [ -d "$wsdir" ]; then ws_size=$(du -sm "$wsdir" 2>/dev/null | cut -f1) if [ "${ws_size:-0}" -gt 100 ]; then diff --git a/scripts/unlock-repo.sh b/scripts/unlock-repo.sh index 79c5664..2f33653 100755 --- a/scripts/unlock-repo.sh +++ b/scripts/unlock-repo.sh @@ -4,7 +4,8 @@ # Exit 0 always REPO="${1:?Usage: unlock-repo.sh }" -PROJECT_DIR="${PROJECT_DIR:-/Users/kevinlin/clawOSS}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-${CLAWOSS_PROJECT_DIR:-$(dirname "$SCRIPT_DIR")}}" OWNER="${REPO%%/*}" REPO_NAME="${REPO##*/}" diff --git a/scripts/update-trust-repos.sh b/scripts/update-trust-repos.sh index cea67f8..121d69c 100755 --- a/scripts/update-trust-repos.sh +++ b/scripts/update-trust-repos.sh @@ -6,7 +6,8 @@ ACTION="${1:?Usage: update-trust-repos.sh [--score N] [--reason ]}" REPO="${2:?Usage: update-trust-repos.sh }" -PROJECT_DIR="${PROJECT_DIR:-/Users/kevinlin/clawOSS}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-${CLAWOSS_PROJECT_DIR:-$(dirname "$SCRIPT_DIR")}}" TRUST_FILE="$PROJECT_DIR/workspace/memory/trust-repos.md" SCORE="" REASON="" diff --git a/scripts/verify-budget-pause.sh b/scripts/verify-budget-pause.sh new file mode 100755 index 0000000..00047f1 --- /dev/null +++ b/scripts/verify-budget-pause.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +TOKEN_BUDGET="${1:-${CLAWOSS_VERIFY_TOKEN_BUDGET:-1}}" +ISSUE="${CLAWOSS_VERIFY_ISSUE:-cli/cli#13283}" +CYCLES="${CLAWOSS_VERIFY_CYCLES:-1}" + +if ! [[ "$TOKEN_BUDGET" =~ ^[0-9]+$ ]]; then + echo "Usage: $0 [token_budget]" >&2 + echo "token_budget must be a non-negative integer" >&2 + exit 2 +fi + +export DASHBOARD_URL="${DASHBOARD_URL:-https://yuanbaomao.cyou}" +export CLAWOSS_TOKEN_BUDGET_TOTAL="$TOKEN_BUDGET" +export CLAWOSS_VERIFY_EXPECTED_CYCLES="$CYCLES" + +echo "Running budget pause verification" +echo "Dashboard: $DASHBOARD_URL" +echo "Token budget: $CLAWOSS_TOKEN_BUDGET_TOTAL" +echo "Issue: $ISSUE" + +set +e +OUTPUT="$(npm run mvp:dry-run -- --cycles "$CYCLES" --issue "$ISSUE" 2>&1)" +STATUS=$? +set -e + +printf '%s\n' "$OUTPUT" + +if [[ "$STATUS" -ne 0 ]]; then + echo "mvp:dry-run failed before verification could inspect the report" >&2 + exit "$STATUS" +fi + +REPORT_PATH="$(printf '%s\n' "$OUTPUT" | sed -n 's/^MVP report: //p' | tail -1)" + +if [[ -z "$REPORT_PATH" || ! -f "$REPORT_PATH" ]]; then + echo "Could not find the generated MVP markdown report path" >&2 + exit 1 +fi + +JSON_PATH="${REPORT_PATH%.md}.json" + +if [[ ! -f "$JSON_PATH" ]]; then + echo "Could not find the generated MVP JSON report: $JSON_PATH" >&2 + exit 1 +fi + +node - "$JSON_PATH" <<'NODE' +const fs = require("fs"); + +const reportPath = process.argv[2]; +const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); +const expectedBudget = Number(process.env.CLAWOSS_TOKEN_BUDGET_TOTAL); +const expectedCycles = Number(process.env.CLAWOSS_VERIFY_EXPECTED_CYCLES || 1); +const failures = []; + +if (report.cyclesRequested !== expectedCycles) failures.push(`expected cyclesRequested=${expectedCycles}, got ${report.cyclesRequested}`); +if (report.cyclesCompleted !== 0) failures.push(`expected cyclesCompleted=0, got ${report.cyclesCompleted}`); +if (report.attemptedTasks !== 0) failures.push(`expected attemptedTasks=0, got ${report.attemptedTasks}`); +if (report.dryRunStage !== null) failures.push(`expected dryRunStage=null, got ${report.dryRunStage}`); +if (!Array.isArray(report.pauseEvents) || report.pauseEvents.length === 0) { + failures.push("expected at least one pause event"); +} +if (Number(report.budget?.tokenBudgetTotal) !== expectedBudget) { + failures.push(`expected budget.tokenBudgetTotal=${expectedBudget}, got ${report.budget?.tokenBudgetTotal}`); +} + +if (failures.length > 0) { + console.error("Budget pause verification failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("Budget pause verification passed"); +console.log(`Report: ${reportPath}`); +console.log(`Pause reason: ${report.pauseEvents[0].reason || "unknown"}`); +NODE diff --git a/scripts/windows/start-public-stack.ps1 b/scripts/windows/start-public-stack.ps1 new file mode 100644 index 0000000..3a387bb --- /dev/null +++ b/scripts/windows/start-public-stack.ps1 @@ -0,0 +1,117 @@ +$ErrorActionPreference = "Stop" + +function Stop-ProcessesByPattern { + param( + [Parameter(Mandatory = $true)] + [string]$Pattern + ) + + Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -and $_.CommandLine -match $Pattern } | + ForEach-Object { + try { + Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop + } catch { + Write-Warning ("Stop process failed PID={0}: {1}" -f $_.ProcessId, $_.Exception.Message) + } + } +} + +function Require-Path { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "$Label missing: $Path" + } +} + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$dashboardDir = Join-Path $repoRoot "dashboard" +$tmpDir = Join-Path $repoRoot "tmp" +$configPath = Join-Path $env:APPDATA "ClawOSS\public-stack.json" +$dashboardStdout = Join-Path $tmpDir "dashboard-start.out.log" +$dashboardStderr = Join-Path $tmpDir "dashboard-start.err.log" +$cloudflaredLog = Join-Path $tmpDir "cloudflared.log" +$cloudflaredPid = Join-Path $tmpDir "cloudflared.pid" +$powershellExe = "C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe" + +Require-Path -Path $configPath -Label "public stack config" + +$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json +$npxCmd = if ($config.npxPath) { [string]$config.npxPath } else { "D:\develop\qianduan_tool\npx.cmd" } +$cloudflaredExe = if ($config.cloudflaredPath) { [string]$config.cloudflaredPath } else { "C:\Program Files (x86)\cloudflared\cloudflared.exe" } +$dashboardPort = if ($config.dashboardPort) { [int]$config.dashboardPort } else { 3000 } +$dashboardHost = if ($config.dashboardHost) { [string]$config.dashboardHost } else { "0.0.0.0" } +$databaseUrl = if ($config.databaseUrl) { [string]$config.databaseUrl } else { "file:demo-local.db" } +$clawAgentUsername = if ($config.clawAgentUsername) { [string]$config.clawAgentUsername } elseif ($config.githubUsername) { [string]$config.githubUsername } else { "clawoss-agent" } +$manualGithubSync = if ($null -ne $config.manualGithubSync) { [bool]$config.manualGithubSync } else { $false } +$autoGithubSync = if ($null -ne $config.autoGithubSync) { [bool]$config.autoGithubSync } else { $false } +$manualGithubSyncEnv = if ($manualGithubSync) { "true" } else { "false" } +$autoGithubSyncEnv = if ($autoGithubSync) { "true" } else { "false" } + +Require-Path -Path $npxCmd -Label "npx" +Require-Path -Path $cloudflaredExe -Label "cloudflared" + +New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null + +Stop-ProcessesByPattern -Pattern ([regex]::Escape($dashboardDir)) +Get-Process cloudflared -ErrorAction SilentlyContinue | ForEach-Object { + try { + Stop-Process -Id $_.Id -Force -ErrorAction Stop + } catch { + Write-Warning ("Stop cloudflared failed PID={0}: {1}" -f $_.Id, $_.Exception.Message) + } +} + +if (Test-Path -LiteralPath $cloudflaredPid) { + Remove-Item -LiteralPath $cloudflaredPid -Force -ErrorAction SilentlyContinue +} + +$dashboardCommand = @( + "`$env:GITHUB_TOKEN='$($config.githubToken)'" + "`$env:CLAW_AGENT_USERNAME='$clawAgentUsername'" + "`$env:CLAW_API_KEY='$($config.clawApiKey)'" + "`$env:TURSO_DATABASE_URL='$databaseUrl'" + "`$env:CLAWOSS_DASHBOARD_MANUAL_GITHUB_SYNC='$manualGithubSyncEnv'" + "`$env:CLAWOSS_DASHBOARD_AUTO_GITHUB_SYNC='$autoGithubSyncEnv'" + "Set-Location '$dashboardDir'" + "& '$npxCmd' next start --hostname $dashboardHost --port $dashboardPort" +) -join "; " + +$dashboardStart = @{ + FilePath = $powershellExe + ArgumentList = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", $dashboardCommand) + WorkingDirectory = $dashboardDir + RedirectStandardOutput = $dashboardStdout + RedirectStandardError = $dashboardStderr + WindowStyle = "Hidden" +} +Start-Process @dashboardStart | Out-Null + +Start-Sleep -Seconds 8 + +$cloudflaredStart = @{ + FilePath = $cloudflaredExe + ArgumentList = @( + "tunnel", + "--loglevel", "info", + "--logfile", $cloudflaredLog, + "--pidfile", $cloudflaredPid, + "run", + "--token", [string]$config.tunnelToken, + "--url", "http://127.0.0.1:$dashboardPort" + ) + WorkingDirectory = $repoRoot + WindowStyle = "Hidden" +} +Start-Process @cloudflaredStart | Out-Null + +Write-Output "Dashboard directory: $dashboardDir" +Write-Output "Dashboard port: $dashboardPort" +Write-Output "Cloudflared log: $cloudflaredLog" +Write-Output "Cloudflared pid file: $cloudflaredPid" diff --git a/workspace/HEARTBEAT.md b/workspace/HEARTBEAT.md index 3f17ccf..1ab19d4 100644 --- a/workspace/HEARTBEAT.md +++ b/workspace/HEARTBEAT.md @@ -3,6 +3,10 @@ ## CRITICAL: NEVER REPLY HEARTBEAT_OK — ALWAYS WORK There is ALWAYS something to do. Execute ALL steps 0-7 every cycle. If queue is empty, run discovery. If discovery finds nothing, expand to new niches. If no new issues, follow up on open PRs. If truly nothing: search broader (lower star threshold, older issues, new languages). The agent must NEVER be idle. +## ABSOLUTE STOP: Dashboard Pause and Budget Guardrail +`pauseAgent=true`, dashboard manual pause, or exhausted token/cost budget has higher priority than every work directive in this file, including NEVER idle / ALWAYS work. +At the start of every cycle and before any comment, branch push, subagent spawn, or PR creation, call `${CLAWOSS_HEALTHCHECK_URL:-${DASHBOARD_URL:-https://clawoss-dashboard.vercel.app}/api/agent/health-check}`. If it returns `pauseAgent: true` or `budget.paused: true`, stop submitting, stop spawning, log the pause reason, update dashboard state if possible, and do not resume until the dashboard/budget guardrail clears. + ## Rules — see AGENTS.md (loaded alongside this file) Keep all 10 impl/followup sub-agent slots filled. **NEW PRs FIRST** — fill all 10 slots with new implementations. Only do follow-ups AFTER all 10 impl slots are full or no new work exists. Work queue should have 10+ items. If < 5, run oss-discover IMMEDIATELY. @@ -31,10 +35,10 @@ You have skills loaded. **Read the SKILL.md file** (use the `read` tool) before - **Safety**: Read `safety-checker` skill before any PR submission — it's the final gate. - **Context**: `context-manager` skill when context > 40%. - **Dashboard**: `dashboard-reporter` skill to report metrics. -Skills: `~/clawOSS/workspace/skills/{name}/SKILL.md`. Load with `read`. +Skills: `$CLAWOSS_WORKSPACE_DIR/skills/{name}/SKILL.md`. Load with `read`. ## 0. Health Checks -**0a. Quick status snapshot**: `bash /Users/kevinlin/clawOSS/scripts/heartbeat-status.sh` — shows queue depth, open PRs, locks, always-on status, wake state in one JSON call. +**0a. Quick status snapshot**: `bash $CLAWOSS_PROJECT_DIR/scripts/heartbeat-status.sh` — shows queue depth, open PRs, locks, always-on status, wake state in one JSON call. **0a2. Context**: Use the `session_status` tool (NOT a bash command — it's an OpenClaw built-in tool). **>35%: COMPACT IMMEDIATELY** — flush state to memory files, then `/compact`. Do NOT proceed to any other step until context is under 35%. This is the #1 cause of gateway timeouts and stalled cycles. **0b. Circuit breakers**: Read wake-state.md (or use heartbeat-status.sh output). If errors_this_hour >= 5, pause 2 minutes then continue (never fully stop). consecutive_wakes is informational only — never use it to skip work. **0b2. Cycle guardrails** (prevent runaway cycles and quota burn): @@ -43,9 +47,10 @@ Skills: `~/clawOSS/workspace/skills/{name}/SKILL.md`. Load with `read`. - **API error backoff**: If 3+ API calls fail in a row (rate limit, 403, 5xx), pause 60 seconds before continuing. If 5+ fail, skip to step 7 cleanup and self-wake — never fully stop. **0c. Dashboard self-check** (run every cycle, skip if dashboard unreachable): ```bash -HEALTH=$(curl -s --max-time 5 https://clawoss-dashboard.vercel.app/api/agent/health-check) +HEALTH=$(curl -s --max-time 5 "${CLAWOSS_HEALTHCHECK_URL:-${DASHBOARD_URL:-https://clawoss-dashboard.vercel.app}/api/agent/health-check}") ``` Parse the response and OBEY all three fields: +- `pauseAgent` / `budget.paused`: STOP immediately. Do not spawn subagents, comment, push, or create PRs. This overrides all NEVER idle / ALWAYS work text. - `directives`: plain-English corrections (slow down, follow up first, avoid dead repos). Read and follow. - `avoidRepos`: repos with 2+ PRs and 0 merges — do NOT submit NEW PRs to any of these. But NEVER kill in-progress subagents working on these repos. Let them finish — killing mid-flight wastes the work already done. - `reposWithOpenPRs`: repos where we already have open PRs — do NOT submit new PRs, focus on follow-ups instead. @@ -79,7 +84,7 @@ Always-on subagents use 4 slots. Remaining 10 for impl/followup. Total maxConcur ## 1. Stall Recovery Check for stalled sub-agents (no messages >5 min). Kill, re-queue at TOP of work-queue.md, increment errors_this_hour. Mark stalled task as `failed` in `memory/impl-spawn-state.md`. 2 consecutive stalls on same task = SKIP it. -**Clean stale locks + orphaned state**: `bash /Users/kevinlin/clawOSS/scripts/cleanup-stale-sessions.sh` (removes locks >30min, resets orphaned spawned_pending entries) +**Clean stale locks + orphaned state**: `bash $CLAWOSS_PROJECT_DIR/scripts/cleanup-stale-sessions.sh` (removes locks >30min, resets orphaned spawned_pending entries) ## 2. Pick New Work (PRIORITY — new PRs before follow-ups) @@ -93,8 +98,8 @@ Count active impl/followup sub-agents (sessions_list, exclude main + always-on s - **impl/followup active >= 10**: skip to step 6. **Do NOT say "monitoring for completion events" and stop.** Always continue to step 6, then 7, then self-wake and loop back. - **impl/followup active < 10, queue has items**: pick next (urgent first, P(merge) >= 30, score >= 5). Gates: a. **IMPL SPAWN GUARD**: skip if issue has `spawned_pending` in `memory/impl-spawn-state.md`. - b. **DEDUP**: skip if in pr-ledger.md, in subagent-result-*.md, repo has `spawned_pending` in impl-spawn-state.md, OR lock file exists (`memory/locks/{owner}_{repo}.lock`). ALWAYS use `BillionClaw` explicitly — `@me` can fail in sub-agent contexts. - **DOUBLE-CHECK**: Before each spawn, re-run `gh search prs --author BillionClaw --repo {owner}/{repo} --state open --json number --jq 'length'`. If count changed, skip (race condition guard). + b. **DEDUP**: skip if in pr-ledger.md, in subagent-result-*.md, repo has `spawned_pending` in impl-spawn-state.md, OR lock file exists (`memory/locks/{owner}_{repo}.lock`). ALWAYS use `${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}` explicitly — `@me` can fail in sub-agent contexts. + **DOUBLE-CHECK**: Before each spawn, re-run `gh search prs --author "${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}" --repo {owner}/{repo} --state open --json number --jq 'length'`. If count changed, skip (race condition guard). **LOCK FILE**: Before spawning, write lock: `echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) {issue}" > memory/locks/{owner}_{repo}.lock`. Sub-agent deletes lock after PR creation or failure. Orchestrator cleans stale locks (>30 minutes) in step 1 (stall recovery). b2. **BLOCKLIST HARD-BLOCK**: Read `memory/trust-repos.md` Deprioritized section. If the repo appears there AND `Skip Until` is "permanent" or a future date, SKIP unconditionally — no override by score, labels, or any other factor. Repos on this list have hostile maintainers, ban threats, or non-automatable CLAs. c. Skip if we had a PR closed on this repo in the last 7 days. @@ -134,7 +139,7 @@ Count active impl/followup sub-agents (sessions_list, exclude main + always-on s **5a. Pre-spawn comment (score >= 8, or >= 6 for trusted repos):** Post brief comment: `gh issue comment {issue} --repo {owner}/{repo} --body "Looking into this — [1-sentence approach]. Happy to submit a fix."` Skip for lower scores. **5b.** Use the `read` tool to load `templates/subagent-implementation.md` from disk NOW (do NOT reuse cached content). Substitute `{repo}`, `{issue}`, `{title}` in the content. Spawn: `sessions_spawn(task: {THE_SUBSTITUTED_CONTENT}, label: "{repo}#{issue}", ...)`. Always re-read the template for EVERY spawn — files change between cycles. Pass repo conventions + issue details as attachments. **IMMEDIATELY mark issue as `spawned_pending` in `memory/impl-spawn-state.md` BEFORE spawning the next agent.** -**NEVER use `@me` — it fails in sub-agent contexts. ALWAYS use `BillionClaw` explicitly.** +**NEVER use `@me` — it fails in sub-agent contexts. ALWAYS use `${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}` explicitly.** **Read `memory/repos/{owner}_{repo}.md`** if it exists — pass key info (target branch, CLA, CI) to the subagent via attachments. **5c. PASS OPEN PR CONTEXT**: Before spawning, fetch open PRs in the repo and pass as attachment: `gh pr list --repo {owner}/{repo} --state open --json number,title,headRefName --limit 20` @@ -159,7 +164,7 @@ Load `templates/subagent-followup.md` from disk. Spawn with the staging data as - failure/abandoned: mark `failed`. Log failure_reason in failure-log.md. `repo_health_fail` = cache 24h. If `fix_rejected_terminal` (2+ failed reworks) or `reviewer_rejected_scope`, deprioritize repo in trust-repos.md for 30 days. Single `fix_rejected` = rework opportunity, not deprioritization. - already_fixed: mark `completed`. Remove. Delete result file after processing. -**6c. Trust validation**: When updating trust-repos.md, verify merge counts match reality: `gh search prs --author BillionClaw --repo {owner}/{repo} "is:merged" --json number --jq 'length'`. Don't trust cached counts — GitHub is the source of truth. +**6c. Trust validation**: When updating trust-repos.md, verify merge counts match reality: `gh search prs --author "${CLAW_AGENT_USERNAME:-${GITHUB_USERNAME:-clawoss-agent}}" --repo {owner}/{repo} "is:merged" --json number --jq 'length'`. Don't trust cached counts — GitHub is the source of truth. **6b. Follow-up**: List `memory/subagent-result-followup-*.md`. Parse YAML. Clear `spawned_pending`, increment round. - `changes_pushed`/`question_answered`/`scope_adjusted`/`rework_in_progress` -> `follow_up_round_N` (continue iterating) diff --git a/workspace/templates/subagent-pr-analyst.md b/workspace/templates/subagent-pr-analyst.md index 5af4a5e..8cc8804 100644 --- a/workspace/templates/subagent-pr-analyst.md +++ b/workspace/templates/subagent-pr-analyst.md @@ -17,9 +17,9 @@ runTimeoutSeconds: 0 ## CRITICAL: Script Path **EVERY bash block MUST start with this line:** ```bash -SCRIPTS=/Users/kevinlin/clawOSS/scripts +SCRIPTS="$CLAWOSS_PROJECT_DIR/scripts" ``` -All ClawOSS utility scripts are at this absolute path. You run in /tmp — relative paths WILL NOT WORK. +All ClawOSS utility scripts are resolved from the configured project root. You run in /tmp — relative paths WILL NOT WORK. ## Task Prompt