diff --git a/apps/desktop/e2e/live-reginald.spec.ts b/apps/desktop/e2e/live-reginald.spec.ts new file mode 100644 index 0000000..c15469c --- /dev/null +++ b/apps/desktop/e2e/live-reginald.spec.ts @@ -0,0 +1,33 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { _electron as electron, expect, test } from '@playwright/test' + +const here = dirname(fileURLToPath(import.meta.url)) +const MAIN = join(here, '..', 'out', 'main', 'index.js') + +// Opt-in (GITEA_LIVE=1 + COMMITEA_MODEL_LIVE=1 + a local model on :1234). Launches +// WITHOUT COMMITEA_E2E so Reginald runs the real agent loop against the real repo. +test.describe('live Reginald', () => { + test('answers a question by consulting the real project', async () => { + test.skip(!process.env.GITEA_LIVE || !process.env.COMMITEA_MODEL_LIVE, 'live model test — opt-in') + test.setTimeout(120_000) + const app = await electron.launch({ args: [MAIN], env: { ...process.env } }) + const win = await app.firstWindow() + await win.waitForLoadState('domcontentloaded') + + // model configured → the live greeting + header, not the scripted demo + await expect(win.getByText('gemma-4 · local')).toBeVisible({ timeout: 20000 }) + await expect(win.getByText(/I check the real board before I answer/)).toBeVisible() + + const composer = win.getByPlaceholder(/Tell me what to do/) + await composer.fill('What should I work on right now?') + await composer.press('Enter') + + // the agent loop ran end-to-end: it consulted the project, then answered + await expect(win.getByText(/consulted the project/)).toBeVisible({ timeout: 90_000 }) + await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-reginald.png'), fullPage: true, animations: 'disabled' }) + + await app.close() + }) +}) diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index a443d30..97ffba2 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -9,7 +9,13 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { createGiteaClient, type GiteaConfig, type LifecycleEvent } from '@commitea/core' +import { + createGiteaClient, + type GiteaClient, + type GiteaConfig, + type LifecycleEvent, + type ProjectSnapshot, +} from '@commitea/core' import { ipcMain } from 'electron' /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ @@ -43,28 +49,45 @@ function resolveConfig(): GiteaConfig | null { } } -export function registerGiteaIpc(): void { - const config = resolveConfig() - const client = config ? createGiteaClient(config, fetch) : null - const repo = config ? `${config.owner}/${config.repo}` : null +// Memoized client so both the gitea and model bridges share one instance. +let sharedClient: GiteaClient | null | undefined +export function getGiteaClient(): GiteaClient | null { + if (sharedClient === undefined) { + const config = resolveConfig() + sharedClient = config ? createGiteaClient(config, fetch) : null + } + return sharedClient +} - ipcMain.handle('gitea:status', () => ({ configured: !!config, repo })) +/** Full reconcile: issues + milestones + native deps + lifecycle timelines. */ +export async function reconcileSnapshot( + client: GiteaClient, +): Promise> }> { + const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()]) + // dependency edges among the open scope (the scheduler only plans what's left) + const open = issues.filter((i) => i.state === 'open') + const perIssue = await Promise.all( + open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })), + ) + const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d }))) + // lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals) + const timelineEntries = await Promise.all( + issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const), + ) + const timelines: Record = Object.fromEntries(timelineEntries) + return { issues, milestones, deps, timelines } +} + +export function registerGiteaIpc(): void { + const client = getGiteaClient() + const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null + + ipcMain.handle('gitea:status', () => ({ configured: !!client, repo })) ipcMain.handle('gitea:reconcile', async () => { if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } - const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()]) - // dependency edges among the open scope (the scheduler only plans what's left) - const open = issues.filter((i) => i.state === 'open') - const perIssue = await Promise.all( - open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })), - ) - const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d }))) - // lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals) - const timelineEntries = await Promise.all( - issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const), - ) - const timelines: Record = Object.fromEntries(timelineEntries) - return { configured: true, issues, milestones, deps, timelines } + const snap = await reconcileSnapshot(client) + return { configured: true, ...snap } }) ipcMain.handle('gitea:getIssue', async (_event, index: number) => { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 76c0500..bf09116 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { BrowserWindow, app, shell } from 'electron' import { registerGiteaIpc } from './gitea.js' +import { registerModelIpc } from './model.js' function createWindow(): void { const win = new BrowserWindow({ @@ -35,6 +36,7 @@ function createWindow(): void { void app.whenReady().then(() => { registerGiteaIpc() + registerModelIpc() createWindow() app.on('activate', () => { diff --git a/apps/desktop/src/main/model.ts b/apps/desktop/src/main/model.ts new file mode 100644 index 0000000..b79afd9 --- /dev/null +++ b/apps/desktop/src/main/model.ts @@ -0,0 +1,69 @@ +/** + * 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, + type ChatMessage, + createChatClient, + type ModelRouter, + type ProjectView, + type QueryFilters, + REGINALD_SYSTEM, + REGINALD_TOOLS, + runAgentTurn, +} from '@commitea/core' +import { ipcMain } from 'electron' + +import { getGiteaClient, reconcileSnapshot } 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 ?? 'google/gemma-4-e4b' }, + big: { baseUrl, model: process.env.COMMITEA_MODEL_BIG ?? 'qwen/qwen3.6-35b-a3b' }, + } +} + +export function registerModelIpc(): void { + const router = resolveModelRouter() + + ipcMain.handle('model:status', () => ({ + configured: !!router, + model: router?.small.model ?? null, + })) + + ipcMain.handle('model:chat', async (_event, messages: ChatMessage[]) => { + if (!router) return { ok: false as const, reason: 'unconfigured' as const } + const client = getGiteaClient() + const chat = createChatClient(router.small, fetch) + + const execute = async (name: string, args: unknown) => { + if (name !== 'query_project') return { error: `unknown tool: ${name}` } + if (!client) return { error: 'gitea is not configured' } + const snap = await reconcileSnapshot(client) + const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters } + return buildProjectView(a.view, a.filters, snap, new Date()) + } + + try { + const turn = await runAgentTurn({ + complete: (m, t) => chat.complete(m, t), + messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages], + tools: REGINALD_TOOLS, + execute, + }) + return { ok: true as const, content: turn.content, steps: turn.steps } + } catch (e) { + return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) } + } + }) +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 0e846d7..21c7342 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -10,6 +10,12 @@ const api = { /** One issue by index, normalized (or null if unconfigured). */ getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index), }, + model: { + /** Whether a model endpoint is configured (else the UI keeps the scripted Reginald). */ + status: () => ipcRenderer.invoke('model:status'), + /** One agent turn: messages in, Reginald's prose + the tools it consulted out. */ + chat: (messages: unknown) => ipcRenderer.invoke('model:chat', messages), + }, } export type CommiteaApi = typeof api diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx index fde4e09..23ed380 100644 --- a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx +++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx @@ -1,12 +1,13 @@ import React, { useEffect, useRef, useState } from 'react' -import { CANNED_REPLY, CHAT, type ChatMessage } from '../../data/fixtures.js' +import { useChat } from '../../lib/use-chat.js' import { Icon, IconButton } from '../ui/index.js' /** - * Reginald's panel — chat is the write-path (decisions.md D1). This is the P3-2 - * fixture shell: it echoes a canned reply so the layout + interactions are real, - * but no model is wired. P4 replaces `send` with the model router + tools. + * Reginald's panel — chat is the write-path (decisions.md D1). Wired to the + * model bridge via `useChat`: when a model is configured, sending drives a real + * agent turn (query_project + prose); otherwise it echoes the scripted fixture + * reply so the layout stays real. Writes still go through propose-approve. */ export interface ChatPanelProps { onOpenDirectives?: () => void @@ -14,9 +15,8 @@ export interface ChatPanelProps { } export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { - const [msgs, setMsgs] = useState(CHAT) + const { msgs, thinking, live, steps, send: sendChat } = useChat() const [text, setText] = useState('') - const [thinking, setThinking] = useState(false) const scrollRef = useRef(null) useEffect(() => { @@ -27,13 +27,8 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { const send = () => { const t = text.trim() if (!t) return - setMsgs((m) => [...m, { from: 'user', text: t }]) setText('') - setThinking(true) - setTimeout(() => { - setThinking(false) - setMsgs((m) => [...m, { from: 'agent', text: CANNED_REPLY }]) - }, 900) + sendChat(t) } return ( @@ -61,7 +56,7 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { Reginald - {offline ? 'offline · queueing' : 'gemma-4b · local'} + {offline ? 'offline · queueing' : live ? 'gemma-4 · local' : 'demo · scripted'} @@ -98,6 +93,11 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { ) : null} {thinking ?
considering…
: null} + {!thinking && steps.length ? ( +
+ consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project')))).join(', ')} +
+ ) : null}
diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 019d932..58b002c 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -1,4 +1,11 @@ -import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core' +import type { + AgentStep, + ChatMessage, + DependencyEdge, + GiteaIssue, + GiteaMilestone, + LifecycleEvent, +} from '@commitea/core' /** The gitea bridge exposed by the preload over IPC (main-process backed). */ export interface GiteaBridge { @@ -14,11 +21,23 @@ export interface GiteaBridge { getIssue(index: number): Promise } +/** One agent turn's result. */ +export type ChatResult = + | { ok: false; reason: 'unconfigured' | 'error'; message?: string } + | { ok: true; content: string; steps: AgentStep[] } + +/** The model bridge (Reginald) exposed by the preload over IPC. */ +export interface ModelBridge { + status(): Promise<{ configured: boolean; model: string | null }> + chat(messages: ChatMessage[]): Promise +} + declare global { interface Window { commitea: { platform: string gitea: GiteaBridge + model: ModelBridge } } } diff --git a/apps/desktop/src/renderer/src/lib/use-chat.ts b/apps/desktop/src/renderer/src/lib/use-chat.ts new file mode 100644 index 0000000..ec3b25b --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/use-chat.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { ChatMessage as WireMessage } from '@commitea/core' + +import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js' + +const LIVE_GREETING: ChatMessage = { + from: 'agent', + text: 'Morning. Ask me anything about the project — I check the real board before I answer.', +} + +export interface ChatState { + msgs: ChatMessage[] + thinking: boolean + /** true once a model endpoint is confirmed; otherwise the panel echoes the demo reply. */ + live: boolean + /** Tools Reginald consulted on the last turn (for a subtle activity line). */ + steps: string[] + send: (text: string) => void +} + +/** + * Reginald's conversation. When a model is configured, `send` drives one agent + * turn through the main-process bridge (which runs the tool loop). Otherwise it + * echoes the scripted fixture reply, so the layout stays real with no model and + * fixture e2e is unaffected. The fixture greeting is display-only — only real + * turns (`convo`) are sent to the model as history. + */ +export function useChat(): ChatState { + const [seed, setSeed] = useState(CHAT) + const [convo, setConvo] = useState([]) + const [thinking, setThinking] = useState(false) + const [live, setLive] = useState(false) + const [steps, setSteps] = useState([]) + const convoRef = useRef(convo) + convoRef.current = convo + + useEffect(() => { + let alive = true + window.commitea.model + .status() + .then((s) => { + if (alive && s.configured) { + setLive(true) + setSeed([LIVE_GREETING]) + } + }) + .catch(() => {}) + return () => { + alive = false + } + }, []) + + const send = useCallback( + (raw: string) => { + const text = raw.trim() + if (!text) return + const nextConvo: ChatMessage[] = [...convoRef.current, { from: 'user', text }] + setConvo(nextConvo) + setThinking(true) + setSteps([]) + + if (!live) { + window.setTimeout(() => { + setThinking(false) + setConvo((c) => [...c, { from: 'agent', text: CANNED_REPLY }]) + }, 900) + return + } + + const wire: WireMessage[] = nextConvo.map((m) => ({ + role: m.from === 'user' ? 'user' : 'assistant', + content: m.text, + })) + window.commitea.model + .chat(wire) + .then((res) => { + setThinking(false) + if (res.ok) { + setSteps(res.steps.map((s) => s.tool)) + setConvo((c) => [...c, { from: 'agent', text: res.content || '…' }]) + } else { + setConvo((c) => [ + ...c, + { + from: 'agent', + text: res.reason === 'error' ? `I hit a snag: ${res.message ?? 'unknown error'}` : 'No model is configured.', + }, + ]) + } + }) + .catch(() => { + setThinking(false) + setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }]) + }) + }, + [live], + ) + + return { msgs: [...seed, ...convo], thinking, live, steps, send } +} diff --git a/packages/core/src/agent/agent-live.test.ts b/packages/core/src/agent/agent-live.test.ts new file mode 100644 index 0000000..bf61bae --- /dev/null +++ b/packages/core/src/agent/agent-live.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { extractLabelFacts } from '../labels/label-schema.js' +import type { FetchLike, GiteaIssue } from '../gitea/types.js' +import { runAgentTurn } from './agent-loop.js' +import { REGINALD_SYSTEM, REGINALD_TOOLS } from './agent-tools.js' +import { createChatClient } from './chat-client.js' +import { buildProjectView, type ProjectSnapshot } from './query-project.js' + +/** + * Opt-in (COMMITEA_MODEL_LIVE=1). Drives the real chat client + agent loop + * against a local OpenAI-compatible server (LM Studio on :1234 by default), + * proving the model calls query_project and narrates the real result. + */ +const LIVE = !!process.env.COMMITEA_MODEL_LIVE +const BASE = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1' +const MODEL = process.env.COMMITEA_MODEL_SMALL ?? 'google/gemma-4-e4b' + +function issue(over: Partial): GiteaIssue { + const labels = over.labels ?? [] + return { + number: 1, title: '#1', body: '', state: 'open', labels, facts: extractLabelFacts(labels), + milestone: null, assignee: null, assignees: [], createdAt: '2026-01-05T09:00:00Z', + updatedAt: '2026-01-05T09:00:00Z', closedAt: null, url: '', ...over, + } +} + +const SNAP: ProjectSnapshot = { + issues: [ + issue({ number: 2, title: 'ChangeSource interface + polling source', labels: ['est/3d', 'p/1'] }), + issue({ number: 3, title: 'SQLite cache bootstrap', labels: ['est/2d', 'p/1'] }), + ], + timelines: {}, + deps: [{ issue: 3, dependsOn: 2 }], +} + +describe('agent loop (live model)', () => { + it.skipIf(!LIVE)( + 'calls query_project and narrates the real focus', + async () => { + const client = createChatClient({ baseUrl: BASE, model: MODEL }, globalThis.fetch as unknown as FetchLike) + const turn = await runAgentTurn({ + complete: (m, t) => client.complete(m, t), + messages: [ + { role: 'system', content: REGINALD_SYSTEM }, + { role: 'user', content: 'What should I work on right now?' }, + ], + tools: REGINALD_TOOLS, + execute: async (name, args) => + name === 'query_project' + ? buildProjectView((args as { view: any }).view, (args as any).filters, SNAP, new Date()) + : { error: `unknown tool ${name}` }, + }) + + // it consulted the project, then answered in prose about the real top item (#2) + expect(turn.steps.some((s) => s.tool === 'query_project')).toBe(true) + expect(turn.content.trim().length).toBeGreaterThan(0) + expect(turn.content).toMatch(/#?2\b|ChangeSource/i) + }, + 60_000, + ) +}) diff --git a/packages/core/src/agent/agent-loop.ts b/packages/core/src/agent/agent-loop.ts new file mode 100644 index 0000000..dc174d8 --- /dev/null +++ b/packages/core/src/agent/agent-loop.ts @@ -0,0 +1,69 @@ +/** + * The agent turn loop. Given a model `complete` fn, the conversation, the tool + * declarations, and an `execute` that actually runs a tool, it drives the + * call→tool→result→call cycle until the model answers in prose (or a step + * budget is hit). Pure orchestration with injected I/O — the model and the tool + * executor are both stubbable, so the loop is fully unit-testable offline. + */ + +import type { ChatMessage, CompletionResult, ToolDecl } from './chat-client.js' + +/** A tool the loop ran, with the raw args and its stringified result — for the UI's activity trail. */ +export interface AgentStep { + tool: string + arguments: string + result: string +} + +export type ToolExecutor = (name: string, args: unknown) => Promise + +export interface AgentTurn { + content: string + steps: AgentStep[] + /** The full conversation including this turn's assistant/tool messages. */ + messages: ChatMessage[] +} + +const DEFAULT_MAX_STEPS = 4 + +function stringify(result: unknown): string { + return typeof result === 'string' ? result : JSON.stringify(result) +} + +export async function runAgentTurn(opts: { + complete: (messages: ChatMessage[], tools?: ToolDecl[]) => Promise + messages: ChatMessage[] + tools: ToolDecl[] + execute: ToolExecutor + maxSteps?: number +}): Promise { + const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS + const convo: ChatMessage[] = [...opts.messages] + const steps: AgentStep[] = [] + + for (let step = 0; step < maxSteps; step++) { + const { content, toolCalls } = await opts.complete(convo, opts.tools) + if (toolCalls.length === 0) { + convo.push({ role: 'assistant', content }) + return { content, steps, messages: convo } + } + convo.push({ role: 'assistant', content, toolCalls }) + for (const tc of toolCalls) { + let result: unknown + try { + const args = tc.arguments ? JSON.parse(tc.arguments) : {} + result = await opts.execute(tc.name, args) + } catch (e) { + result = { error: e instanceof Error ? e.message : String(e) } + } + const resultStr = stringify(result) + steps.push({ tool: tc.name, arguments: tc.arguments, result: resultStr }) + convo.push({ role: 'tool', toolCallId: tc.id, name: tc.name, content: resultStr }) + } + } + + // Out of tool budget — force a final prose answer with tools withheld. + const final = await opts.complete(convo, []) + convo.push({ role: 'assistant', content: final.content }) + return { content: final.content, steps, messages: convo } +} diff --git a/packages/core/src/agent/agent-tools.ts b/packages/core/src/agent/agent-tools.ts new file mode 100644 index 0000000..909f618 --- /dev/null +++ b/packages/core/src/agent/agent-tools.ts @@ -0,0 +1,46 @@ +/** + * Reginald's tool surface. v0 ships the one read tool (`query_project`); the + * three write tools (capture_work, apply_changes, record_directive) layer on + * later against the same loop. Few, fat tools so a small local model survives + * with one thing to reach for (docs/agent-tools.md). + */ + +import type { ToolDecl } from './chat-client.js' + +export const QUERY_PROJECT_TOOL: ToolDecl = { + name: 'query_project', + description: + 'Read the current project state. A `view` selects the shape; deterministic code (scheduler, ' + + 'lifecycle inference, calibration) backs every number — you report it, you never compute it.', + parameters: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['focus', 'board', 'calibration', 'issue', 'search'], + description: + 'focus = Now/Next/Later; board = issues by lifecycle column; calibration = estimate-vs-actual; ' + + 'issue = one issue (needs filters.issueId); search = issues matching filters.query.', + }, + filters: { + type: 'object', + properties: { + issueId: { type: 'number' }, + query: { type: 'string' }, + limit: { type: 'number' }, + }, + }, + }, + required: ['view'], + }, +} + +export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL] + +export const REGINALD_SYSTEM = [ + 'You are Reginald, the calm, dry project manager inside CommiTea — a tool that runs projects on Gitea.', + 'Call query_project to ground every answer in the real project; never invent issues, numbers, or dates.', + 'The scheduler and forecasts are deterministic code — report their output, do not recompute it.', + 'Forecasts are ranges, never single dates. Refer to issues as #.', + 'Be brief and plain. A sentence or two is usually enough. No preamble, no bullet-point dumps.', +].join(' ') diff --git a/packages/core/src/agent/agent.test.ts b/packages/core/src/agent/agent.test.ts new file mode 100644 index 0000000..b94ba20 --- /dev/null +++ b/packages/core/src/agent/agent.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' + +import { extractLabelFacts } from '../labels/label-schema.js' +import type { GiteaIssue } from '../gitea/types.js' +import type { FetchLike } from '../gitea/types.js' +import { type ChatMessage, type CompletionResult, createChatClient, type ToolDecl } from './chat-client.js' +import { runAgentTurn } from './agent-loop.js' +import { pickModel } from './model-router.js' +import { buildProjectView, type ProjectSnapshot } from './query-project.js' + +// ---- chat client ---- + +describe('createChatClient', () => { + function stub(body: unknown) { + const calls: { url: string; body: unknown }[] = [] + const fetch: FetchLike = (url, init) => { + calls.push({ url, body: init?.body ? JSON.parse(init.body) : undefined }) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(''), + }) + } + return { fetch, calls } + } + + it('POSTs to /chat/completions and parses content', async () => { + const { fetch, calls } = stub({ choices: [{ message: { content: 'the focus is #2' } }] }) + const client = createChatClient({ baseUrl: 'http://localhost:1234/v1', model: 'gemma' }, fetch) + const res = await client.complete([{ role: 'user', content: 'what now?' }]) + + expect(res.content).toBe('the focus is #2') + expect(res.toolCalls).toEqual([]) + expect(calls[0].url).toBe('http://localhost:1234/v1/chat/completions') + expect((calls[0].body as { model: string }).model).toBe('gemma') + }) + + it('parses tool calls from the wire shape', async () => { + const { fetch } = stub({ + choices: [ + { + message: { + content: null, + tool_calls: [{ id: 'c1', function: { name: 'query_project', arguments: '{"view":"focus"}' } }], + }, + }, + ], + }) + const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch) + const res = await client.complete([{ role: 'user', content: 'x' }], [{ name: 'query_project', description: '', parameters: {} }]) + expect(res.toolCalls).toEqual([{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }]) + }) + + it('serializes assistant tool_calls + tool results in the request', async () => { + const { fetch, calls } = stub({ choices: [{ message: { content: 'ok' } }] }) + const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch) + const convo: ChatMessage[] = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] }, + { role: 'tool', toolCallId: 'c1', name: 'query_project', content: '{"now":null}' }, + ] + await client.complete(convo) + const sent = (calls[0].body as { messages: Record[] }).messages + expect(sent[1].tool_calls).toBeDefined() + expect(sent[2]).toEqual({ role: 'tool', tool_call_id: 'c1', content: '{"now":null}' }) + }) +}) + +// ---- model router ---- + +describe('pickModel', () => { + const router = { small: { baseUrl: 'u', model: 'small' }, big: { baseUrl: 'u', model: 'big' } } + it('routes plan → big, everything else → small', () => { + expect(pickModel(router, 'plan').model).toBe('big') + expect(pickModel(router, 'ritual').model).toBe('small') + }) +}) + +// ---- agent loop ---- + +describe('runAgentTurn', () => { + const tools: ToolDecl[] = [{ name: 'query_project', description: '', parameters: {} }] + + it('runs a tool then returns the model prose, recording the step', async () => { + // first completion asks for a tool; second answers in prose + const scripted: CompletionResult[] = [ + { content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }] }, + { content: 'Right now: #2.', toolCalls: [] }, + ] + let i = 0 + const executed: { name: string; args: unknown }[] = [] + const turn = await runAgentTurn({ + complete: async () => scripted[i++], + messages: [{ role: 'user', content: 'what now?' }], + tools, + execute: async (name, args) => { + executed.push({ name, args }) + return { now: { issue: 2 } } + }, + }) + + expect(turn.content).toBe('Right now: #2.') + expect(turn.steps).toEqual([ + { tool: 'query_project', arguments: '{"view":"focus"}', result: '{"now":{"issue":2}}' }, + ]) + expect(executed).toEqual([{ name: 'query_project', args: { view: 'focus' } }]) + // conversation carries user → assistant(tool) → tool → assistant(prose) + expect(turn.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant']) + }) + + it('answers directly when the model needs no tool', async () => { + const turn = await runAgentTurn({ + complete: async () => ({ content: 'Hello.', toolCalls: [] }), + messages: [{ role: 'user', content: 'hi' }], + tools, + execute: async () => ({}), + }) + expect(turn.content).toBe('Hello.') + expect(turn.steps).toEqual([]) + }) + + it('captures a tool executor error as the tool result instead of throwing', async () => { + const scripted: CompletionResult[] = [ + { content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] }, + { content: 'Something went wrong reading that.', toolCalls: [] }, + ] + let i = 0 + const turn = await runAgentTurn({ + complete: async () => scripted[i++], + messages: [{ role: 'user', content: 'x' }], + tools, + execute: async () => { + throw new Error('boom') + }, + }) + expect(turn.steps[0].result).toContain('boom') + expect(turn.content).toBe('Something went wrong reading that.') + }) + + it('forces a final prose answer when the step budget is exhausted', async () => { + // always asks for a tool; the loop must still terminate with prose + let toolRounds = 0 + const turn = await runAgentTurn({ + complete: async (_m, tls) => { + if (tls && tls.length) { + toolRounds++ + return { content: '', toolCalls: [{ id: `c${toolRounds}`, name: 'query_project', arguments: '{}' }] } + } + return { content: 'final answer', toolCalls: [] } // called with tools withheld + }, + messages: [{ role: 'user', content: 'x' }], + tools, + execute: async () => ({}), + maxSteps: 2, + }) + expect(toolRounds).toBe(2) + expect(turn.content).toBe('final answer') + }) +}) + +// ---- query_project view builder ---- + +describe('buildProjectView', () => { + const asOf = new Date('2026-02-01T00:00:00Z') + + function issue(over: Partial): GiteaIssue { + const labels = over.labels ?? [] + return { + number: 1, + title: '#1', + body: '', + state: 'open', + labels, + facts: extractLabelFacts(labels), + milestone: null, + assignee: null, + assignees: [], + createdAt: '2026-01-05T09:00:00Z', + updatedAt: '2026-01-05T09:00:00Z', + closedAt: null, + url: '', + ...over, + } + } + + const snap: ProjectSnapshot = { + issues: [ + issue({ number: 2, title: 'ChangeSource', labels: ['est/3d', 'p/1'] }), + issue({ number: 3, title: 'SQLite cache', labels: ['est/2d', 'p/1'] }), + issue({ number: 9, title: 'Done thing', state: 'closed', labels: ['est/2d'], closedAt: '2026-01-12T09:00:00Z' }), + ], + timelines: { 9: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }] }, + deps: [{ issue: 3, dependsOn: 2 }], + } + + it('focus returns Now/Next/Later grounded in the scheduler', () => { + const v = buildProjectView('focus', undefined, snap, asOf) as { + now: { issue: number } | null + openCount: number + } + expect(v.now?.issue).toBe(2) // #3 depends on #2, so #2 comes first + expect(v.openCount).toBe(2) + }) + + it('board groups issues by lifecycle column', () => { + const v = buildProjectView('board', undefined, snap, asOf) as { + columns: { column: string; count: number }[] + } + const done = v.columns.find((c) => c.column === 'done') + expect(done?.count).toBe(1) // #9 + const triage = v.columns.find((c) => c.column === 'triage') + expect(triage?.count).toBe(2) // #2, #3 labelled, no commits yet + }) + + it('calibration reports n + cold-start honestly', () => { + const v = buildProjectView('calibration', undefined, snap, asOf) as { n: number; coldStart: boolean } + expect(v.n).toBe(1) // one closed, estimated, with an actual + expect(v.coldStart).toBe(true) + }) + + it('issue returns intent + derived for a specific id', () => { + const v = buildProjectView('issue', { issueId: 3 }, snap, asOf) as { + issue: number + blockedBy: number[] + } + expect(v.issue).toBe(3) + expect(v.blockedBy).toEqual([2]) + }) + + it('search matches title/labels', () => { + const v = buildProjectView('search', { query: 'sqlite' }, snap, asOf) as { results: { issue: number }[] } + expect(v.results.map((r) => r.issue)).toEqual([3]) + }) + + it('unbuilt views return a notImplemented marker, not fabricated data', () => { + expect(buildProjectView('standup', undefined, snap, asOf)).toEqual({ notImplemented: 'standup' }) + }) +}) diff --git a/packages/core/src/agent/chat-client.ts b/packages/core/src/agent/chat-client.ts new file mode 100644 index 0000000..79280e2 --- /dev/null +++ b/packages/core/src/agent/chat-client.ts @@ -0,0 +1,123 @@ +/** + * Reginald's model client — a thin OpenAI-wire chat-completions client over an + * injected fetch (the same seam the gitea client uses). `@commitea/core` stays + * pure: no network, no globals. The desktop main process passes the real + * `fetch`; tests pass a stub. Points at any OpenAI-compatible endpoint (LM + * Studio, Ollama, the OpenAI API) — the model router picks which. + */ + +import type { FetchLike } from '../gitea/types.js' + +/** Where a model lives + which model to ask for. */ +export interface ModelConfig { + /** OpenAI-compatible base, including the version segment, e.g. `http://localhost:1234/v1`. */ + baseUrl: string + model: string + /** Bearer key; omitted for local servers that don't check it. */ + apiKey?: string +} + +/** A model's request to run a tool. `arguments` is a raw JSON string (OpenAI shape). */ +export interface ToolCall { + id: string + name: string + arguments: string +} + +/** One turn in the conversation, in our normalized shape. */ +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool' + content: string + /** assistant turns that requested tools. */ + toolCalls?: ToolCall[] + /** tool turns: which call they answer. */ + toolCallId?: string + /** tool turns: the function name. */ + name?: string +} + +/** A tool the model may call — name, description, and a JSON-Schema parameter spec. */ +export interface ToolDecl { + name: string + description: string + parameters: object +} + +export interface CompletionResult { + content: string + toolCalls: ToolCall[] +} + +export interface ChatClient { + complete(messages: ChatMessage[], tools?: ToolDecl[]): Promise +} + +/** Map our message shape to the OpenAI wire shape. */ +function toWireMessage(m: ChatMessage): Record { + if (m.role === 'assistant' && m.toolCalls?.length) { + return { + role: 'assistant', + content: m.content || null, + tool_calls: m.toolCalls.map((tc) => ({ + id: tc.id, + type: 'function', + function: { name: tc.name, arguments: tc.arguments }, + })), + } + } + if (m.role === 'tool') { + return { role: 'tool', tool_call_id: m.toolCallId, content: m.content } + } + return { role: m.role, content: m.content } +} + +function toWireTool(t: ToolDecl): Record { + return { type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } } +} + +/** Shape of the one choice we read back. */ +interface RawChoiceMessage { + content: string | null + tool_calls?: { id: string; function: { name: string; arguments: string } }[] +} + +export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): ChatClient { + const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions` + + return { + async complete(messages, tools) { + const body: Record = { + model: config.model, + messages: messages.map(toWireMessage), + temperature: 0, + } + if (tools?.length) { + body.tools = tools.map(toWireTool) + body.tool_choice = 'auto' + } + const res = await fetchImpl(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}), + }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`model completion failed (${res.status}): ${text.slice(0, 200)}`) + } + const json = (await res.json()) as { choices?: { message: RawChoiceMessage }[] } + const msg = json.choices?.[0]?.message + return { + content: msg?.content ?? '', + toolCalls: (msg?.tool_calls ?? []).map((tc) => ({ + id: tc.id, + name: tc.function.name, + arguments: tc.function.arguments, + })), + } + }, + } +} diff --git a/packages/core/src/agent/model-router.ts b/packages/core/src/agent/model-router.ts new file mode 100644 index 0000000..772a9a7 --- /dev/null +++ b/packages/core/src/agent/model-router.ts @@ -0,0 +1,22 @@ +/** + * Model router (per PLAN.md / decisions.md): prose + the read tool run on a + * small local model; decomposition/negotiation earns the big one. Reginald v0 + * only exercises the small model (read + prose); the `big` slot is declared so + * `capture_work` / `record_directive` can route to it without a rewrite. + */ + +import type { ModelConfig } from './chat-client.js' + +export type TaskKind = 'ritual' | 'plan' + +export interface ModelRouter { + /** Small local model — standups, focus prose, the read tool. */ + small: ModelConfig + /** Big model — capture decomposition, directive negotiation. */ + big: ModelConfig +} + +/** `plan` → the big model; everything else → the small one. */ +export function pickModel(router: ModelRouter, kind: TaskKind): ModelConfig { + return kind === 'plan' ? router.big : router.small +} diff --git a/packages/core/src/agent/query-project.ts b/packages/core/src/agent/query-project.ts new file mode 100644 index 0000000..aa488c7 --- /dev/null +++ b/packages/core/src/agent/query-project.ts @@ -0,0 +1,146 @@ +/** + * `query_project` — Reginald's single read tool. It selects a compact, + * model-friendly view over the reconciled backlog. Every number comes from + * deterministic code (scheduler, lifecycle inference, calibration); the model + * only requests a shape and narrates it — it never computes (decisions.md). + * + * v0 serves focus / board / calibration / issue. The remaining views + * (milestone / runway / standup / search) return a `notImplemented` marker so + * the model degrades honestly instead of inventing data. + */ + +import { fitCalibration, calibrationSamples } from '../calibration/calibration-v0.js' +import type { GiteaIssue } from '../gitea/types.js' +import { inferLifecycle, type LifecycleColumn, type LifecycleEvent } from '../lifecycle/lifecycle-v0.js' +import { type DependencyEdge, schedule, selectFocus } from '../scheduler/scheduler-v0.js' + +export type ProjectView = + | 'focus' + | 'board' + | 'calibration' + | 'issue' + | 'milestone' + | 'runway' + | 'standup' + | 'search' + +export interface QueryFilters { + issueId?: number + query?: string + limit?: number +} + +/** The reconciled inputs a view is built from. */ +export interface ProjectSnapshot { + issues: GiteaIssue[] + timelines: Record + deps: DependencyEdge[] +} + +const COLUMN_ORDER: LifecycleColumn[] = ['diagnosis', 'triage', 'steeping', 'review', 'done'] + +function toSchedulable(issues: GiteaIssue[]) { + return issues + .filter((i) => i.state === 'open') + .map((i) => ({ + number: i.number, + title: i.title, + labels: i.labels, + estimateDays: i.facts.estimateDays, + priority: i.facts.priority, + })) +} + +function focusView(snap: ProjectSnapshot) { + const plan = schedule(toSchedulable(snap.issues), snap.deps) + const f = selectFocus(plan) + const slot = (item: (typeof plan.items)[number] | null) => + item ? { issue: item.number, title: item.title, rationale: item.rationale } : null + return { now: slot(f.now), next: slot(f.next), later: slot(f.later), openCount: plan.items.length } +} + +function boardView(snap: ProjectSnapshot, asOf: Date) { + const columns: Record = {} + for (const key of COLUMN_ORDER) columns[key] = [] + for (const issue of snap.issues) { + const inf = inferLifecycle(issue, snap.timelines[issue.number] ?? [], asOf) + columns[inf.column].push({ issue: issue.number, title: issue.title, labels: issue.labels }) + } + return { + columns: COLUMN_ORDER.map((key) => ({ column: key, count: columns[key].length, issues: columns[key] })), + } +} + +function calibrationView(snap: ProjectSnapshot, asOf: Date) { + const model = fitCalibration(calibrationSamples(snap.issues, snap.timelines, asOf)) + return { + n: model.n, + coldStart: model.coldStart, + globalMultiplier: Number(Math.exp(model.global.mu).toFixed(2)), + byBucket: Object.entries(model.byBucket).map(([bucket, fit]) => ({ + estimate: `est/${bucket}d`, + n: fit.n, + multiplier: Number(Math.exp(fit.mu).toFixed(2)), + })), + } +} + +function issueView(snap: ProjectSnapshot, filters: QueryFilters, asOf: Date) { + const issue = snap.issues.find((i) => i.number === filters.issueId) + if (!issue) return { notFound: filters.issueId ?? null } + const inf = inferLifecycle(issue, snap.timelines[issue.number] ?? [], asOf) + const blockedBy = snap.deps.filter((d) => d.issue === issue.number).map((d) => d.dependsOn) + const blocks = snap.deps.filter((d) => d.dependsOn === issue.number).map((d) => d.issue) + return { + issue: issue.number, + title: issue.title, + state: issue.state, + column: inf.column, + labels: issue.labels, + assignee: issue.assignee, + milestone: issue.milestone?.title ?? null, + estimateDays: issue.facts.estimateDays, + priority: issue.facts.priority, + steepingDays: inf.steepingDays, + blockedBy, + blocks, + } +} + +function searchView(snap: ProjectSnapshot, filters: QueryFilters) { + const q = (filters.query ?? '').toLowerCase().trim() + const limit = Math.min(filters.limit ?? 20, 100) + const hits = q + ? snap.issues.filter( + (i) => i.title.toLowerCase().includes(q) || i.labels.some((l) => l.toLowerCase().includes(q)), + ) + : [] + return { + query: filters.query ?? '', + results: hits.slice(0, limit).map((i) => ({ issue: i.number, title: i.title, state: i.state, labels: i.labels })), + } +} + +/** Build the compact payload for one view. Unknown/unbuilt views return a marker. */ +export function buildProjectView( + view: ProjectView, + filters: QueryFilters | undefined, + snap: ProjectSnapshot, + asOf: Date, +): unknown { + const f = filters ?? {} + switch (view) { + case 'focus': + return focusView(snap) + case 'board': + return boardView(snap, asOf) + case 'calibration': + return calibrationView(snap, asOf) + case 'issue': + return issueView(snap, f, asOf) + case 'search': + return searchView(snap, f) + default: + return { notImplemented: view } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c5b9716..cd3fbff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,3 +74,13 @@ export type { CalibrationSample, PersonBias, } from './calibration/calibration-v0.js' + +export { createChatClient } from './agent/chat-client.js' +export type { ChatClient, ChatMessage, CompletionResult, ModelConfig, ToolCall, ToolDecl } from './agent/chat-client.js' +export { pickModel } from './agent/model-router.js' +export type { ModelRouter, TaskKind } from './agent/model-router.js' +export { runAgentTurn } from './agent/agent-loop.js' +export type { AgentStep, AgentTurn, ToolExecutor } from './agent/agent-loop.js' +export { QUERY_PROJECT_TOOL, REGINALD_SYSTEM, REGINALD_TOOLS } from './agent/agent-tools.js' +export { buildProjectView } from './agent/query-project.js' +export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js'