feat: Integrate GitHub Copilot SDK and LangChain for provider-independent orchestration

- Added @github/copilot-sdk and related dependencies for GitHub Copilot integration.
- Implemented ChatCopilot adapter for LangChain compatibility.
- Created LLM factory to return provider-specific models (OpenAI, Anthropic, Copilot).
- Developed Orchestrator agent using LangGraph for intent routing and context assembly.
- Enhanced IPC communication for streaming AI responses to the renderer.
- Updated progress documentation with implementation details and learnings.
This commit is contained in:
Roberto Musso
2026-02-23 17:58:00 +01:00
parent c1aa6829c9
commit 00a43e0fbc
13 changed files with 1348 additions and 14 deletions

134
src/main/ai/chat-copilot.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* ChatCopilot — LangChain-compatible ChatModel adapter for the GitHub Copilot SDK.
*
* Wraps the CopilotClient's session API so it can be used as a drop-in
* BaseChatModel within LangGraph, making the orchestrator provider-agnostic.
*/
import { SimpleChatModel, type BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
import type { BaseMessage } from '@langchain/core/messages';
import { AIMessageChunk } from '@langchain/core/messages';
import { ChatGenerationChunk } from '@langchain/core/outputs';
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import { getCopilotClient } from './copilot';
const COPILOT_TIMEOUT = 60_000;
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
constructor() {
super({});
}
_llmType(): string {
return 'copilot';
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
const client = getCopilotClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please add your GitHub token in Settings.');
}
// Extract system message and user prompt from LangChain messages
const systemContent = messages
.filter((m) => m._getType() === 'system')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const userContent = messages
.filter((m) => m._getType() === 'human')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const session = await client.createSession({
systemMessage: systemContent
? { mode: 'replace', content: systemContent }
: undefined,
availableTools: [],
streaming: true,
});
try {
const result = await session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
return result?.data.content ?? '';
} finally {
await session.destroy().catch(() => { /* ignore cleanup errors */ });
}
}
async *_streamResponseChunks(
messages: BaseMessage[],
_options: this['ParsedCallOptions'],
_runManager?: CallbackManagerForLLMRun,
): AsyncGenerator<ChatGenerationChunk> {
const client = getCopilotClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please add your GitHub token in Settings.');
}
const systemContent = messages
.filter((m) => m._getType() === 'system')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const userContent = messages
.filter((m) => m._getType() === 'human')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const session = await client.createSession({
systemMessage: systemContent
? { mode: 'replace', content: systemContent }
: undefined,
availableTools: [],
streaming: true,
});
// Buffer chunks via event listener and yield them
const chunks: string[] = [];
let done = false;
let resolveNext: (() => void) | null = null;
const unsubDelta = session.on('assistant.message_delta', (event) => {
const delta = event.data.deltaContent;
if (delta) {
chunks.push(delta);
resolveNext?.();
}
});
const unsubEnd = session.on('session.idle', () => {
done = true;
resolveNext?.();
});
// Fire the request (don't await — we'll drain via events)
const sendPromise = session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
try {
while (!done || chunks.length > 0) {
if (chunks.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const text = chunks.shift()!;
const chunk = new ChatGenerationChunk({
message: new AIMessageChunk({ content: text }),
text,
});
await _runManager?.handleLLMNewToken(text);
yield chunk;
} else if (!done) {
await new Promise<void>((resolve) => {
resolveNext = resolve;
});
}
}
// Ensure the send completes
await sendPromise;
} finally {
unsubDelta();
unsubEnd();
await session.destroy().catch(() => { /* ignore cleanup errors */ });
}
}
}

View File

