diff --git a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts index 373322c55a..4bdda329c7 100644 --- a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts +++ b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts @@ -7,23 +7,23 @@ export type SelfHostEnvReferenceRow = { export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ { name: "AI_COMBINE", - firstReference: "src/selfhost/ai.ts:936", + firstReference: "src/selfhost/ai.ts:968", }, { name: "AI_EMBED_API_KEY", - firstReference: "src/server.ts:445", + firstReference: "src/server.ts:440", }, { name: "AI_EMBED_BASE_URL", - firstReference: "src/server.ts:442", + firstReference: "src/server.ts:437", }, { name: "AI_EMBED_MODEL", - firstReference: "src/selfhost/ai.ts:832", + firstReference: "src/selfhost/ai.ts:864", }, { name: "AI_ON_MERGE", - firstReference: "src/selfhost/ai.ts:938", + firstReference: "src/selfhost/ai.ts:970", }, { name: "AI_PROVIDER", @@ -31,19 +31,19 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "ANTHROPIC_AI_BASE_URL", - firstReference: "src/selfhost/ai.ts:836", + firstReference: "src/selfhost/ai.ts:868", }, { name: "ANTHROPIC_AI_MODEL", - firstReference: "src/selfhost/ai.ts:57", + firstReference: "src/selfhost/ai.ts:85", }, { name: "ANTHROPIC_API_KEY", - firstReference: "src/selfhost/ai.ts:835", + firstReference: "src/selfhost/ai.ts:867", }, { name: "BACKUP_ACKNOWLEDGED", - firstReference: "src/server.ts:384", + firstReference: "src/server.ts:379", }, { name: "BROWSER_WS_ENDPOINT", @@ -51,39 +51,39 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "CLAUDE_AI_EFFORT", - firstReference: "src/selfhost/ai.ts:108", + firstReference: "src/selfhost/ai.ts:136", }, { name: "CLAUDE_AI_MODEL", - firstReference: "src/selfhost/ai.ts:49", + firstReference: "src/selfhost/ai.ts:77", }, { name: "CLAUDE_AI_TIMEOUT_MS", - firstReference: "src/selfhost/ai.ts:108", + firstReference: "src/selfhost/ai.ts:136", }, { name: "CODEX_AI_EFFORT", - firstReference: "src/selfhost/ai.ts:112", + firstReference: "src/selfhost/ai.ts:140", }, { name: "CODEX_AI_MODEL", - firstReference: "src/selfhost/ai.ts:53", + firstReference: "src/selfhost/ai.ts:81", }, { name: "CODEX_AI_TIMEOUT_MS", - firstReference: "src/selfhost/ai.ts:112", + firstReference: "src/selfhost/ai.ts:140", }, { name: "CODEX_HOME", - firstReference: "src/selfhost/ai.ts:274", + firstReference: "src/selfhost/ai.ts:302", }, { name: "CRON_INTERVAL_MS", - firstReference: "src/server.ts:921", + firstReference: "src/server.ts:916", }, { name: "DATABASE_PATH", - firstReference: "src/server.ts:250", + firstReference: "src/server.ts:249", }, { name: "DATABASE_URL", @@ -111,19 +111,19 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "GITHUB_CACHE_TTL_SECONDS", - firstReference: "src/server.ts:513", + firstReference: "src/server.ts:508", }, { name: "GITTENSORY_REPO_CONFIG_DIR", - firstReference: "src/server.ts:289", + firstReference: "src/server.ts:288", }, { name: "GITTENSORY_VERSION", - firstReference: "src/selfhost/health.ts:29", + firstReference: "src/selfhost/otel.ts:62", }, { name: "HOME", - firstReference: "src/selfhost/ai.ts:274", + firstReference: "src/selfhost/ai.ts:302", }, { name: "MAINTENANCE_ADMISSION_ENABLED", @@ -131,7 +131,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "MIGRATIONS_DIR", - firstReference: "src/server.ts:397", + firstReference: "src/server.ts:392", }, { name: "OBSERVABILITY_SMOKE_POLL_MS", @@ -143,39 +143,39 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "OLLAMA_AI_API_KEY", - firstReference: "src/selfhost/ai.ts:829", + firstReference: "src/selfhost/ai.ts:861", }, { name: "OLLAMA_AI_BASE_URL", - firstReference: "src/selfhost/ai.ts:825", + firstReference: "src/selfhost/ai.ts:857", }, { name: "OLLAMA_AI_MODEL", - firstReference: "src/selfhost/ai.ts:61", + firstReference: "src/selfhost/ai.ts:89", }, { name: "OPENAI_AI_BASE_URL", - firstReference: "src/selfhost/ai.ts:827", + firstReference: "src/selfhost/ai.ts:859", }, { name: "OPENAI_AI_MODEL", - firstReference: "src/selfhost/ai.ts:62", + firstReference: "src/selfhost/ai.ts:90", }, { name: "OPENAI_API_KEY", - firstReference: "src/selfhost/ai.ts:829", + firstReference: "src/selfhost/ai.ts:861", }, { name: "OPENAI_COMPATIBLE_AI_API_KEY", - firstReference: "src/selfhost/ai.ts:829", + firstReference: "src/selfhost/ai.ts:861", }, { name: "OPENAI_COMPATIBLE_AI_BASE_URL", - firstReference: "src/selfhost/ai.ts:828", + firstReference: "src/selfhost/ai.ts:860", }, { name: "OPENAI_COMPATIBLE_AI_MODEL", - firstReference: "src/selfhost/ai.ts:63", + firstReference: "src/selfhost/ai.ts:91", }, { name: "ORB_AIR_GAP", @@ -191,7 +191,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "ORB_BROKER_URL", - firstReference: "src/server.ts:970", + firstReference: "src/server.ts:965", }, { name: "ORB_COLLECTOR_TOKEN", @@ -207,7 +207,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "ORB_RELAY_MODE", - firstReference: "src/server.ts:972", + firstReference: "src/server.ts:967", }, { name: "OTEL_EXPORTER_OTLP_ENDPOINT", @@ -239,11 +239,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "PGVECTOR_ENABLED", - firstReference: "src/server.ts:230", + firstReference: "src/server.ts:229", }, { name: "PORT", - firstReference: "src/server.ts:720", + firstReference: "src/server.ts:715", }, { name: "PUBLIC_API_ORIGIN", @@ -259,7 +259,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "QDRANT_URL", - firstReference: "src/server.ts:532", + firstReference: "src/server.ts:527", }, { name: "QUEUE_BACKGROUND_CONCURRENCY", @@ -271,7 +271,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "REVIEW_AUDIT_DIR", - firstReference: "src/server.ts:577", + firstReference: "src/server.ts:572", }, { name: "SELFHOST_BUNDLE_ALL", @@ -307,7 +307,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "SETUP_OUTPUT_PATH", - firstReference: "src/server.ts:837", + firstReference: "src/server.ts:832", }, { name: "SLACK_WEBHOOK_URL", @@ -318,57 +318,57 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| Name | First reference |", "| --- | --- |", - "| `AI_COMBINE` | `src/selfhost/ai.ts:936` |", - "| `AI_EMBED_API_KEY` | `src/server.ts:445` |", - "| `AI_EMBED_BASE_URL` | `src/server.ts:442` |", - "| `AI_EMBED_MODEL` | `src/selfhost/ai.ts:832` |", - "| `AI_ON_MERGE` | `src/selfhost/ai.ts:938` |", + "| `AI_COMBINE` | `src/selfhost/ai.ts:968` |", + "| `AI_EMBED_API_KEY` | `src/server.ts:440` |", + "| `AI_EMBED_BASE_URL` | `src/server.ts:437` |", + "| `AI_EMBED_MODEL` | `src/selfhost/ai.ts:864` |", + "| `AI_ON_MERGE` | `src/selfhost/ai.ts:970` |", "| `AI_PROVIDER` | `src/selfhost/ai-config.ts:43` |", - "| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts:836` |", - "| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts:57` |", - "| `ANTHROPIC_API_KEY` | `src/selfhost/ai.ts:835` |", - "| `BACKUP_ACKNOWLEDGED` | `src/server.ts:384` |", + "| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts:868` |", + "| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts:85` |", + "| `ANTHROPIC_API_KEY` | `src/selfhost/ai.ts:867` |", + "| `BACKUP_ACKNOWLEDGED` | `src/server.ts:379` |", "| `BROWSER_WS_ENDPOINT` | `src/selfhost/stubs/puppeteer.ts:11` |", - "| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts:108` |", - "| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts:49` |", - "| `CLAUDE_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:108` |", - "| `CODEX_AI_EFFORT` | `src/selfhost/ai.ts:112` |", - "| `CODEX_AI_MODEL` | `src/selfhost/ai.ts:53` |", - "| `CODEX_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:112` |", - "| `CODEX_HOME` | `src/selfhost/ai.ts:274` |", - "| `CRON_INTERVAL_MS` | `src/server.ts:921` |", - "| `DATABASE_PATH` | `src/server.ts:250` |", + "| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts:136` |", + "| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts:77` |", + "| `CLAUDE_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:136` |", + "| `CODEX_AI_EFFORT` | `src/selfhost/ai.ts:140` |", + "| `CODEX_AI_MODEL` | `src/selfhost/ai.ts:81` |", + "| `CODEX_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:140` |", + "| `CODEX_HOME` | `src/selfhost/ai.ts:302` |", + "| `CRON_INTERVAL_MS` | `src/server.ts:916` |", + "| `DATABASE_PATH` | `src/server.ts:249` |", "| `DATABASE_URL` | `src/selfhost/preflight.ts:201` |", "| `DISCORD_REPO_WEBHOOKS` | `src/services/notify-discord.ts:41` |", "| `DISCORD_WEBHOOK_URL` | `src/services/notify-discord.ts:78` |", "| `FOREGROUND_LIVENESS_ENABLED` | `src/selfhost/foreground-liveness.ts:41` |", "| `GITHUB_APP_ID` | `src/selfhost/orb-collector.ts:59` |", "| `GITHUB_APP_PRIVATE_KEY` | `src/selfhost/orb-collector.ts:166` |", - "| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:513` |", - "| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:289` |", - "| `GITTENSORY_VERSION` | `src/selfhost/health.ts:29` |", - "| `HOME` | `src/selfhost/ai.ts:274` |", + "| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:508` |", + "| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:288` |", + "| `GITTENSORY_VERSION` | `src/selfhost/otel.ts:62` |", + "| `HOME` | `src/selfhost/ai.ts:302` |", "| `MAINTENANCE_ADMISSION_ENABLED` | `src/selfhost/maintenance-admission.ts:126` |", - "| `MIGRATIONS_DIR` | `src/server.ts:397` |", + "| `MIGRATIONS_DIR` | `src/server.ts:392` |", "| `OBSERVABILITY_SMOKE_POLL_MS` | `scripts/smoke-observability-traces.mjs:8` |", "| `OBSERVABILITY_SMOKE_TIMEOUT_MS` | `scripts/smoke-observability-traces.mjs:6` |", - "| `OLLAMA_AI_API_KEY` | `src/selfhost/ai.ts:829` |", - "| `OLLAMA_AI_BASE_URL` | `src/selfhost/ai.ts:825` |", - "| `OLLAMA_AI_MODEL` | `src/selfhost/ai.ts:61` |", - "| `OPENAI_AI_BASE_URL` | `src/selfhost/ai.ts:827` |", - "| `OPENAI_AI_MODEL` | `src/selfhost/ai.ts:62` |", - "| `OPENAI_API_KEY` | `src/selfhost/ai.ts:829` |", - "| `OPENAI_COMPATIBLE_AI_API_KEY` | `src/selfhost/ai.ts:829` |", - "| `OPENAI_COMPATIBLE_AI_BASE_URL` | `src/selfhost/ai.ts:828` |", - "| `OPENAI_COMPATIBLE_AI_MODEL` | `src/selfhost/ai.ts:63` |", + "| `OLLAMA_AI_API_KEY` | `src/selfhost/ai.ts:861` |", + "| `OLLAMA_AI_BASE_URL` | `src/selfhost/ai.ts:857` |", + "| `OLLAMA_AI_MODEL` | `src/selfhost/ai.ts:89` |", + "| `OPENAI_AI_BASE_URL` | `src/selfhost/ai.ts:859` |", + "| `OPENAI_AI_MODEL` | `src/selfhost/ai.ts:90` |", + "| `OPENAI_API_KEY` | `src/selfhost/ai.ts:861` |", + "| `OPENAI_COMPATIBLE_AI_API_KEY` | `src/selfhost/ai.ts:861` |", + "| `OPENAI_COMPATIBLE_AI_BASE_URL` | `src/selfhost/ai.ts:860` |", + "| `OPENAI_COMPATIBLE_AI_MODEL` | `src/selfhost/ai.ts:91` |", "| `ORB_AIR_GAP` | `src/selfhost/orb-collector.ts:161` |", "| `ORB_ANONYMIZE` | `src/selfhost/orb-collector.ts:174` |", "| `ORB_APP_ID` | `src/selfhost/orb-collector.ts:59` |", - "| `ORB_BROKER_URL` | `src/server.ts:970` |", + "| `ORB_BROKER_URL` | `src/server.ts:965` |", "| `ORB_COLLECTOR_TOKEN` | `src/selfhost/orb-collector.ts:205` |", "| `ORB_COLLECTOR_URL` | `src/selfhost/orb-collector.ts:172` |", "| `ORB_ENROLLMENT_SECRET` | `src/selfhost/orb-collector.ts:165` |", - "| `ORB_RELAY_MODE` | `src/server.ts:972` |", + "| `ORB_RELAY_MODE` | `src/server.ts:967` |", "| `OTEL_EXPORTER_OTLP_ENDPOINT` | `src/selfhost/otel.ts:47` |", "| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `src/selfhost/otel.ts:45` |", "| `OTEL_SERVICE_ENVIRONMENT` | `src/selfhost/otel.ts:60` |", @@ -376,15 +376,15 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `OTEL_TRACES_EXPORTER` | `src/selfhost/otel.ts:40` |", "| `OTEL_TRACES_SAMPLER` | `src/selfhost/otel.ts:74` |", "| `OTEL_TRACES_SAMPLER_ARG` | `src/selfhost/otel.ts:76` |", - "| `PGVECTOR_ENABLED` | `src/server.ts:230` |", - "| `PORT` | `src/server.ts:720` |", + "| `PGVECTOR_ENABLED` | `src/server.ts:229` |", + "| `PORT` | `src/server.ts:715` |", "| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts:192` |", "| `QDRANT_API_KEY` | `src/selfhost/qdrant-vectorize.ts:50` |", "| `QDRANT_DIM` | `src/selfhost/qdrant-vectorize.ts:71` |", - "| `QDRANT_URL` | `src/server.ts:532` |", + "| `QDRANT_URL` | `src/server.ts:527` |", "| `QUEUE_BACKGROUND_CONCURRENCY` | `src/selfhost/queue-common.ts:120` |", "| `REDIS_URL` | `src/selfhost/preflight.ts:144` |", - "| `REVIEW_AUDIT_DIR` | `src/server.ts:577` |", + "| `REVIEW_AUDIT_DIR` | `src/server.ts:572` |", "| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.mjs:13` |", "| `SELFHOST_SERVICE` | `scripts/smoke-observability-traces.mjs:5` |", "| `SELFHOST_SETUP_TOKEN` | `src/selfhost/preflight.ts:186` |", @@ -393,6 +393,6 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `SENTRY_RELEASE` | `src/selfhost/otel.ts:62` |", "| `SENTRY_SERVER_NAME` | `src/selfhost/sentry.ts:383` |", "| `SENTRY_TRACES_SAMPLE_RATE` | `src/selfhost/sentry.ts:171` |", - "| `SETUP_OUTPUT_PATH` | `src/server.ts:837` |", + "| `SETUP_OUTPUT_PATH` | `src/server.ts:832` |", "| `SLACK_WEBHOOK_URL` | `src/services/notify-discord.ts:173` |", ].join("\n"); diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 7da290ebb9..9bf5844e54 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -16,6 +16,7 @@ import { delimiter } from "node:path"; interface AiRunOptions { messages?: Array<{ role: string; content: string }>; prompt?: string; + systemAppend?: string; text?: string[]; // embedding input — the core's embedTexts passes { text: string[] } max_tokens?: number; temperature?: number; @@ -32,6 +33,33 @@ function toMessages(options: AiRunOptions): Array<{ role: string; content: strin return [{ role: "user", content: String(options.prompt ?? "") }]; } +function normalizedSystemAppend(options: AiRunOptions): string | undefined { + const trimmed = options.systemAppend?.trim(); + return trimmed ? trimmed : undefined; +} + +function stripSystemAppend(content: string, systemAppend: string): string { + const index = content.indexOf(systemAppend); + if (index < 0) return content; + return `${content.slice(0, index)}${content.slice(index + systemAppend.length)}`.trimEnd(); +} + +function toCliPrompt(options: AiRunOptions, systemAppend: string | undefined): string { + return toMessages(options) + .map((message) => + systemAppend && message.role === "system" + ? stripSystemAppend(message.content, systemAppend) + : message.content, + ) + .join("\n\n"); +} + +function prependCodexSystemAppend(prompt: string, systemAppend: string | undefined): string { + return systemAppend + ? `ADDITIONAL SYSTEM INSTRUCTIONS:\n${systemAppend}\n\n${prompt}` + : prompt; +} + /** The core passes a Workers-AI model id (e.g. "@cf/meta/llama-3.1-8b-instruct-fp8-fast") that is meaningless * off-Workers — handing it to Ollama or `claude --model` fails. Prefer the provider-specific self-host model, * then any non-Workers model the core passed, then a provider default. */ @@ -565,12 +593,15 @@ export function createClaudeCodeAi(parentEnv: Record try { if (!token) throw new Error("claude_code_no_oauth_token"); const env = subscriptionCliEnv(parentEnv, { CLAUDE_CODE_OAUTH_TOKEN: token }); - const prompt = toMessages(options).map((m) => m.content).join("\n\n"); + const systemAppend = normalizedSystemAppend(options); + const prompt = toCliPrompt(options, systemAppend); const spawn = spawnImpl ?? (await defaultSpawn()); + const args = ["--print", "--output-format", "json", "--model", claudeModel, "--permission-mode", "plan", "--effort", effort, "--disallowedTools", "Bash,Edit,Write,WebFetch,WebSearch"]; + if (systemAppend) args.push("--append-system-prompt", systemAppend); attempted = true; const { stdout, code, stderr, timedOut } = await spawn( "claude", - ["--print", "--output-format", "json", "--model", claudeModel, "--permission-mode", "plan", "--effort", effort, "--disallowedTools", "Bash,Edit,Write,WebFetch,WebSearch"], + args, { env, input: prompt, timeoutMs, cwd: await isolatedCliCwd() }, ); stdoutForMetrics = stdout; @@ -619,7 +650,8 @@ export function createCodexAi( assertCodexCredentialIsolation(parentEnv); await authCheckImpl(parentEnv); const env = codexCliEnv(parentEnv); - const prompt = toMessages(options).map((m) => m.content).join("\n\n"); + const systemAppend = normalizedSystemAppend(options); + const prompt = prependCodexSystemAppend(toCliPrompt(options, systemAppend), systemAppend); const spawn = spawnImpl ?? (await defaultSpawn()); const args = ["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only"]; if (codexModel) args.push("--model", codexModel); diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index e541267b98..9e1d1a9c8c 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -355,6 +355,13 @@ type AiRunner = { ) => Promise; }; +function selfHostCliSystemAppend(model: string, systemAppend: string): string | undefined { + const trimmed = systemAppend.trim(); + if (!trimmed) return undefined; + const [provider = ""] = model.trim().toLowerCase().split(":"); + return provider === "claude-code" || provider === "codex" ? trimmed : undefined; +} + // Exported so the sibling AI-advisory features (e.g. the slop advisory in `./ai-slop`) share ONE budget // window + neuron estimator and never drift from the review path's accounting. export function isEnabled(value: string | undefined): boolean { @@ -648,13 +655,19 @@ function buildSystemPrompt(input: GittensoryAiReviewInput): string { const pathSuffix = input.pathGuidance?.trim() ? input.pathGuidance : ""; // `.gittensory.yml` review.instructions (#review-instructions): a repo-level maintainer brief appended to every // review; empty ⇒ nothing appended (byte-identical). - const repoInstructionsSuffix = input.repoInstructions?.trim() - ? ` REPOSITORY REVIEW INSTRUCTIONS (maintainer conventions for this repo — honor them unless they conflict with a real defect): ${input.repoInstructions.trim()}` - : ""; + const repoInstructionsAppend = buildRepoInstructionsSystemAppend(input.repoInstructions); + const repoInstructionsSuffix = repoInstructionsAppend ? ` ${repoInstructionsAppend}` : ""; const inlineSuffix = input.inlineFindings ? INLINE_FINDINGS_SUFFIX : ""; return `${REVIEW_SYSTEM_PROMPT}${groundingSuffix}${enrichmentSuffix}${profileSuffix}${securityFocusSuffix}${pathSuffix}${repoInstructionsSuffix}${inlineSuffix}`; } +function buildRepoInstructionsSystemAppend(repoInstructions: string | null | undefined): string { + const trimmed = repoInstructions?.trim(); + return trimmed + ? `REPOSITORY REVIEW INSTRUCTIONS (maintainer conventions for this repo — honor them unless they conflict with a real defect): ${trimmed}` + : ""; +} + /** One Workers-AI opinion with a per-slot reliable fallback and a 3× retry on the primary. */ async function runWorkersOpinion( env: Env, @@ -664,6 +677,7 @@ async function runWorkersOpinion( user: string, maxTokens: number, diagnostics: AiReviewDiagnostic[] = [], + systemAppend = "", ): Promise { const ai = env.AI as unknown as AiRunner | undefined; if (!ai || typeof ai.run !== "function") return { review: null }; @@ -685,6 +699,7 @@ async function runWorkersOpinion( : [primary]) { for (let attempt = 0; attempt < 3; attempt += 1) { try { + const cliSystemAppend = selfHostCliSystemAppend(model, systemAppend); const result = await ai.run( model, { @@ -694,6 +709,7 @@ async function runWorkersOpinion( { role: "system", content: system }, { role: "user", content: user }, ], + ...(cliSystemAppend ? { systemAppend: cliSystemAppend } : {}), }, extra, ); @@ -1162,6 +1178,7 @@ export async function runGittensoryAiReview( // reviewers are told to verify claims against the attached CI/files; otherwise this is REVIEW_SYSTEM_PROMPT // unchanged (byte-identical). Computed from `promptInput` so it travels with the (possibly defanged) input. const system = buildSystemPrompt(promptInput); + const repoInstructionsSystemAppend = buildRepoInstructionsSystemAppend(promptInput.repoInstructions); // The daily neuron budget governs FREE Workers-AI spend only. BYOK advisory calls bill the maintainer's // own provider account, so they are not counted here (and a BYOK advisory still runs when the free // budget is exhausted). Free calls = the consensus pair in block mode (always Workers AI), plus the @@ -1286,6 +1303,7 @@ export async function runGittensoryAiReview( user, maxTokens, reviewDiagnostics, + repoInstructionsSystemAppend, ); advisoryReview = outcome.review; if (outcome.fallbackNote) fallbackNotes.push(outcome.fallbackNote); @@ -1311,6 +1329,7 @@ export async function runGittensoryAiReview( user, maxTokens, reviewDiagnostics, + repoInstructionsSystemAppend, ) : Promise.resolve({ review: advisoryReview }), runWorkersOpinion( @@ -1321,6 +1340,7 @@ export async function runGittensoryAiReview( user, maxTokens, reviewDiagnostics, + repoInstructionsSystemAppend, ), ]); if (a.fallbackNote) fallbackNotes.push(a.fallbackNote); @@ -1345,6 +1365,7 @@ export async function runGittensoryAiReview( user, maxTokens, reviewDiagnostics, + repoInstructionsSystemAppend, ) : ({ review: advisoryReview } as ReviewerOpinionOutcome); if (a.fallbackNote) fallbackNotes.push(a.fallbackNote); diff --git a/test/unit/ai-review.test.ts b/test/unit/ai-review.test.ts index f181c2f053..628ac3d476 100644 --- a/test/unit/ai-review.test.ts +++ b/test/unit/ai-review.test.ts @@ -341,9 +341,12 @@ describe("review.profile shapes the reviewer system prompt (#review-profile)", ( }); it("repoInstructions (#review-instructions) is appended to the system prompt; absent leaves it byte-identical", async () => { + const optionsOf = (run: ReturnType): { messages?: Array<{ content?: string }>; systemAppend?: string } => { + const calls = run.mock.calls as unknown as Array<[unknown, { messages?: Array<{ content?: string }>; systemAppend?: string }]>; + return calls[0]?.[1] ?? {}; + }; const systemPromptOf = (run: ReturnType): string => - (run.mock.calls[0]?.[1] as { messages?: Array<{ content?: string }> }) - ?.messages?.[0]?.content ?? ""; + optionsOf(run).messages?.[0]?.content ?? ""; const runInstr = async (repoInstructions: string | undefined) => { const run = vi.fn(async () => ({ response: reviewJson() })); const env = createTestEnv({ @@ -353,20 +356,50 @@ describe("review.profile shapes the reviewer system prompt (#review-profile)", ( AI_DAILY_NEURON_BUDGET: "100000", }); await runGittensoryAiReview(env, { ...baseInput, repoInstructions }); - return systemPromptOf(run); + return { system: systemPromptOf(run), options: optionsOf(run) }; }; const withInstr = await runInstr("Follow our async-error conventions."); - expect(withInstr).toContain("REPOSITORY REVIEW INSTRUCTIONS"); - expect(withInstr).toContain("async-error conventions"); + expect(withInstr.system).toContain("REPOSITORY REVIEW INSTRUCTIONS"); + expect(withInstr.system).toContain("async-error conventions"); + expect(withInstr.options.systemAppend).toBeUndefined(); // Absent or whitespace-only → no append (byte-identical prompt). - expect(await runInstr(undefined)).not.toContain( + expect((await runInstr(undefined)).system).not.toContain( "REPOSITORY REVIEW INSTRUCTIONS", ); - expect(await runInstr(" ")).not.toContain( + expect((await runInstr(" ")).system).not.toContain( "REPOSITORY REVIEW INSTRUCTIONS", ); }); + it("repoInstructions are passed as systemAppend only for self-host CLI reviewers (#1471)", async () => { + const optionsFor = async (model: string, repoInstructions: string | undefined) => { + const run = vi.fn(async () => ({ response: reviewJson() })); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "100000", + }); + await runGittensoryAiReview(env, { + ...baseInput, + reviewers: [{ model }], + combine: "single", + repoInstructions, + }); + const calls = run.mock.calls as unknown as Array<[unknown, { messages?: Array<{ content?: string }>; systemAppend?: string }]>; + return calls[0]?.[1] ?? {}; + }; + + for (const model of ["claude-code", "codex"]) { + const options = await optionsFor(model, "Follow our async-error conventions."); + expect(options.systemAppend).toContain("REPOSITORY REVIEW INSTRUCTIONS"); + expect(options.systemAppend).toContain("async-error conventions"); + expect(options.messages?.[0]?.content).toContain(options.systemAppend); + } + expect((await optionsFor("claude-code", undefined)).systemAppend).toBeUndefined(); + expect((await optionsFor("claude-code", " ")).systemAppend).toBeUndefined(); + }); + it("the inline-findings instruction is appended to the system prompt ONLY when requested (#inline-comments)", async () => { const systemPromptOf = (run: ReturnType): string => (run.mock.calls[0]?.[1] as { messages?: Array<{ content?: string }> }) diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index bade34b448..dd6a3d888b 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -80,6 +80,10 @@ type StubSpawn = ( // the preflight itself (resolveCodexAuthPath / assertCodexAuthConfigured) is covered separately below. const noAuthCheck = async () => undefined; +function countOccurrences(haystack: string | undefined, needle: string): number { + return haystack?.split(needle).length ? haystack.split(needle).length - 1 : 0; +} + describe("createOpenAiCompatibleAi (#979)", () => { it("POSTs to /chat/completions and returns { response }", async () => { const calls: Array<{ url: string; body: { model: string } }> = []; @@ -717,6 +721,7 @@ describe("subscription CLI helpers + fail-safe", () => { await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, cap).run("", { prompt: "x" }); expect(seen[seen.indexOf("--model") + 1]).toBe("claude-sonnet-4-6"); expect(seen[seen.indexOf("--effort") + 1]).toBe("high"); + expect(seen).not.toContain("--append-system-prompt"); expect(timeout).toBe(240_000); // high → 240s (not the old fixed 120s) // Provider-specific overrides flow through to the argv + timeout scale. await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t", CLAUDE_AI_MODEL: "claude-opus-4-8", CLAUDE_AI_EFFORT: "max" }, cap).run("", { prompt: "x" }); @@ -725,6 +730,35 @@ describe("subscription CLI helpers + fail-safe", () => { expect(timeout).toBe(600_000); // max → 600s, so a large max-effort review isn't SIGKILLed at 120s }); + it("Claude Code passes systemAppend through --append-system-prompt and strips the duplicate stdin copy (#1471)", async () => { + const systemAppend = "REPOSITORY REVIEW INSTRUCTIONS: Follow async-error conventions."; + let seen: string[] = []; + let capturedInput = ""; + const cap: StubSpawn = async (_c, a, o) => { + seen = a; + capturedInput = o.input ?? ""; + return { stdout: JSON.stringify({ type: "result", result: "ok" }), code: 0 }; + }; + await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, cap).run("", { + messages: [ + { role: "system", content: `Base system. ${systemAppend}` }, + { role: "user", content: "Review this diff." }, + ], + systemAppend, + }); + expect(seen[seen.indexOf("--append-system-prompt") + 1]).toBe(systemAppend); + expect(capturedInput).toContain("Base system."); + expect(capturedInput).toContain("Review this diff."); + expect(capturedInput).not.toContain(systemAppend); + + await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, cap).run("", { + prompt: "Review this diff.", + systemAppend: " ", + }); + expect(seen).not.toContain("--append-system-prompt"); + expect(capturedInput).toBe("Review this diff."); + }); + it("chat-only CLIs reject embeds so the chain routes embeddings to an embed-capable provider (Claude review + ollama embed)", async () => { const reviewOk: StubSpawn = async () => ({ stdout: JSON.stringify({ type: "result", result: "the review" }), code: 0 }); // A stand-in embed-capable provider (e.g. ollama): returns `data` for an embed request, `response` for chat. @@ -776,6 +810,36 @@ describe("subscription CLI helpers + fail-safe", () => { await expect(createCodexAi({ GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER: "1" }, bad, noAuthCheck).run("", { prompt: "x" })).rejects.toThrow(/codex_exit_1/); }); + it("Codex prepends systemAppend to stdin once and strips an existing system copy (#1471)", async () => { + const systemAppend = "REPOSITORY REVIEW INSTRUCTIONS: Follow async-error conventions."; + let capturedInput = ""; + const ok: StubSpawn = async (_cmd, _args, opts) => { + capturedInput = opts.input ?? ""; + return { stdout: JSON.stringify({ type: "result", result: "codex review" }), code: 0 }; + }; + await createCodexAi({ GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER: "1" }, ok, noAuthCheck).run("", { + messages: [ + { role: "system", content: `Base system. ${systemAppend}` }, + { role: "user", content: "Review this diff." }, + ], + systemAppend, + }); + expect(capturedInput.startsWith("ADDITIONAL SYSTEM INSTRUCTIONS:\n")).toBe(true); + expect(countOccurrences(capturedInput, systemAppend)).toBe(1); + expect(capturedInput).toContain("Base system."); + expect(capturedInput).toContain("Review this diff."); + + await createCodexAi({ GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER: "1" }, ok, noAuthCheck).run("", { + messages: [ + { role: "system", content: "Base system without the append block." }, + { role: "user", content: "Review this diff." }, + ], + systemAppend, + }); + expect(countOccurrences(capturedInput, systemAppend)).toBe(1); + expect(capturedInput).toContain("Base system without the append block."); + }); + it("drives the REAL subprocess (defaultSpawn) against a fake `claude` on PATH", async () => { const dir = mkdtempSync(join(tmpdir(), "fakecli-")); const fake = join(dir, "claude");