import type { ConflictHunk } from "../git/merge3"; import { redactSecrets } from "./candidates"; import type { Candidate, SecretVault } from "./candidates"; export type Thresholds = { confidence: number; margin: number }; export const DEFAULT_THRESHOLDS: Thresholds = { confidence: 0.8, margin: 0.3 }; export const RISK_LEVELS = ["cosmetic", "logic", "security-sensitive"] as const; export type RiskLevel = (typeof RISK_LEVELS)[number]; /** Any real chance of a security-sensitive change routes the file to a human. */ const SECURITY_PROBABILITY_LIMIT = 0.25; const NEEDS_HUMAN_LIMIT = 0.5; /** Clef accepts at most 64 questions; two are the per-file risk and needs-human questions. */ export const MAX_HUNKS_PER_FILE = 16; type ChoiceQuestion = { type: "choice"; instructions: string; criteria: Record }; type ScoreQuestion = { type: "score"; instructions: string; criteria: string[] }; type NoulQuestion = { type: "noul"; instructions: string; criteria?: { true: string; false: string } }; export type ClefRequest = { model: "clef" | "clef-flash"; state: unknown; questions: Record; }; type ChoiceAnswer = { type: "choice"; choice: string; probabilities: Record; confidence: number }; type ScoreAnswer = { type: "score"; score: number; probabilities: Record; confidence: number }; type NoulAnswer = { type: "noul"; noul: number }; export type ClefResponse = { answers: Record; usage?: { input_tokens: number; output_tokens: number }; }; export type HunkInput = { hunk: ConflictHunk; candidates: Candidate[] }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Clef output is parsed defensively: anything that is not a probability counts as zero. */ function finite(value: unknown): number { const number = Number(value); return Number.isFinite(number) && number >= 0 && number <= 1 ? number : 0; } export function hunkQuestionId(index: number): string { return `hunk_${index}`; } /** One Clef request per file: a Choice per hunk, plus file-level risk (Score) and needs-a-human (Noul). */ export function buildClefRequest(input: { model: ClefRequest["model"]; path: string; title: string; body: string; maxBodyChars?: number; hunks: HunkInput[]; vault: SecretVault; }): ClefRequest { const redact = (text: string) => redactSecrets(text, input.vault); const questions: ClefRequest["questions"] = {}; const hunks = input.hunks.map((entry, index) => { questions[hunkQuestionId(index)] = { type: "choice", instructions: `Which candidate correctly resolves conflict ${hunkQuestionId(index)} in ${input.path}, preserving the intent of both the head and base changes and producing valid code?`, criteria: Object.fromEntries(entry.candidates.map((candidate) => [candidate.id, candidate.label])), }; return { id: hunkQuestionId(index), base: redact(entry.hunk.base), head: redact(entry.hunk.ours), incoming_base_branch: redact(entry.hunk.theirs), candidates: Object.fromEntries(entry.candidates.map((candidate) => [candidate.id, redact(candidate.text)])), }; }); questions.risk = { type: "score", instructions: `How risky is it to resolve the conflicts in ${input.path} automatically?`, criteria: [ "Cosmetic: formatting, comments, documentation, imports or ordering only", "Logic: changes program behavior", "Security-sensitive: authentication, authorization, cryptography, secrets, input validation, permissions or dependency versions", ], }; questions.needs_human = { type: "noul", instructions: `Does resolving the conflicts in ${input.path} need a human decision (for example the two sides are semantically incompatible or no candidate is correct)?`, criteria: { true: "A human must decide", false: "One of the candidates is clearly right" }, }; return { model: input.model, state: { task: "Resolve git merge conflicts for a pull request that merges the base branch into the head branch.", pull_request: { title: redact(input.title), body: redact(input.body).slice(0, input.maxBodyChars) }, path: input.path, hunks, }, questions, }; } export type HunkDecision = { selected: string | null; probabilities: Record; confident: boolean }; export type FileDecision = { hunks: HunkDecision[]; risk: { level: RiskLevel; score: number; probabilities: Record }; needsHuman: number; autoApply: boolean; reasons: string[]; }; /** Mirrors gojev's Decision.Above: the pick must reach `confidence` and lead the runner-up by `margin`. */ export function above(probabilities: Record, selected: string, thresholds: Thresholds): boolean { const top = probabilities[selected]; if (!(typeof top === "number")) return false; const runnerUp = Math.max( 0, ...Object.entries(probabilities) .filter(([id]) => id !== selected) .map(([, p]) => p), ); return top >= thresholds.confidence && top - runnerUp >= thresholds.margin; } function hunkDecision(answer: ClefResponse["answers"][string], candidates: Candidate[], thresholds: Thresholds) { const offered = new Set(candidates.map((candidate) => candidate.id)); if (answer?.type !== "choice") return { selected: null, probabilities: {}, confident: false }; const raw = isRecord(answer.probabilities) ? answer.probabilities : {}; const probabilities = Object.fromEntries(candidates.map((candidate) => [candidate.id, finite(raw[candidate.id])])); const strays = Object.keys(raw).some((id) => !offered.has(id)); const selected = offered.has(answer.choice) ? answer.choice : null; return { selected, probabilities, confident: selected !== null && !strays && above(probabilities, selected, thresholds), }; } function riskOf(answer: ClefResponse["answers"][string]): FileDecision["risk"] { const raw = answer?.type === "score" && isRecord(answer.probabilities) ? answer.probabilities : {}; const probabilities = Object.fromEntries( RISK_LEVELS.map((level, index) => [level, finite(raw[String(index)] ?? raw[level])]), ) as Record; if (answer?.type !== "score") probabilities["security-sensitive"] = 1; const level = RISK_LEVELS.reduce((best, level) => (probabilities[level] > probabilities[best] ? level : best)); const score = answer?.type === "score" && Number.isFinite(Number(answer.score)) ? Number(answer.score) : RISK_LEVELS.length - 1; return { level, score, probabilities }; } const COMMENT_LINE = /^(\/\/|#|\/\*|\*|--|