diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index cfc9de2..a633c06 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -144,6 +144,21 @@ Falls die GGUF-Datei an einem anderen Ort liegt: HF_HOME=/mnt/daten/huggingface ./start-coding-server.sh ``` +### Anderes Modell für die Schleife + +Ohne weitere Angabe arbeiten Coder und Judge mit `llama-cpp-single/coding_model` (Port 8001). Ein anderes Modell aus +`models.json` setzt die Umgebungsvariable `PI_CODER_MODEL` beim Start von pi, als `/`: + +```bash +PI_CODER_MODEL=strata/qwen3.8-flash-next-iq3_s \ + pi --provider strata --model qwen3.8-flash-next-iq3_s +``` + +`--provider`/`--model` machen das Modell auch für freie Eingaben zum Standard dieses Laufs; die globalen +Einstellungen bleiben unverändert. Die Bereitschaftsprüfung fragt den Endpunkt aus `baseUrl` des Modells ab. Der +Provider `strata` (lokaler Strata-Server auf `127.0.0.1:9090`) steht in `models.json`; den Server startet man außerhalb +dieses Repos. + --- ## 4. Neues Projekt anlegen diff --git a/README.md b/README.md index 82295fa..387fdcd 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ nur die Persona im System-Prompt ändert sich. Coder- und Judge-Rollen werden durch verschiedene System-Prompts realisiert — dasselbe Modell, derselbe Container, unterschiedliche Persona. +Ein anderes Modell für beide Rollen (z. B. den lokalen Strata-Server) setzt `PI_CODER_MODEL=/`, +siehe [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md#anderes-modell-für-die-schleife). + --- ## Voraussetzungen diff --git a/models.json b/models.json index 8f08ae0..1b7afae 100644 --- a/models.json +++ b/models.json @@ -39,6 +39,28 @@ ] }, + "strata": { + "baseUrl": "http://127.0.0.1:9090/v1", + "api": "openai-completions", + "apiKey": "none", + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": true, + "maxTokensField": "max_tokens" + }, + "models": [ + { + "id": "qwen3.8-flash-next-iq3_s", + "name": "Strata Qwen3.8-Flash-Next IQ3_S (lokal :9090)", + "reasoning": true, + "input": ["text"], + "contextWindow": 262144, + "maxTokens": 16384, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } + } + ] + }, + "openrouter": { "models": [ { "id": "qwen/qwen3-235b-a22b:free", "name": "Qwen3 235B (Free)" }, diff --git a/pi-coding-extension.ts b/pi-coding-extension.ts index ac411d1..883fc22 100644 --- a/pi-coding-extension.ts +++ b/pi-coding-extension.ts @@ -1,6 +1,6 @@ // pi-coding-extension.ts // Automatisierter Coder-Judge-Fix-Workflow für AI-Coding-Assistenten -// Ein Modell (coding_model) auf Single-Server (Port 8001); Rollen Coder/Judge via System-Prompt +// Ein Modell (Standard: coding_model auf Single-Server :8001, sonst PI_CODER_MODEL); Rollen Coder/Judge via System-Prompt import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; @@ -432,13 +432,30 @@ async function tickTaskMdStatus( ); } -// Single-Server: genau ein Modell (coding_model auf :8001) für beide Rollen. +// Single-Modell: genau ein Modell (Standard coding_model auf :8001, oder PI_CODER_MODEL) für beide Rollen. // Der Rollenwechsel Coder/Judge erfolgt NICHT mehr über den Modellwechsel, sondern // über den System-Prompt (siehe before_agent_start-Hook + currentRole). Diese Funktion // stellt nur sicher, dass das eine Single-Modell aktiv ist; nach dem ersten Aufruf // ist der Wechsel via currentModelKey gecacht und damit ein No-op. -const SINGLE_PROVIDER = "llama-cpp-single"; -const SINGLE_MODEL_ID = "coding_model"; +// PI_CODER_MODEL="/" setzt ein anderes Modell für beide Rollen (z. B. strata/qwen3.8-flash-next-iq3_s); +// ohne die Variable bleibt es beim Single-Server auf :8001. +const DEFAULT_SINGLE_MODEL = "llama-cpp-single/coding_model"; +const SINGLE_MODEL_SPEC = (process.env.PI_CODER_MODEL || DEFAULT_SINGLE_MODEL).trim(); +const SINGLE_PROVIDER = SINGLE_MODEL_SPEC.slice(0, SINGLE_MODEL_SPEC.indexOf("/")); +const SINGLE_MODEL_ID = SINGLE_MODEL_SPEC.slice(SINGLE_MODEL_SPEC.indexOf("/") + 1); +const SINGLE_LABEL = `${SINGLE_PROVIDER}/${SINGLE_MODEL_ID}`; + +// Endpunkt des Single-Modells laut models.json, für Bereitschaftsprüfung und Meldungen. +function singleEndpoint(ctx: ExtensionCommandContext): string { + const model = ctx.modelRegistry.find(SINGLE_PROVIDER, SINGLE_MODEL_ID); + return model?.baseUrl ? model.baseUrl.replace(/\/+$/, "") : ""; +} + +function notReadyMessage(ctx: ExtensionCommandContext): string { + const where = singleEndpoint(ctx) || SINGLE_LABEL; + const hint = SINGLE_MODEL_SPEC === DEFAULT_SINGLE_MODEL ? "start-coding-server.sh ausführen" : "Server starten"; + return `Server nicht bereit (${where}) — ${hint}`; +} async function ensureSingleModel( pi: ExtensionAPI, @@ -518,17 +535,20 @@ async function sendAndWait( async function waitUntilModelReady( pi: ExtensionAPI, ctx: ExtensionCommandContext, - port: number, - modelAlias: string, maxWaitMs = 180_000 ): Promise { + const endpoint = singleEndpoint(ctx); + if (!endpoint) { + ctx.ui.notify(`Modell ${SINGLE_LABEL} nicht in models.json gefunden`, "error"); + return false; + } const deadline = Date.now() + maxWaitMs; const body = JSON.stringify({ - model: modelAlias, + model: SINGLE_MODEL_ID, messages: [{ role: "user", content: "ping" }], max_tokens: 1, temperature: 0.0, stream: false, }); - // Body als Datei — verhindert Shell-Injection wenn modelAlias Sonderzeichen enthält + // Body als Datei — verhindert Shell-Injection wenn die Modell-ID Sonderzeichen enthält const tmpBody = `/tmp/pi_ready_${Date.now()}_${Math.random().toString(36).slice(2)}.json`; await pi.exec("bash", ["-c", `printf "%s" "$1" > "${tmpBody}"`, "_", body], { cwd: ctx.cwd }); let notified = false; @@ -536,13 +556,13 @@ async function waitUntilModelReady( while (Date.now() < deadline) { const r = await pi.exec("bash", ["-c", `curl -s -o /dev/null -w "%{http_code}" --max-time 5 ` + - `-X POST http://localhost:${port}/v1/chat/completions ` + + `-X POST "$1/chat/completions" ` + `-H "Content-Type: application/json" ` + - `-d "@${tmpBody}"` + `-d "@${tmpBody}"`, "_", endpoint ], { cwd: ctx.cwd }); if (r.stdout?.trim() === "200") return true; if (!notified) { - ctx.ui.notify(`Modell-Server (Port ${port}) lädt noch — warte bis zu 3 min…`, "info"); + ctx.ui.notify(`Modell-Server (${endpoint}) lädt noch — warte bis zu 3 min…`, "info"); notified = true; } await new Promise(res => setTimeout(res, 3000)); @@ -1128,15 +1148,15 @@ export default function (pi: ExtensionAPI) { // ── Manuelle Kommandos ─────────────────────────────────────────────────── pi.registerCommand("coder", { - description: "Implementiert ohne Review-Loop → Coder-Rolle (:8001).", + description: "Implementiert ohne Review-Loop → Coder-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { const task = (args || "").trim(); if (!task) { ctx.ui.notify("Benutzung: /coder ", "error"); return; } - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await ensureLocalGitRepo(pi, ctx); @@ -1149,10 +1169,10 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("judge", { - description: "Review gegen TASK.md + git show HEAD → Judge-Rolle (:8001).", + description: "Review gegen TASK.md + git show HEAD → Judge-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await selectRole(pi, ctx, "judge"); @@ -1163,10 +1183,10 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("fix", { - description: "Fixt Judge-Kritik, committet Ergebnis → Coder-Rolle (:8001).", + description: "Fixt Judge-Kritik, committet Ergebnis → Coder-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await ensureLocalGitRepo(pi, ctx); @@ -1178,10 +1198,10 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("shipit", { - description: "Finale Freigabe gegen TASK.md + git log → Judge-Rolle (:8001).", + description: "Finale Freigabe gegen TASK.md + git log → Judge-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await selectRole(pi, ctx, "judge"); @@ -1246,11 +1266,11 @@ export default function (pi: ExtensionAPI) { : `--continue: Überspringe Implementierung, starte direkt mit Judge-Prüfung.`; ctx.ui.notify(continueMsg, "info"); - // Im --continue-Modus: Single-Server prüfen (Port 8001 — Coder + Judge in einem). - ctx.ui.setStatus("optimize", "Single-Server wird geprüft (Port 8001)…"); - const serverReady = await waitUntilModelReady(pi, ctx, 8001, "coding_model"); + // Im --continue-Modus: Single-Modell prüfen (Coder + Judge in einem). + ctx.ui.setStatus("optimize", `Single-Modell wird geprüft (${SINGLE_LABEL})…`); + const serverReady = await waitUntilModelReady(pi, ctx); if (!serverReady) { - finalNotify(ctx, "⛔ Server nicht erreichbar", "Port 8001 — kein HTTP 200 nach 3 min. start-coding-server.sh ausführen"); + finalNotify(ctx, "⛔ Server nicht erreichbar", `${notReadyMessage(ctx)} (kein HTTP 200 nach 3 min)`); return; } } else { @@ -1260,7 +1280,7 @@ export default function (pi: ExtensionAPI) { const taskPreview = task.length > 55 ? task.slice(0, 52) + "…" : task; ctx.ui.setStatus("optimize", `◉ Coder liest Anforderungen + implementiert: ${taskPreview}`); if (!await selectRole(pi, ctx, "coder")) { - finalNotify(ctx, "⛔ Modell-Fehler", "Modell (llama-cpp-single) nicht verfügbar"); + finalNotify(ctx, "⛔ Modell-Fehler", `Modell (${SINGLE_LABEL}) nicht verfügbar`); return; } currentActivity = "Coder implementiert…"; @@ -1274,8 +1294,8 @@ export default function (pi: ExtensionAPI) { // GPU-Ladevorgangs und ist kein verlässliches Signal. Nur HTTP 200 auf einen // echten Completion-Request bedeutet: Modell ist im VRAM und bereit. ctx.ui.setStatus("optimize", "Single-Server wird geprüft…"); - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - finalNotify(ctx, "⛔ Server nicht erreichbar", "Port 8001 — kein HTTP 200 nach 3 min. start-coding-server.sh ausführen"); + if (!await waitUntilModelReady(pi, ctx)) { + finalNotify(ctx, "⛔ Server nicht erreichbar", `${notReadyMessage(ctx)} (kein HTTP 200 nach 3 min)`); return; } } @@ -1313,7 +1333,7 @@ export default function (pi: ExtensionAPI) { for (let round = 1; round <= maxRounds; round++) { const prog = "●".repeat(round - 1) + "◉" + "○".repeat(maxRounds - round); if (!await selectRole(pi, ctx, "judge")) { - finalNotify(ctx, "⛔ Modell-Fehler", "Modell (llama-cpp-single) nicht verfügbar"); + finalNotify(ctx, "⛔ Modell-Fehler", `Modell (${SINGLE_LABEL}) nicht verfügbar`); return; } @@ -1378,7 +1398,7 @@ export default function (pi: ExtensionAPI) { ? (currentBlockers.length > 50 ? currentBlockers.slice(0, 47) + "…" : currentBlockers) : "Kritikpunkte aus Judge-Bericht"; if (!await selectRole(pi, ctx, "coder")) { - finalNotify(ctx, "⛔ Modell-Fehler", "Modell (llama-cpp-single) nicht verfügbar"); + finalNotify(ctx, "⛔ Modell-Fehler", `Modell (${SINGLE_LABEL}) nicht verfügbar`); return; } currentActivity = `Coder fixt: ${blockerHint}`; @@ -1471,15 +1491,15 @@ export default function (pi: ExtensionAPI) { // ── Schlanke Kommandos für kleine Änderungen ───────────────────────────── pi.registerCommand("patch", { - description: "Gezielte Minimaländerung ohne Refactoring, committet → Coder-Rolle (:8001).", + description: "Gezielte Minimaländerung ohne Refactoring, committet → Coder-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { const change = (args || "").trim(); if (!change) { ctx.ui.notify("Benutzung: /patch ", "error"); return; } - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await ensureLocalGitRepo(pi, ctx); @@ -1491,10 +1511,10 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("quick_check", { - description: "Schnelle OK/PROBLEM-Prüfung einer kleinen Codeänderung → Judge-Rolle (:8001).", + description: "Schnelle OK/PROBLEM-Prüfung einer kleinen Codeänderung → Judge-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await selectRole(pi, ctx, "judge"); @@ -1562,8 +1582,8 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("Benutzung: /plan ", "error"); return; } - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await writeTaskMd(pi, ctx, task); @@ -1645,8 +1665,8 @@ export default function (pi: ExtensionAPI) { } // Standard-Verhalten: unterbrochenen Prozess wieder aufnehmen - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await selectRole(pi, ctx, "coder"); diff --git a/tests/config.test.mjs b/tests/config.test.mjs index 606d923..592a581 100644 --- a/tests/config.test.mjs +++ b/tests/config.test.mjs @@ -15,6 +15,13 @@ test("models.json: Provider llama-cpp-single hat genau ein Modell coding_model a assert.deepEqual(p.models.map((m) => m.id), ["coding_model"]); }); +test("models.json: Provider strata zeigt auf den lokalen Strata-Server (:9090)", () => { + const p = models.providers["strata"]; + assert.ok(p, "Provider strata fehlt"); + assert.match(p.baseUrl, /^http:\/\/127\.0\.0\.1:9090\/v1$/); + assert.deepEqual(p.models.map((m) => m.id), ["qwen3.8-flash-next-iq3_s"]); +}); + test("models.json: tote Dual-/Alt-Provider sind entfernt", () => { for (const dead of ["llama-cpp", "llama-cpp-coder", "llama-cpp-judge"]) { assert.ok(!(dead in models.providers), `Toter Provider '${dead}' noch vorhanden`); diff --git a/tests/extension-guards.test.mjs b/tests/extension-guards.test.mjs index 158f811..9297e6e 100644 --- a/tests/extension-guards.test.mjs +++ b/tests/extension-guards.test.mjs @@ -61,3 +61,14 @@ test("LICENCE.md: deterministisch in der Doku-Phase, nur wenn nicht vorhanden, p assert.match(extSource, /git config user\.name/); assert.match(extSource, /new Date\(\)\.getFullYear\(\)/); }); + +test("Single-Modell: Standard bleibt llama-cpp-single/coding_model, PI_CODER_MODEL überschreibt", () => { + assert.match(extSource, /DEFAULT_SINGLE_MODEL = "llama-cpp-single\/coding_model"/); + assert.match(extSource, /process\.env\.PI_CODER_MODEL \|\| DEFAULT_SINGLE_MODEL/); +}); + +test("Bereitschaftsprüfung: Endpunkt aus models.json statt fest Port 8001", () => { + assert.doesNotMatch(extSource, /waitUntilModelReady\(pi, ctx, 8001/); + assert.doesNotMatch(extSource, /localhost:\$\{port\}/); + assert.match(extSource, /function singleEndpoint\b/); +});