@@ -1,5 +1,10 @@
import { app } from 'electron';
import { registerProvider, type AIProvider } from './provider';
// Dynamic import type — @github/copilot-sdk is ESM-only
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
let client: CopilotClientType | null = null;
let token: string | null = null;
const copilotProvider: AIProvider = {
@@ -8,13 +13,33 @@ const copilotProvider: AIProvider = {
async initialize(t: string): Promise<boolean> {
token = t;
// Actual GitHub Copilot SDK client creation will be added in US-019.
// For now, having a token means the provider is ready.
return true;
try {
// Stop existing client if re-initializing
if (client) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
await client.stop().catch(() => {});
client = null;
}
const { CopilotClient } = await import('@github/copilot-sdk');
client = new CopilotClient({
githubToken: t,
autoStart: true,
autoRestart: true,
logLevel: 'warning',
});
await client.start();
console.log('[AI] CopilotClient started successfully');
return true;
} catch (err) {
console.error('[AI] Failed to start CopilotClient:', err);
client = null;
return false;
}
},
isReady(): boolean {
return token !== null;
return client !== null && token !== null;
},
};
@@ -23,4 +48,16 @@ export function getCopilotToken(): string | null {
return token;
}
/** Get the CopilotClient instance (null if not initialized). */
export function getCopilotClient(): CopilotClientType | null {
return client;
}
// Clean shutdown on app quit
app.on('before-quit', () => {
if (client) {
client.stop().catch((err: unknown) => console.error('[AI] Error stopping CopilotClient:', err));
}
});
registerProvider(copilotProvider);

