diff --git a/runsum.mts b/runsum.mts new file mode 100644 index 0000000..5eaf338 --- /dev/null +++ b/runsum.mts @@ -0,0 +1,119 @@ +/** + * One-off: summarise a project's commits from the command line. + * + * Imports the same PURE modules the app uses — batch.ts, preamble.ts, + * prompt.ts — so the prompts, the batching and the index-matched parsing are + * byte-identical to what the "Summarise" button does. Only the surrounding + * plumbing (which carries `server-only`) is reimplemented here. + */ +import PocketBase from "pocketbase"; + +import { BATCH_SIZE, buildBatchPrompt, chunk, parseBatchResponse } from "./src/lib/ai/batch"; +import { PROMPT_VERSION } from "./src/lib/ai/preamble"; +import { buildSystemPrompt } from "./src/lib/ai/prompt"; +import { stripThinking } from "./src/lib/ai/think-strip"; + +async function main() { + const pb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL!); + pb.autoCancellation(false); + await pb + .collection("_superusers") + .authWithPassword(process.env.PB_SUPERUSER_EMAIL!, process.env.PB_SUPERUSER_PASSWORD!); + + const settings = await pb.collection("settings").getFirstListItem("id != ''"); + const key: string = settings.ai_api_key ?? ""; + const model: string = settings.ai_model || "MiniMax-M2"; + if (!key) throw new Error("No API key configured — paste it in Settings first."); + + const system = buildSystemPrompt(settings.ai_system_prompt_extra ?? ""); + + const pending = await pb.collection("commits").getFullList({ + filter: [ + "is_merge = false", + `(summary = "" || summary_version < ${PROMPT_VERSION})`, + "summary_attempts < 3", + ].join(" && "), + sort: "-authored_at", + fields: "id,subject,type,scope,summary_attempts", + }); + + console.log(`provider=${settings.ai_provider} model=${model}`); + console.log(`pending commits: ${pending.length}`); + if (pending.length === 0) return; + + const batches = chunk( + pending.map((c) => ({ + id: c.id, + subject: (c.subject as string) || "", + type: (c.type as string) || "other", + scope: (c.scope as string) || "", + })), + BATCH_SIZE + ); + + let summarised = 0; + let missing = 0; + let inTok = 0; + let outTok = 0; + + for (const [index, batch] of batches.entries()) { + process.stdout.write(` batch ${index + 1}/${batches.length} … `); + + let text = ""; + try { + const res = await fetch("https://api.minimax.io/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${key}` }, + body: JSON.stringify({ + model, + max_tokens: 4096, + n: 1, + messages: [ + { role: "system", content: system }, + { role: "user", content: buildBatchPrompt(batch) }, + ], + }), + }); + + if (!res.ok) { + console.log(`HTTP ${res.status} — ${(await res.text()).slice(0, 120)}`); + continue; + } + + const json = await res.json(); + text = stripThinking(json.choices?.[0]?.message?.content ?? ""); + inTok += json.usage?.prompt_tokens ?? 0; + outTok += json.usage?.completion_tokens ?? 0; + } catch (error) { + console.log(`failed — ${(error as Error).message}`); + continue; + } + + const parsed = parseBatchResponse(text, batch); + + for (const commit of batch) { + const summary = parsed.get(commit.id); + if (!summary) { + missing += 1; + continue; + } + await pb.collection("commits").update(commit.id, { + summary, + summary_version: PROMPT_VERSION, + summary_attempts: 0, + }); + summarised += 1; + } + + console.log(`${parsed.size}/${batch.length}`); + } + + console.log(`\nsummarised: ${summarised}`); + console.log(`not returned by the model: ${missing}`); + console.log(`tokens: ${inTok} in, ${outTok} out`); +} + +main().catch((e) => { + console.error("FAILED:", e?.message ?? e); + process.exit(1); +}); diff --git a/src/components/board/board.tsx b/src/components/board/board.tsx index 927edae..6f3b45c 100644 --- a/src/components/board/board.tsx +++ b/src/components/board/board.tsx @@ -292,6 +292,16 @@ export function Board({ return (