/** * Main-process model bridge — Reginald's brain runs here. Model traffic (like * gitea's) stays in main: the renderer is CSP-locked and never talks to the * LLM directly. On `model:chat` it drives the agent loop against the configured * OpenAI-compatible endpoint, executing `query_project` by reconciling the repo * and building the requested view. v0 is read-only — writes still go through the * propose-approve controls. */ import { buildProjectView, captureWork, type ChangeProposal, type ChatMessage, createChatClient, describeChange, type ModelRouter, type ProjectView, proposalsFor, type ProposeChangeArgs, type QueryFilters, REGINALD_SYSTEM, REGINALD_TOOLS, runAgentTurn, toDirectiveInput, } from '@commitea/core' import { ipcMain } from 'electron' import { AGENT_SNAPSHOT_TTL_MS, appendDirectiveEntry, getGiteaClient, getPmStateClient, getSnapshot, } from './gitea.js' /** Small local model for prose + the read tool; big model reserved for later decomposition. */ function resolveModelRouter(): ModelRouter | null { if (process.env.COMMITEA_E2E === '1') return null // e2e uses the scripted fixture Reginald const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1' if (!baseUrl) return null return { small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' }, big: { baseUrl, model: process.env.COMMITEA_MODEL_BIG ?? '' }, } } /** * Resolve which model to actually ask for. An explicit env override wins; * otherwise ask the server which model is *loaded* (LM Studio's native * `/api/v0/models`) so Reginald follows whatever you load — no config churn on a * model switch. Falls back to the first non-embedding model, then a sane default. */ async function resolveLoadedModel(baseUrl: string, override: string): Promise { if (override) return override const root = baseUrl.replace(/\/v1\/?$/, '') try { const res = await fetch(`${root}/api/v0/models`) if (res.ok) { const data = (await res.json()) as { data?: { id: string; state?: string; type?: string }[] } const loaded = (data.data ?? []).find((m) => m.state === 'loaded' && m.type !== 'embeddings') if (loaded) return loaded.id } } catch { // native API unavailable — fall through to the OpenAI listing } try { const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`) if (res.ok) { const data = (await res.json()) as { data?: { id: string }[] } const first = (data.data ?? []).find((m) => !/embed/i.test(m.id)) if (first) return first.id } } catch { // ignore — use the default } return 'google/gemma-4-e4b' } export function registerModelIpc(): void { const router = resolveModelRouter() ipcMain.handle('model:status', async () => { if (!router) return { configured: false, model: null } const model = await resolveLoadedModel(router.small.baseUrl, router.small.model) return { configured: true, model } }) ipcMain.handle('model:chat', async (event, messages: ChatMessage[]) => { if (!router) return { ok: false as const, reason: 'unconfigured' as const } const client = getGiteaClient() const model = await resolveLoadedModel(router.small.baseUrl, router.small.model) const chat = createChatClient({ ...router.small, model }, fetch) // stream the model's prose to the renderer token-by-token const onToken = (delta: string) => { if (!event.sender.isDestroyed()) event.sender.send('model:chat:token', delta) } // Proposals the model formulates this turn; the renderer approves them (the // write happens through gitea:applyChange, never inside the loop). const proposals: ChangeProposal[] = [] const execute = async (name: string, args: unknown) => { if (!client) return { error: 'gitea is not configured' } if (name === 'query_project') { // reuse a recent reconcile — a multi-tool turn shouldn't refetch the repo each call const snap = await getSnapshot(client, { maxAgeMs: AGENT_SNAPSHOT_TTL_MS }) const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters } return buildProjectView(a.view, a.filters, snap, new Date()) } if (name === 'propose_change') { const a = (args ?? {}) as ProposeChangeArgs const issue = await client.getIssue(a.issue).catch(() => null) if (!issue) return { error: `issue #${a.issue} not found` } const built = proposalsFor(a, issue.labels, issue.title) proposals.push(...built) return built.length ? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) } : { proposed: [], note: 'no change — already at that value' } } if (name === 'record_directive') { const pm = getPmStateClient() if (!pm) return { error: 'pm-state is not configured' } try { const entry = await appendDirectiveEntry(pm, toDirectiveInput(args)) return { recorded: { kind: entry.kind, quote: entry.quote } } } catch (e) { return { error: `could not record — is the pm-state repo created? (${e instanceof Error ? e.message : e})` } } } return { error: `unknown tool: ${name}` } } try { const turn = await runAgentTurn({ complete: (m, t, ot) => chat.complete(m, t, ot), messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages], tools: REGINALD_TOOLS, execute, onToken, }) return { ok: true as const, content: turn.content, steps: turn.steps, proposals } } catch (e) { return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) } } }) // capture_work — braindump → proposed issue set. The big model does the // decomposition (with one loaded local model, that's the loaded one). Returns // a proposal; nothing is filed until the Capture tray approves it. ipcMain.handle('model:capture', async (_event, braindump: string) => { if (!router) return { ok: false as const, reason: 'unconfigured' as const } const model = await resolveLoadedModel(router.big.baseUrl, router.big.model) const chat = createChatClient({ ...router.big, model }, fetch) try { const proposal = await captureWork((m, t) => chat.complete(m, t), braindump) return { ok: true as const, ...proposal } } catch (e) { return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) } } }) }