77
src/main/ai/llm.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* LLM connector factory — returns a LangChain BaseChatModel for the active provider.
*
* The agent orchestration (LangGraph) is provider-independent. This module is
* the only place that knows how to create provider-specific LLM instances.
*/
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { getActiveProviderName } from './provider';
import { getToken } from './token';
// ---------------------------------------------------------------------------
// Provider-specific factory functions (lazy-loaded)
// ---------------------------------------------------------------------------
async function createOpenAIModel(token: string): Promise<BaseChatModel> {
const { ChatOpenAI } = await import('@langchain/openai');
return new ChatOpenAI({
apiKey: token,
model: 'gpt-4o-mini',
temperature: 0.3,
streaming: true,
});
}
async function createAnthropicModel(token: string): Promise<BaseChatModel> {
const { ChatAnthropic } = await import('@langchain/anthropic');
return new ChatAnthropic({
apiKey: token,
model: 'claude-sonnet-4-20250514',
temperature: 0.3,
streaming: true,
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function createCopilotModel(_token: string): Promise<BaseChatModel> {
// GitHub Copilot uses the Copilot SDK subprocess for auth and API access.
// We wrap it in a LangChain-compatible adapter.
const { ChatCopilot } = await import('./chat-copilot');
return new ChatCopilot();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
const MODEL_FACTORIES: Record<string, (token: string) => Promise<BaseChatModel>> = {
openai: createOpenAIModel,
anthropic: createAnthropicModel,
copilot: createCopilotModel,
};
/**
* Get a LangChain BaseChatModel for the currently active AI provider.
* Returns null if no provider is configured or no token is available.
*/
export async function getLLM(): Promise<BaseChatModel | null> {
const providerName = getActiveProviderName();
const factory = MODEL_FACTORIES[providerName];
if (!factory) {
console.log(`[AI] No LLM factory for provider "${providerName}"`);
return null;
}
const token = await getToken(providerName);
if (!token) {
console.log(`[AI] No token available for provider "${providerName}"`);
return null;
}
try {
return await factory(token);
} catch (err) {
console.error(`[AI] Failed to create LLM for "${providerName}":`, err);
return null;
}
}

401
src/main/ai/orchestrator.ts Normal file
View File

@@ -0,0 +1,401 @@
/**
* @Orchestrator agent — LangGraph-based intent routing.
*
* The agent logic (routing, state) lives here and is fully LLM-agnostic.
* The LLM is a swappable connector obtained via `getLLM()`.
*/
import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
import { SystemMessage, HumanMessage, type BaseMessage } from '@langchain/core/messages';
import { z } from 'zod';
import { eq, asc } from 'drizzle-orm';
import { getDb } from '../db';
import { projects, tasks, checkpoints, notes, clients } from '../db/schema';
import { getLLM } from './llm';
const AI_STREAM_CHANNEL = 'ai:stream';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface OrchestrateInput {
message: string;
context: { type: 'global' | 'project'; projectId?: string };
sender?: Electron.WebContents;
}
export interface OrchestrateResult {
response: string;
error?: string;
}
// ---------------------------------------------------------------------------
// Context assembly (DB queries — provider-independent)
// ---------------------------------------------------------------------------
function buildProjectContext(projectId: string): string {
const db = getDb();
const project = db.select().from(projects).where(eq(projects.id, projectId)).all()[0];
if (!project) return 'Project not found.';
let clientName = '';
if (project.clientId) {
const client = db.select().from(clients).where(eq(clients.id, project.clientId)).all()[0];
if (client) clientName = client.name;
}
const projectTasks = db
.select({ title: tasks.title, status: tasks.status, priority: tasks.priority, dueDate: tasks.dueDate })
.from(tasks)
.where(eq(tasks.projectId, projectId))
.orderBy(asc(tasks.createdAt))
.all();
const projectCheckpoints = db
.select({ title: checkpoints.title, date: checkpoints.date, isApproved: checkpoints.isApproved })
.from(checkpoints)
.where(eq(checkpoints.projectId, projectId))
.orderBy(asc(checkpoints.date))
.all();
const projectNotes = db
.select({ title: notes.title, content: notes.content })
.from(notes)
.where(eq(notes.projectId, projectId))
.orderBy(asc(notes.createdAt))
.all();
const lines: string[] = [
`## Project: ${project.name}`,
clientName ? `Client: ${clientName}` : '',
`Status: ${project.status ?? 'active'}`,
project.aiSummary ? `AI Summary: ${project.aiSummary}` : '',
'',
`### Tasks (${projectTasks.length})`,
...projectTasks.map((t) => {
const due = t.dueDate ? new Date(t.dueDate).toLocaleDateString() : 'no due date';
return `- [${t.status}] ${t.title} (${t.priority}, ${due})`;
}),
'',
`### Checkpoints (${projectCheckpoints.length})`,
...projectCheckpoints.map((c) => {
const approved = c.isApproved ? 'approved' : 'pending';
return `- ${c.title}${new Date(c.date).toLocaleDateString()} (${approved})`;
}),
'',
`### Notes (${projectNotes.length})`,
...projectNotes.map((n) => {
const excerpt = n.content.length > 500 ? n.content.slice(0, 500) + '…' : n.content;
return `#### ${n.title}\n${excerpt}`;
}),
];
return lines.filter(Boolean).join('\n');
}
function buildGlobalContext(): string {
const db = getDb();
const allProjects = db
.select({ id: projects.id, name: projects.name, status: projects.status })
.from(projects)
.where(eq(projects.status, 'active'))
.orderBy(asc(projects.name))
.all();
const allTasks = db.select().from(tasks).all();
const todoCount = allTasks.filter((t) => t.status === 'todo').length;
const inProgressCount = allTasks.filter((t) => t.status === 'in_progress').length;
const doneCount = allTasks.filter((t) => t.status === 'done').length;
const now = Date.now();
const weekFromNow = now + 7 * 24 * 60 * 60 * 1000;
const upcomingTasks = allTasks
.filter((t) => t.dueDate && t.dueDate >= now && t.dueDate <= weekFromNow && t.status !== 'done')
.sort((a, b) => (a.dueDate ?? 0) - (b.dueDate ?? 0));
const projectMap = new Map(allProjects.map((p) => [p.id, p.name]));
const lines: string[] = [
`## Workspace Overview`,
`Active projects: ${allProjects.length}`,
`Tasks: ${allTasks.length} total (${todoCount} todo, ${inProgressCount} in progress, ${doneCount} done)`,
'',
`### Active Projects`,
...allProjects.map((p) => `- ${p.name}`),
'',
`### Tasks Due This Week (${upcomingTasks.length})`,
...upcomingTasks.map((t) => {
const projectName = t.projectId ? (projectMap.get(t.projectId) ?? 'Unknown') : 'No project';
const due = t.dueDate ? new Date(t.dueDate).toLocaleDateString() : '';
return `- ${t.title} [${projectName}] — due ${due}`;
}),
];
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// System prompts
// ---------------------------------------------------------------------------
function makeProjectAgentPrompt(contextData: string): string {
return `You are @ProjectAgent, an AI assistant specialized in a specific project within Adiuva.
You have access to the following project data:
${contextData}
Answer the user's question based on this project context. Be concise and helpful.
When referencing tasks, notes, or checkpoints, mention them by name.
If you don't have enough information, say so.`;
}
function makeGeneralAgentPrompt(contextData: string): string {
return `You are @GeneralAgent, an AI assistant for the Adiuva workspace.
You have access to the following workspace data:
${contextData}
Help the user with their question based on this workspace context. Provide concise, actionable answers.
When discussing tasks or projects, reference them by name.`;
}
function makeKnowledgeAgentPrompt(contextData: string): string {
return `You are @KnowledgeAgent, an AI assistant that searches across all project knowledge in Adiuva.
You have access to the following workspace data:
${contextData}
Note: Semantic vector search is not yet available. Answer based on the workspace summary data above.
If the user asks about specific note contents that aren't included here, let them know that full cross-project search will be available soon.`;
}
// ---------------------------------------------------------------------------
// LangGraph State
// ---------------------------------------------------------------------------
const RouteSchema = z.object({
route: z.enum(['project', 'knowledge', 'general']).describe(
'Which specialist agent should handle this request',
),
});
const OrchestratorState = Annotation.Root({
/** The user's original message */
userMessage: Annotation<string>(),
/** Chat context (global vs project-scoped) */
chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string }>(),
/** The route chosen by the orchestrator */
route: Annotation<'project' | 'knowledge' | 'general'>(),
/** Messages for the specialist agent */
messages: Annotation<BaseMessage[]>({
reducer: (existing, incoming) =>
Array.isArray(incoming) ? existing.concat(incoming) : existing.concat([incoming]),
default: () => [],
}),
/** The final response text */
response: Annotation<string>(),
});
type State = typeof OrchestratorState.State;
// ---------------------------------------------------------------------------
// Graph nodes
// ---------------------------------------------------------------------------
/** Node 1: Classify intent using structured output */
async function classifyIntent(state: State): Promise<Partial<State>> {
const llm = await getLLM();
if (!llm) throw new Error('AI provider not configured. Please add your token in Settings.');
const routerLLM = llm.withStructuredOutput(RouteSchema);
const result = await routerLLM.invoke([
new SystemMessage(
`You are a routing classifier for Adiuva, a project management workspace.
Classify the user's message into one of these categories:
- "project": Question about a specific project (tasks, notes, checkpoints, progress, summaries)
- "knowledge": Cross-project or historical question (e.g., "what did we decide about X?", "find notes about Y")
- "general": Everything else (general help, scheduling, task overviews, workspace summaries)`,
),
new HumanMessage(state.userMessage),
]);
return { route: result.route };
}
/** Node 2a: Project agent — answer project-scoped questions */
async function projectAgent(state: State): Promise<Partial<State>> {
const llm = await getLLM();
if (!llm) throw new Error('AI provider not configured.');
const projectId = state.chatContext.projectId;
const contextData = projectId ? buildProjectContext(projectId) : buildGlobalContext();
const systemPrompt = projectId
? makeProjectAgentPrompt(contextData)
: makeGeneralAgentPrompt(contextData);
const response = await llm.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(state.userMessage),
]);
const content = typeof response.content === 'string' ? response.content : '';
return {
messages: [response],
response: content,
};
}
/** Node 2b: Knowledge agent — cross-project search */
async function knowledgeAgent(state: State): Promise<Partial<State>> {
const llm = await getLLM();
if (!llm) throw new Error('AI provider not configured.');
const contextData = buildGlobalContext();
const systemPrompt = makeKnowledgeAgentPrompt(contextData);
const response = await llm.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(state.userMessage),
]);
const content = typeof response.content === 'string' ? response.content : '';
return {
messages: [response],
response: content,
};
}
/** Node 2c: General agent — workspace-wide questions */
async function generalAgent(state: State): Promise<Partial<State>> {
const llm = await getLLM();
if (!llm) throw new Error('AI provider not configured.');
const contextData = buildGlobalContext();
const systemPrompt = makeGeneralAgentPrompt(contextData);
const response = await llm.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(state.userMessage),
]);
const content = typeof response.content === 'string' ? response.content : '';
return {
messages: [response],
response: content,
};
}
/** Routing function: reads state.route and returns the next node name */
function routeDecision(state: State): string {
switch (state.route) {
case 'project': return 'projectAgent';
case 'knowledge': return 'knowledgeAgent';
case 'general': return 'generalAgent';
default: return 'generalAgent';
}
}
// ---------------------------------------------------------------------------
// Compile the graph (singleton, reused across calls)
// ---------------------------------------------------------------------------
function buildGraph() {
return new StateGraph(OrchestratorState)
.addNode('classifyIntent', classifyIntent)
.addNode('projectAgent', projectAgent)
.addNode('knowledgeAgent', knowledgeAgent)
.addNode('generalAgent', generalAgent)
.addEdge(START, 'classifyIntent')
.addConditionalEdges('classifyIntent', routeDecision, [
'projectAgent', 'knowledgeAgent', 'generalAgent',
])
.addEdge('projectAgent', END)
.addEdge('knowledgeAgent', END)
.addEdge('generalAgent', END)
.compile();
}
let compiledGraph: ReturnType<typeof buildGraph> | null = null;
function getGraph() {
if (!compiledGraph) {
compiledGraph = buildGraph();
}
return compiledGraph;
}
// ---------------------------------------------------------------------------
// Streaming helper
// ---------------------------------------------------------------------------
function sendStreamChunk(sender: Electron.WebContents | undefined, token: string, done: boolean): void {
if (!sender || sender.isDestroyed()) return;
sender.send(AI_STREAM_CHANNEL, { token, done });
}
// ---------------------------------------------------------------------------
// Orchestrate (public entry point)
// ---------------------------------------------------------------------------
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
const { message, context, sender } = input;
// Quick check: is an LLM available?
const llm = await getLLM();
if (!llm) {
return { response: '', error: 'AI provider not configured. Please add your token in Settings.' };
}
try {
const graph = getGraph();
// Use streaming to push tokens to the renderer in real-time
const stream = await graph.stream(
{
userMessage: message,
chatContext: context,
route: 'general' as const,
response: '',
},
{ streamMode: 'messages' as const },
);
let fullResponse = '';
for await (const [chunk, metadata] of stream) {
// Only stream tokens from the specialist agent nodes (not the classifier)
if (
metadata.langgraph_node !== 'classifyIntent' &&
chunk.content &&
typeof chunk.content === 'string'
) {
fullResponse += chunk.content;
sendStreamChunk(sender, chunk.content, false);
}
}
// Signal stream completion
sendStreamChunk(sender, '', true);
return { response: fullResponse };
} catch (err) {
sendStreamChunk(sender, '', true);
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes('401') || errMsg.includes('403') || errMsg.includes('auth') || errMsg.includes('Unauthorized')) {
return { response: '', error: 'Authentication failed. Please check your token in Settings.' };
}
if (errMsg.includes('timeout') || errMsg.includes('Timeout')) {
return { response: '', error: 'Request timed out. Please try again.' };
}
return { response: '', error: errMsg };
}
}

