Crafter.Review/implementations/Crafter.Review-OpenCode.cpp

359 lines
21 KiB
C++
Raw Permalink Normal View History

// 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::size_t N>
std::string Enum(const std::array<std::string_view, N>& values) {
std::vector<std::string> 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<std::string>());
}
// 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<char>(c | 0x20) : t[at + i] != static_cast<char32_t>(static_cast<std::uint8_t>(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<digits>\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<double>();
if (value.is_boolean()) return 1.0;
if (value.is_string()) {
if (const std::optional<double> parsed = Dyn::ParseFloat(value.get_ref<const std::string&>())) return *parsed;
throw Dyn::Error("ValueError", Dyn::FloatError(value.get_ref<const std::string&>()));
}
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<std::string>();
return DumpCompact(key);
}
}
Json Routing() {
return {{"zdr", true}, {"data_collection", "deny"}, {"quantizations", {"fp8", "fp16", "bf16", "fp32"}}, {"sort", "price"}};
}
std::string DefaultBin() {
const std::optional<std::string> configured = Platform::GetEnv("REVIEW_GATES_OPENCODE_BIN");
return configured && !configured->empty() ? *configured : std::string("opencode");
}
std::pair<std::string, std::string> 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<const std::string> 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<std::string> 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<std::string, std::string> BaseEnv(const fs::path& home, std::string_view key, const std::optional<std::map<std::string, std::string>>& base) {
std::map<std::string, std::string> 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<const std::string> 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<std::string, std::string> ChildEnv(const std::map<std::string, std::string>& base, const fs::path& systemPath, const fs::path& soPath, std::span<const std::string> allowList,
const fs::path& dataDir) {
std::map<std::string, std::string> 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<std::string> 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<std::string> 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<bool, std::string> Warm(const std::string& bin, const fs::path& home, const std::map<std::string, std::string>& env, std::string_view model,
const std::function<void(const std::string&)>& log, std::int64_t timeoutS) {
std::map<std::string, std::string> 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<std::string> 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<std::int64_t>() > 0 && errors.empty();
Json firstErrors = Json::array();
for (std::size_t i = 0; i < std::min<std::size_t>(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<std::int64_t>(), 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<fs::path>& 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<const std::string&>() == 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<std::string, std::string> 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<std::string> 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")))};
}
}