// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module Crafter.Review:OpenCode_impl; import :OpenCode; import :Json; import :Text; import :Platform; import :Process; import :Schema; import std; namespace fs = std::filesystem; namespace Crafter::Review::OpenCode { namespace { // A JSON string literal: the descriptions and enum values go into the // TypeScript as one. std::string Literal(std::string_view text) { return DumpSpaced(Json(std::string(text))); } template std::string Enum(const std::array& values) { std::vector parts; for (const std::string_view v : values) parts.push_back(Literal(v)); return std::format("z.enum([{}])", Dyn::Join(parts, ", ")); } std::string Described(const Json& schema, std::string_view property) { return Literal(schema.at("properties").at(property).at("description").get()); } // The literal at `at`, its letters matched as re.IGNORECASE folds // them and everything else as it is. bool LiteralAt(const std::u32string& t, std::size_t at, std::string_view literal) { if (at + literal.size() > t.size()) return false; for (std::size_t i = 0; i < literal.size(); ++i) { const char c = literal[i]; const bool letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); if (letter ? Dyn::IgnoreCaseLetter(t[at + i]) != static_cast(c | 0x20) : t[at + i] != static_cast(static_cast(c))) return false; } return true; } bool Contains(const std::u32string& t, std::string_view literal) { for (std::size_t at = 0; at + literal.size() <= t.size(); ++at) { if (LiteralAt(t, at, literal)) return true; } return false; } // \b\b bool Number(const std::u32string& t, std::string_view digits) { for (std::size_t at = 0; at + digits.size() <= t.size(); ++at) { if (LiteralAt(t, at, digits) && (at == 0 || !Dyn::IsWord(t[at - 1])) && (at + digits.size() == t.size() || !Dyn::IsWord(t[at + digits.size()]))) return true; } return false; } // r'\b429\b|rate[ -]limit|temporarily rate-limited|engine_overloaded', re.I bool LimitText(const std::u32string& t) { return Number(t, "429") || Contains(t, "rate limit") || Contains(t, "rate-limit") || Contains(t, "temporarily rate-limited") || Contains(t, "engine_overloaded"); } // r'\b402\b|insufficient credits|requires more credits|credit balance', re.I bool CreditText(const std::u32string& t) { return Number(t, "402") || Contains(t, "insufficient credits") || Contains(t, "requires more credits") || Contains(t, "credit balance"); } // r'\b401\b|\b403\b|invalid.*(api key|token)|unauthori[sz]ed|No auth credentials', re.I bool AuthText(const std::u32string& t) { if (Number(t, "401") || Number(t, "403") || Contains(t, "unauthorised") || Contains(t, "unauthorized") || Contains(t, "no auth credentials")) return true; for (std::size_t at = 0; at + 7 <= t.size(); ++at) { if (!LiteralAt(t, at, "invalid")) continue; // `.*` runs to the end of the line. for (std::size_t j = at + 7; j < t.size(); ++j) { if (LiteralAt(t, j, "api key") || LiteralAt(t, j, "token")) return true; if (t[j] == U'\n') break; } } return false; } // `x.get(k, 0) or 0` Json Count(const Json& value) { return Dyn::Truthy(value) ? value : Json(0); } // The value as a float, 0 when it is falsy. double Num(const Json& value) { if (!Dyn::Truthy(value)) return 0.0; if (value.is_number()) return value.get(); if (value.is_boolean()) return 1.0; if (value.is_string()) { if (const std::optional parsed = Dyn::ParseFloat(value.get_ref())) return *parsed; throw Dyn::Error("ValueError", Dyn::FloatError(value.get_ref())); } throw Dyn::Error("TypeError", std::format("float() argument must be a string or a real number, not '{}'", Dyn::TypeName(value))); } // The text a JSON object key is written as. std::string KeyText(const Json& key) { if (key.is_string()) return key.get(); return DumpCompact(key); } } Json Routing() { return {{"zdr", true}, {"data_collection", "deny"}, {"quantizations", {"fp8", "fp16", "bf16", "fp32"}}, {"sort", "price"}}; } std::string DefaultBin() { const std::optional configured = Platform::GetEnv("REVIEW_GATES_OPENCODE_BIN"); return configured && !configured->empty() ? *configured : std::string("opencode"); } std::pair ModelId(std::string_view model) { const std::size_t slash = model.find('/'); if (slash != std::string_view::npos && model.substr(0, slash) == "openrouter") return {std::string(model.substr(0, slash)), std::string(model.substr(slash + 1))}; return {"openrouter", std::string(model)}; } std::string ToolSource() { const Json finding = Schema::Finding(); const Json note = Schema::Note(); const Json coverage = Schema::Coverage(); std::string out = "import { tool } from \"@opencode-ai/plugin\"\nimport * as fs from \"node:fs\"\nconst z = tool.schema\nconst Finding = z.object({\n"; out += std::format(" finding: z.string().describe({}),\n", Described(finding, "finding")); out += std::format(" evidence: z.string().describe({}),\n", Described(finding, "evidence")); out += " proposedAction: z.string(),\n"; out += std::format(" items: z.array(z.string()).describe({}),\n", Described(finding, "items")); out += std::format(" dischargedBy: z.string().optional().describe({}),\n", Described(finding, "dischargedBy")); out += std::format(" contests: z.object({{ id: z.string(), why: z.string().describe({}) }}).strict().optional(),\n", Described(finding.at("properties").at("contests"), "why")); out += "}).strict()\nconst Note = z.object({\n"; out += std::format(" kind: {},\n", Enum(Schema::NoteKinds)); out += " note: z.string(), evidence: z.string(),\n"; out += std::format(" action: z.string().describe({}),\n", Described(note, "action")); out += std::format(" id: z.string().optional().describe({}),\n", Described(note, "id")); out += " items: z.array(z.string()).optional(),\n}).strict()\nconst Coverage = z.object({\n"; out += std::format(" item: z.string().describe({}),\n", Described(coverage, "item")); out += std::format(" verdict: {},\n", Enum(Schema::CoverageVerdicts)); out += std::format(" basis: z.string().describe({}),\n", Described(coverage, "basis")); out += "}).strict()\nexport default tool({\n"; out += " description: \"Return the structured output your instructions describe: the verdict, findings, notes and coverage of this pass. Call it exactly once, as the last action of " "the pass, with the complete result; nothing you write as text counts.\",\n"; out += " args: {\n"; out += std::format(" verdict: {}.describe({}),\n", Enum(Schema::VerdictEnum), Described(Schema::BlockSchema(), "verdict")); out += " findings: z.array(Finding),\n notes: z.array(Note),\n"; out += " coverage: z.array(Coverage).describe(\"block pass: exactly one entry, the unit this pass covers; whole pass: one entry per unit you were asked to cover\"),\n"; out += std::format(" summary: z.string().optional().describe({}),\n", Described(Schema::WholeSchema(), "summary")); out += " },\n async execute(args) {\n"; out += std::format(" const p = process.env.{}\n", SoEnv); out += " if (p) fs.writeFileSync(p, JSON.stringify(args))\n return \"Structured output provided successfully\"\n },\n})\n"; return out; } fs::path PrepareHome(const fs::path& scratch, const fs::path& repo, std::span models, const Json& routing) { const fs::path home = scratch / "opencode"; const fs::path configDir = home / "xdg" / "opencode"; Platform::MakeDirs(configDir / "tools", 0700); for (const std::string_view d : {"cache", "state", "data", "view"}) Platform::MakeDirs(home / d, 0700); const fs::path view = home / "view"; std::vector names; for (const fs::directory_entry& entry : fs::directory_iterator(repo)) names.push_back(entry.path().filename().string()); std::ranges::sort(names); for (const std::string& name : names) { const fs::path link = view / name; if (std::ranges::contains(ViewSkip, name) || fs::is_symlink(fs::symlink_status(link))) continue; fs::create_symlink(repo / name, link); } Json provider = Json::object(); for (const std::string& m : models) { const auto [prov, id] = ModelId(m); if (!provider.contains(prov)) provider[prov] = {{"models", Json::object()}}; provider[prov]["models"][id] = {{"options", {{"provider", routing}}}}; } const Json config = {{"$schema", "https://opencode.ai/config.json"}, {"autoupdate", false}, {"share", "disabled"}, {"provider", provider}}; std::ofstream(configDir / "opencode.json", std::ios::binary | std::ios::trunc) << DumpFile(config); std::ofstream(configDir / "tools" / std::format("{}.ts", Tool), std::ios::binary | std::ios::trunc) << ToolSource(); return home; } std::map BaseEnv(const fs::path& home, std::string_view key, const std::optional>& base) { std::map env; for (const auto& [name, value] : base ? *base : Platform::Environment()) { if (name.starts_with("ANTHROPIC_") || name.starts_with("CLAUDE_") || name.starts_with("OPENCODE_") || name.starts_with("XDG_") || name == "OPENROUTER_API_KEY") continue; env[name] = value; } env["XDG_CONFIG_HOME"] = (home / "xdg").string(); env["XDG_CACHE_HOME"] = (home / "cache").string(); env["XDG_STATE_HOME"] = (home / "state").string(); env["XDG_DATA_HOME"] = (home / "data").string(); env["OPENROUTER_API_KEY"] = std::string(key); env["OPENCODE_DISABLE_AUTOUPDATE"] = "1"; return env; } Json Permissions(std::span allowList) { Json bash = {{"*", "deny"}}; Json perm = {{"*", "deny"}, {std::string(Tool), "allow"}, {"read", "deny"}, {"glob", "deny"}, {"grep", "deny"}, {"list", "allow"}, {"external_directory", "allow"}, {"bash", nullptr}}; for (const std::string& a : allowList) { // re.match(r'^Bash\((.+):\*\)$', a): `.` stops at a line break, // `$` also matches before a final one. const std::string_view body = a.ends_with('\n') ? std::string_view(a).substr(0, a.size() - 1) : std::string_view(a); if (body.starts_with("Bash(") && body.ends_with(":*)") && body.size() > 8 && !body.substr(5, body.size() - 8).contains('\n')) { bash[std::format("{}*", body.substr(5, body.size() - 8))] = "allow"; } else if (a == "Read" || a == "Glob" || a == "Grep") { perm[Dyn::Lower(a)] = "allow"; } else if (a.starts_with("Read(")) { perm["read"] = "allow"; } } perm["bash"] = std::move(bash); return perm; } std::map ChildEnv(const std::map& base, const fs::path& systemPath, const fs::path& soPath, std::span allowList, const fs::path& dataDir) { std::map env = base; const Json agent = {{"mode", "primary"}, {"description", "review lens"}, {"prompt", std::format("{{file:{}}}", Dyn::AbsPath(systemPath.string()))}, {"permission", Permissions(allowList)}}; env["OPENCODE_CONFIG_CONTENT"] = DumpCompact({{"agent", {{std::string(Agent), agent}}}}); env[std::string(SoEnv)] = soPath.string(); Platform::MakeDirs(dataDir, 0700); env["XDG_DATA_HOME"] = dataDir.string(); return env; } std::vector Cmd(std::string_view bin, std::string_view model, const fs::path& view, std::string_view title, std::string_view variant, std::string_view prompt) { std::vector argv = {std::string(bin), "run", "--format", "json", "--model", std::string(model), "--agent", std::string(Agent), "--dir", view.string(), "--title", Dyn::Slice(title, 0, 80)}; if (!variant.empty()) { argv.emplace_back("--variant"); argv.emplace_back(variant); } argv.emplace_back("--"); argv.emplace_back(prompt); return argv; } std::pair Warm(const std::string& bin, const fs::path& home, const std::map& env, std::string_view model, const std::function& log, std::int64_t timeoutS) { std::map e = env; const fs::path data = home / "data" / "warm"; e["XDG_DATA_HOME"] = data.string(); fs::create_directories(data); const fs::path view = home / "view"; const std::vector argv = {bin, "run", "--format", "json", "--model", std::string(model), "--dir", view.string(), "--title", "warm-up", "--", "Reply with the single word ok."}; // Under the supervisor: what the warm-up starts goes with it, a // timeout included. const Process::Captured run = Process::Run(Process::Supervised(argv), view, std::chrono::seconds(timeoutS), {}, e); if (run.timedOut) return {false, std::format("warm-up timed out after {} s", timeoutS)}; const std::int32_t code = Process::SupervisedReturnCode(run.returnCode); const Json parsed = ParseEvents(Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out))); const Json& errors = parsed.at("errors"); const bool ok = code == 0 && parsed.at("turns").get() > 0 && errors.empty(); Json firstErrors = Json::array(); for (std::size_t i = 0; i < std::min(2, errors.size()); ++i) firstErrors.push_back(errors.at(i)); log(std::format("opencode: warm-up {} ({} step(s), cost ${:.4f}, exit {}{})", ok ? "ok" : "FAILED", parsed.at("turns").get(), Num(parsed.at("costUsd")), code, errors.empty() ? std::string() : std::format(", errors {}", Dyn::ReprValue(firstErrors)))); return {ok, Dyn::Slice(Dyn::UniversalNewlines(Dyn::DecodeReplace(run.err)), -800)}; } Json ParseEvents(std::string_view text, const std::optional& soPath) { Json usage = {{"input", 0}, {"cacheCreation", 0}, {"cacheRead", 0}, {"output", 0}, {"thinking", 0}}; std::int64_t turns = 0; double cost = 0.0; Json toolCalls = Json::object(); std::int64_t toolErrors = 0; Json errors = Json::array(); Json structured; Json sessionId; Json first; Json stop; for (const std::string& raw : Dyn::SplitLines(text)) { const std::string line = Dyn::Strip(raw); if (!line.starts_with('{') || Dyn::JsonDecodeError(line)) continue; Json o; try { o = ParseLoose(line); } catch (const std::exception&) { // NaN or Infinity, which nlohmann refuses (AGENTS.md): the line // is skipped. continue; } const Json& type = Dyn::Get(o, "type"); const Json& rawPart = Dyn::Get(o, "part"); const Json& part = rawPart.is_object() ? rawPart : Dyn::EmptyDict(); if (!Dyn::Truthy(sessionId)) sessionId = Dyn::Get(o, "sessionID"); if (type == "step_finish") { const Json& tokens = Dyn::Or(Dyn::Get(part, "tokens"), Dyn::EmptyDict()); const Json& cache = Dyn::Or(Dyn::Get(tokens, "cache"), Dyn::EmptyDict()); ++turns; usage["input"] = Dyn::Add(usage.at("input"), Count(Dyn::Get(tokens, "input"))); usage["output"] = Dyn::Add(usage.at("output"), Count(Dyn::Get(tokens, "output"))); usage["thinking"] = Dyn::Add(usage.at("thinking"), Count(Dyn::Get(tokens, "reasoning"))); usage["cacheRead"] = Dyn::Add(usage.at("cacheRead"), Count(Dyn::Get(cache, "read"))); usage["cacheCreation"] = Dyn::Add(usage.at("cacheCreation"), Count(Dyn::Get(cache, "write"))); cost += Num(Dyn::Get(part, "cost")); stop = Dyn::Get(part, "reason"); if (first.is_null()) { first = {{"input_tokens", Count(Dyn::Get(tokens, "input"))}, {"cache_creation_input_tokens", Count(Dyn::Get(cache, "write"))}, {"cache_read_input_tokens", Count(Dyn::Get(cache, "read"))}, {"output_tokens", Count(Dyn::Get(tokens, "output"))}}; } } else if (Dyn::Get(part, "type") == "tool") { const Json& name = Dyn::Get(part, "tool"); const Json& state = Dyn::Or(Dyn::Get(part, "state"), Dyn::EmptyDict()); const std::string key = KeyText(name); toolCalls[key] = Dyn::Add(Dyn::Has(toolCalls, key) ? toolCalls.at(key) : Json(0), Json(1)); if (Dyn::Get(state, "status") == "error") ++toolErrors; if (name.is_string() && name.get_ref() == Tool && Dyn::Get(state, "status") == "completed" && Dyn::Get(state, "input").is_object()) structured = state.at("input"); } else if (type == "error") { const Json& error = Dyn::Get(o, "error"); errors.push_back(Dyn::Slice(DumpSpaced(Dyn::Truthy(error) ? error : o), 0, 300)); } } if (structured.is_null() && soPath && !soPath->empty() && fs::exists(*soPath)) { try { structured = Dyn::LoadJsonFile(*soPath); } catch (const std::exception&) { structured = nullptr; } } // Reasoning tokens are output tokens OpenCode counts apart; the claude // harness counts them inside output. usage["output"] = Dyn::Add(usage.at("output"), usage.at("thinking")); const bool result = structured.is_object() && Dyn::Get(structured, "findings").is_array(); return { {"usage", usage}, {"turns", turns}, {"costUsd", cost != 0 ? Json(cost) : Json()}, {"toolCalls", toolCalls}, {"toolErrors", toolErrors}, {"errors", errors}, {"structured", result ? structured : Json()}, {"sessionId", sessionId}, {"firstTurn", first}, {"stop", stop}, }; } std::pair Classify(const Json& parsed, std::int64_t exitCode, std::string_view stderrText, bool timedOut) { if (timedOut || exitCode == 124) return {"timeout", "process exceeded its time budget"}; const Json& errors = Dyn::At(parsed, "errors"); std::vector texts; for (const Json& e : errors) texts.push_back(Dyn::Str(e)); const std::string text = std::format("{} {}", Dyn::Join(texts, " "), Dyn::Slice(stderrText, -2000)); if (!Dyn::At(parsed, "structured").is_null()) return {"ok", ""}; const std::u32string t = Dyn::Decode(text); const std::string reason = Dyn::Slice(Dyn::Strip(text), 0, 200); if (CreditText(t)) return {"credits", reason}; if (AuthText(t)) return {"auth", reason}; if (LimitText(t)) return {"limit", reason}; if (!errors.empty()) return {"error", std::format("opencode error: {}", Dyn::Slice(Dyn::Str(errors.at(0)), 0, 200))}; if (Dyn::At(parsed, "turns") == 0) return {"error", std::format("no step finished (exit {}): {}", exitCode, Dyn::Slice(Dyn::Strip(stderrText), -200))}; return {"error", std::format("no {} call in the process ({} steps, stop {})", Tool, Dyn::Str(parsed.at("turns")), Dyn::Str(Dyn::Get(parsed, "stop")))}; } }