View File

@@ -16,6 +16,12 @@ import {
export const IPC_CHANNEL = 'trpc';
/** Context passed to every tRPC procedure via the IPC bridge. */
export type TRPCContext = {
/** The IPC sender — available for streaming chunks back to the renderer. */
sender?: Electron.WebContents;
};
interface IPCRequest {
method: 'request';
operation: {
@@ -57,7 +63,7 @@ export function createIPCHandler<TRouter extends AnyRouter>({
router,
path,
getRawInput: async () => rawInput,
ctx: {},
ctx: { sender: event.sender } satisfies TRPCContext,
type,
signal: undefined as unknown as AbortSignal,
batchIndex: 0,

View File

@@ -6,8 +6,10 @@ import { getDb } from '../db';
import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema';
import { getStore } from '../store';
import { saveTokenAndInit, hasActiveToken } from '../ai/provider';
import { orchestrate } from '../ai/orchestrator';
import type { TRPCContext } from '../ipc';
const t = initTRPC.create();
const t = initTRPC.context<TRPCContext>().create();
const router = t.router;
const publicProcedure = t.procedure;
@@ -492,7 +494,18 @@ const aiRouter = router({
projectId: z.string().optional(),
}),
}))
.mutation(() => ({ response: '' })),
.mutation(async ({ input, ctx }) => {
try {
return await orchestrate({
message: input.message,
context: input.context,
sender: ctx.sender,
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
return { response: '', error: msg };
}
}),
setToken: publicProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ input }) => {

View File

@@ -18,3 +18,16 @@ contextBridge.exposeInMainWorld('electronTRPC', {
};
},
});
const AI_STREAM_CHANNEL = 'ai:stream';
contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { token: string; done: boolean }) => cb(data);
ipcRenderer.on(AI_STREAM_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
};
},
});

View File

@@ -13,9 +13,14 @@ interface ElectronTRPC {
onMessage: (cb: (data: unknown) => void) => (() => void) | void;
}
interface ElectronAI {
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
}
declare global {
interface Window {
electronTRPC: ElectronTRPC;
electronAI: ElectronAI;
}
}