The foundation for a shareable team build. Replaces the .env.local-only dev config
with a real, per-teammate connection flow.
main:
- config-store.ts: token encrypted at rest via Electron safeStorage (OS keychain),
config JSON in userData. Token lives only in main; renderer gets everything but.
- resolveConfig: saved config > .env.local (dev) > null; ignored under COMMITEA_E2E.
pm-state repo defaults to `${repo}-pm-state`. resetClients() re-reads on change so
saving config takes effect without a restart. gitea:status gains `demo` (e2e).
- IPC: config:get (no token), config:test (authed read validates token+repo),
config:set (encrypt+save+reset), config:clear. Model bridge reads config.modelUrl
and probes reachability — chat is "configured" only if a model actually answers;
localhost default is dev-only (app.isPackaged gate).
renderer:
- ConnectScreen: real onboarding form (URL/owner/repo/PAT/optional model) → test →
save. AppShell gates on it: demo → shell (fixtures/e2e); configured → shell (real);
else → connect. Settings Connection card is real (repo/url/model/sidecar) with
Reconfigure + Disconnect. Chat cleanly disables with a "no model" state instead of
the scripted canned reply.
Verified: main + desktop typecheck clean, 14 fixture e2e green (demo mode unchanged),
live onboarding e2e: fresh app → connect form → validated PAT → real board (24 done /
10 open). COMMITEA_NO_ENV_LOCAL + COMMITEA_USERDATA are test hooks for the onboarding path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
7.4 KiB
TypeScript
182 lines
7.4 KiB
TypeScript
/**
|
|
* 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 { app, ipcMain } from 'electron'
|
|
|
|
import { loadConfig } from './config-store.js'
|
|
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
|
|
// Config wins (team build); env is the dev override; localhost is a dev convenience only.
|
|
const baseUrl =
|
|
loadConfig()?.modelUrl ??
|
|
process.env.COMMITEA_MODEL_URL ??
|
|
(app.isPackaged ? undefined : '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 ?? '' },
|
|
}
|
|
}
|
|
|
|
/** Probe the endpoint for a usable model; null when unreachable (chat then stays off). */
|
|
async function probeModel(baseUrl: string): Promise<string | null> {
|
|
try {
|
|
const model = await resolveLoadedModel(baseUrl, process.env.COMMITEA_MODEL_SMALL ?? '')
|
|
// resolveLoadedModel only returns a real id when the server answered; the default
|
|
// fallback means unreachable, so confirm with a lightweight models call.
|
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`)
|
|
return res.ok ? model : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<string> {
|
|
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 {
|
|
ipcMain.handle('model:status', async () => {
|
|
const r = resolveModelRouter() // resolve fresh so a saved modelUrl takes effect
|
|
if (!r) return { configured: false, model: null }
|
|
const model = await probeModel(r.small.baseUrl) // only "configured" if a model actually answers
|
|
return { configured: !!model, model }
|
|
})
|
|
|
|
ipcMain.handle('model:chat', async (event, messages: ChatMessage[]) => {
|
|
const router = resolveModelRouter()
|
|
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) => {
|
|
const router = resolveModelRouter()
|
|
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) }
|
|
}
|
|
})
|
|
}
|