From 00a43e0fbc5c2bfe7a25c21fd1ae45e5c3c3e3c0 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Mon, 23 Feb 2026 17:58:00 +0100 Subject: [PATCH] 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. --- package-lock.json | 574 +++++++++++++++++++++++++++++++++++- package.json | 5 + prd.json | 4 +- progress.txt | 65 ++++ src/main/ai/chat-copilot.ts | 134 +++++++++ src/main/ai/copilot.ts | 45 ++- src/main/ai/llm.ts | 77 +++++ src/main/ai/orchestrator.ts | 401 +++++++++++++++++++++++++ src/main/ipc.ts | 8 +- src/main/router/index.ts | 17 +- src/preload/trpc.ts | 13 + src/renderer/lib/ipcLink.ts | 5 + vite.main.config.mts | 14 +- 13 files changed, 1348 insertions(+), 14 deletions(-) create mode 100644 src/main/ai/chat-copilot.ts create mode 100644 src/main/ai/llm.ts create mode 100644 src/main/ai/orchestrator.ts diff --git a/package-lock.json b/package-lock.json index ecd8609..4b2d456 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,12 @@ "license": "MIT", "dependencies": { "@fontsource/geist": "^5.2.8", + "@github/copilot-sdk": "^0.1.25", "@hello-pangea/dnd": "^18.0.1", + "@langchain/anthropic": "^1.3.19", + "@langchain/core": "^1.1.27", + "@langchain/langgraph": "^1.1.5", + "@langchain/openai": "^1.2.9", "@milkdown/crepe": "^7.18.0", "@milkdown/kit": "^7.18.0", "@tailwindcss/vite": "^4.2.0", @@ -89,6 +94,26 @@ "nup": "bin/nup.mjs" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.74.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.74.0.tgz", + "integrity": "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -619,6 +644,12 @@ "node": ">=6.9.0" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -3062,6 +3093,133 @@ "dev": true, "license": "MIT" }, + "node_modules/@github/copilot": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", + "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", + "license": "SEE LICENSE IN LICENSE.md", + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "0.0.411", + "@github/copilot-darwin-x64": "0.0.411", + "@github/copilot-linux-arm64": "0.0.411", + "@github/copilot-linux-x64": "0.0.411", + "@github/copilot-win32-arm64": "0.0.411", + "@github/copilot-win32-x64": "0.0.411" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", + "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", + "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", + "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", + "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.25.tgz", + "integrity": "sha512-hIgYLPXzWw9bNgrsD5BLKmgVH20ow5Or5UyVXfVe3YgeiaTgFxC4jWSAVHLGB6ufHZUrvbjppcq2dWK63FmDRA==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^0.0.411", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", + "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", + "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, "node_modules/@hello-pangea/dnd": { "version": "18.0.1", "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz", @@ -3610,6 +3768,182 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@langchain/anthropic": { + "version": "1.3.19", + "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.19.tgz", + "integrity": "sha512-OY7/pi4MBuBA/RCJJGqL0Dx3J7Udqwz35Q9sTv5oWiwt6UgMQKGZ17k8gEXyknpuKcRd3A81M09mzoGL8/6zGQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.74.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.1.27" + } + }, + "node_modules/@langchain/core": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.27.tgz", + "integrity": "sha512-YVtEz3nqCh8WxtdVXUICmt2BR2An+mn4YRJUBwcHX47Yrh2VwxpO0l97B2N/sNi658m65HnGyz2/hAjF3fzc1w==", + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.5.0 <1.0.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "uuid": "^10.0.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@langchain/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@langchain/langgraph": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.1.5.tgz", + "integrity": "sha512-uJC/asydf/GoHpo9x42lf9hs8ufCkMuJ9sDle5ybP7sMD0XryOfE0E4J3deARk9ZadCCt6zeCoCNu/mTbx8+Sg==", + "license": "MIT", + "dependencies": { + "@langchain/langgraph-checkpoint": "^1.0.0", + "@langchain/langgraph-sdk": "~2.0.0", + "@standard-schema/spec": "1.1.0", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.1.16", + "zod": "^3.25.32 || ^4.2.0", + "zod-to-json-schema": "^3.x" + }, + "peerDependenciesMeta": { + "zod-to-json-schema": { + "optional": true + } + } + }, + "node_modules/@langchain/langgraph-checkpoint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz", + "integrity": "sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==", + "license": "MIT", + "dependencies": { + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.0.1" + } + }, + "node_modules/@langchain/langgraph-sdk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-2.0.0.tgz", + "integrity": "sha512-Xdkl1hve84ZGQ7fgpiBIBvjODhtjbPPccY4snOtYgSdzRXZkESsi2Y7RDKgFe1nC9+DbX+QaYom0raD/XFBKAw==", + "deprecated": "This version is not intended for use. Please use 1.x versions of @langchain/langgraph-sdk instead", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "p-queue": "^9.0.1", + "p-retry": "^7.1.1", + "uuid": "^13.0.0" + }, + "peerDependencies": { + "@langchain/core": "^1.1.16", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", + "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/@langchain/openai": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.2.9.tgz", + "integrity": "sha512-hExRiUoKOg1vfkwBAI5J2C4tqNx5LLZ0CUelG8Ej6K8bS2LfFN9bL4ZNQYqNIwAJNSqpDaV9tknxP2fssZjp+Q==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^6.18.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.1.27" + } + }, "node_modules/@lezer/common": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", @@ -6551,6 +6885,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -7360,7 +7700,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -7499,6 +7838,12 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -9266,6 +9611,18 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001770", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", @@ -9686,6 +10043,15 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/console-table-printer": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", + "integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==", + "license": "MIT", + "dependencies": { + "simple-wcswidth": "^1.1.2" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -10008,6 +10374,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -12362,7 +12737,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, "license": "MIT" }, "node_modules/events": { @@ -14260,6 +14634,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-network-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-node-process": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", @@ -14615,6 +15001,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -14662,6 +15057,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -14782,6 +15190,52 @@ "node": ">=6" } }, + "node_modules/langsmith": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.6.tgz", + "integrity": "sha512-T/RA2l2MsTYX0z1aW8rQ2hBQZEOuXV2v/6tkfG6R5EotJTKMpw1dERCbvP8ezOP8otyWfnNlQA88ZnMRsQ7CHA==", + "license": "MIT", + "dependencies": { + "@types/uuid": "^10.0.0", + "chalk": "^5.6.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "semver": "^7.6.3", + "uuid": "^10.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + } + } + }, + "node_modules/langsmith/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -16646,6 +17100,15 @@ "node": ">=8" } }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mute-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", @@ -17071,6 +17534,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.22.0.tgz", + "integrity": "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -17205,7 +17689,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -17269,6 +17752,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", @@ -20075,6 +20607,12 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-wcswidth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", + "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -21000,6 +21538,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-morph": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", @@ -22052,6 +22596,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -22170,6 +22727,15 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vue": { "version": "3.5.28", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", @@ -22664,7 +23230,7 @@ "version": "3.25.1", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "dev": true, + "devOptional": true, "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/package.json b/package.json index 5184122..754726a 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,12 @@ }, "dependencies": { "@fontsource/geist": "^5.2.8", + "@github/copilot-sdk": "^0.1.25", "@hello-pangea/dnd": "^18.0.1", + "@langchain/anthropic": "^1.3.19", + "@langchain/core": "^1.1.27", + "@langchain/langgraph": "^1.1.5", + "@langchain/openai": "^1.2.9", "@milkdown/crepe": "^7.18.0", "@milkdown/kit": "^7.18.0", "@tailwindcss/vite": "^4.2.0", diff --git a/prd.json b/prd.json index e07e2b6..11a594d 100644 --- a/prd.json +++ b/prd.json @@ -350,8 +350,8 @@ "Typecheck passes" ], "priority": 19, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed: LangGraph-based provider-independent orchestrator. StateGraph with classifyIntent node (structured output via withStructuredOutput(zodSchema) for route classification) + 3 specialist agent nodes (projectAgent, knowledgeAgent, generalAgent) connected via addConditionalEdges. LLM factory (src/main/ai/llm.ts) returns BaseChatModel per provider: ChatOpenAI (openai), ChatAnthropic (anthropic), ChatCopilot adapter (copilot). ChatCopilot (src/main/ai/chat-copilot.ts) wraps @github/copilot-sdk in SimpleChatModel with _call() and _streamResponseChunks(). Streaming via LangGraph streamMode:'messages' + IPC side-channel (ai:stream). Context assembly from DB (buildProjectContext/buildGlobalContext), tRPC context with event.sender, auth/timeout error handling. Typecheck passes." }, { "id": "US-020", diff --git a/progress.txt b/progress.txt index ade80b4..f5eb28b 100644 --- a/progress.txt +++ b/progress.txt @@ -351,3 +351,68 @@ - Token is stored per-provider so multiple providers can have tokens stored simultaneously; switching providers just changes which key is read - `electron-store` dot-notation access (`store.get('a.b')`) works but loses type safety; prefer `store.get('encryptedTokens')` then access the nested key on the result object --- + +## 2026-02-23 - US-019 +- What was implemented: + - Installed `@github/copilot-sdk` (v0.1.25) — official GitHub Copilot SDK with CopilotClient for programmatic CLI control via JSON-RPC + - Updated `src/main/ai/copilot.ts` — CopilotClient singleton created via dynamic `import()` (SDK is ESM-only), `initialize()` starts the client with `githubToken`, `getCopilotClient()` exported, clean shutdown on `app.before-quit` + - Added `@github/copilot-sdk` and `@github/copilot` to `vite.main.config.mts` externals (native CLI binary + prebuilds must stay in node_modules) + - Created IPC streaming side-channel: + - `src/preload/trpc.ts` — exposed `window.electronAI.onStreamChunk(cb)` via contextBridge on `ai:stream` IPC channel + - `src/main/ipc.ts` — added `TRPCContext` type with optional `sender: Electron.WebContents`, passed `event.sender` into tRPC context + - `src/renderer/lib/ipcLink.ts` — added `Window.electronAI` type declaration + - Updated `src/main/router/index.ts` — `initTRPC.context().create()`, `ai.chat` mutation now calls `orchestrate()` with streaming + error handling + - Created `src/main/ai/orchestrator.ts` (new) — core Orchestrator agent: + - System prompt instructs model to use exactly one routing tool per message + - 3 routing tools: `route_to_project`, `route_to_knowledge`, `route_to_general` (each with JSON schema params + handler callback) + - `buildProjectContext(projectId)` — fetches project, tasks, checkpoints, notes from DB + - `buildGlobalContext()` — fetches active projects, task counts, upcoming tasks due this week + - Two-phase orchestration: (1) Orchestrator session classifies intent via tool call, (2) Specialist session generates streamed response + - Specialist agent prompts: @ProjectAgent (scoped project data), @KnowledgeAgent (stub — LanceDB pending US-023), @GeneralAgent (workspace summary) + - Streaming via `session.on('assistant.message_delta')` → `sender.send('ai:stream', { token, done })` + - Error classification: auth (401/403) → friendly message, timeout → retry prompt, generic → error message + - Typecheck passes (zero errors), no new lint errors +- Files changed: `package.json`, `package-lock.json`, `vite.main.config.mts`, `src/main/ai/copilot.ts`, `src/main/ai/orchestrator.ts` (new), `src/main/ipc.ts`, `src/preload/trpc.ts`, `src/main/router/index.ts`, `src/renderer/lib/ipcLink.ts`, `prd.json`, `progress.txt` +- **Learnings for future iterations:** + - `@github/copilot-sdk` is ESM-only (`"type": "module"`) — cannot `require()` from CJS Electron main process. Use `await import('@github/copilot-sdk')` inside async functions + - The SDK depends on `@github/copilot` (the CLI binary, ~400MB) which includes native prebuilds, ripgrep, tree-sitter WASM, etc. Both must be externalized in Vite config + - `CopilotClient` manages a CLI subprocess via JSON-RPC. `autoStart: true` + `autoRestart: true` handles lifecycle. Call `client.stop()` on `app.before-quit` + - SDK tools use a `handler: async (args) => ToolResult` callback pattern — the SDK calls your handler when the model invokes the tool. This is different from OpenAI's function-calling (where you check tool_calls in the response) + - `session.sendAndWait()` blocks until `session.idle`, returns `AssistantMessageEvent | undefined`. Default timeout is 60s + - `session.on('assistant.message_delta', cb)` fires for each streaming chunk with `{ messageId, deltaContent }`. Returns an unsubscribe function + - `SessionConfig.availableTools` controls which tools are exposed to the model. Set to only your custom tool names to disable all built-in Copilot tools + - `SystemMessageConfig` has `mode: 'replace'` to fully replace the SDK's default system prompt — necessary for agent specialization + - For IPC streaming: extend the preload to expose a separate channel (`ai:stream`) via `contextBridge.exposeInMainWorld()`, and use `event.sender.send()` from the main process. This avoids touching the tRPC request-response infrastructure + - `TRPCContext` with optional `sender` field preserves backward compatibility — non-AI procedures get `sender` but don't use it +--- + +## 2026-02-23 - US-019 Refactor: LangGraph Provider-Independent Orchestration +- What was implemented: + - Installed `@langchain/langgraph`, `@langchain/core`, `@langchain/openai`, `@langchain/anthropic` for provider-independent agent orchestration + - Created `src/main/ai/llm.ts` (new) — LLM factory returning `BaseChatModel` for the active provider: + - `openai` → `ChatOpenAI` (gpt-4o-mini, streaming) + - `anthropic` → `ChatAnthropic` (claude-sonnet, streaming) + - `copilot` → `ChatCopilot` adapter wrapping the Copilot SDK + - Created `src/main/ai/chat-copilot.ts` (new) — LangChain-compatible `SimpleChatModel` adapter for GitHub Copilot SDK: + - `_call()` creates a Copilot session, sends messages, returns response text + - `_streamResponseChunks()` yields `ChatGenerationChunk` tokens via `assistant.message_delta` event listener + async generator pattern + - Refactored `src/main/ai/orchestrator.ts` — replaced Copilot SDK sessions with LangGraph `StateGraph`: + - `OrchestratorState` annotation: `userMessage`, `chatContext`, `route`, `messages`, `response` + - `classifyIntent` node: uses `llm.withStructuredOutput(RouteSchema)` for intent classification (project/knowledge/general) + - `projectAgent`, `knowledgeAgent`, `generalAgent` nodes: each gets context from DB + invokes LLM + - `addConditionalEdges` routes from classifier to specialist based on `state.route` + - Streaming via LangGraph `streamMode: 'messages'` — tokens from specialist nodes forwarded to renderer via IPC + - Graph is compiled once (singleton) and reused across calls + - Updated `vite.main.config.mts` — added all `@langchain/*` packages to externals + - Context assembly functions (`buildProjectContext`, `buildGlobalContext`) and system prompts preserved unchanged + - Typecheck passes (zero errors), no new lint errors +- Files changed: `package.json`, `package-lock.json`, `vite.main.config.mts`, `src/main/ai/llm.ts` (new), `src/main/ai/chat-copilot.ts` (new), `src/main/ai/orchestrator.ts`, `progress.txt` +- **Learnings for future iterations:** + - LangGraph packages ship dual ESM/CJS (`require` + `import` in exports map) — no dynamic import needed unlike `@github/copilot-sdk` + - `SimpleChatModel` from `@langchain/core` only requires implementing `_call()` and `_llmType()` — much simpler than full `BaseChatModel` + - `withStructuredOutput(zodSchema)` on a `BaseChatModel` forces the LLM to return JSON matching the schema — ideal for intent routing without custom tool handlers + - LangGraph `streamMode: 'messages'` yields `[chunk, metadata]` tuples; `metadata.langgraph_node` identifies which graph node produced each token — use this to filter out classifier tokens + - `Annotation.Root({})` with a `reducer` function enables append-only message arrays in state — matches LangGraph's immutable state update pattern + - The graph is compiled once via `buildGraph()` singleton — no per-request overhead for graph construction + - Architecture: agent logic (LangGraph) is now fully decoupled from the LLM provider. Adding a new provider only requires a new factory function in `llm.ts` +--- diff --git a/src/main/ai/chat-copilot.ts b/src/main/ai/chat-copilot.ts new file mode 100644 index 0000000..cab796c --- /dev/null +++ b/src/main/ai/chat-copilot.ts @@ -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 { + 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 { + 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 { + 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((resolve) => { + resolveNext = resolve; + }); + } + } + + // Ensure the send completes + await sendPromise; + } finally { + unsubDelta(); + unsubEnd(); + await session.destroy().catch(() => { /* ignore cleanup errors */ }); + } + } +} diff --git a/src/main/ai/copilot.ts b/src/main/ai/copilot.ts index 55a24ae..4a932ed 100644 --- a/src/main/ai/copilot.ts +++ b/src/main/ai/copilot.ts @@ -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 { 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); diff --git a/src/main/ai/llm.ts b/src/main/ai/llm.ts new file mode 100644 index 0000000..30aa49c --- /dev/null +++ b/src/main/ai/llm.ts @@ -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 { + 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 { + 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 { + // 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 Promise> = { + 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 { + 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; + } +} diff --git a/src/main/ai/orchestrator.ts b/src/main/ai/orchestrator.ts new file mode 100644 index 0000000..45e765c --- /dev/null +++ b/src/main/ai/orchestrator.ts @@ -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(), + /** 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({ + reducer: (existing, incoming) => + Array.isArray(incoming) ? existing.concat(incoming) : existing.concat([incoming]), + default: () => [], + }), + /** The final response text */ + response: Annotation(), +}); + +type State = typeof OrchestratorState.State; + +// --------------------------------------------------------------------------- +// Graph nodes +// --------------------------------------------------------------------------- + +/** Node 1: Classify intent using structured output */ +async function classifyIntent(state: State): Promise> { + 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> { + 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> { + 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> { + 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 | 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 { + 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 }; + } +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7061181..1b19c20 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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({ router, path, getRawInput: async () => rawInput, - ctx: {}, + ctx: { sender: event.sender } satisfies TRPCContext, type, signal: undefined as unknown as AbortSignal, batchIndex: 0, diff --git a/src/main/router/index.ts b/src/main/router/index.ts index 2f48389..96bfb33 100644 --- a/src/main/router/index.ts +++ b/src/main/router/index.ts @@ -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().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 }) => { diff --git a/src/preload/trpc.ts b/src/preload/trpc.ts index f7a6c35..19ea582 100644 --- a/src/preload/trpc.ts +++ b/src/preload/trpc.ts @@ -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); + }; + }, +}); diff --git a/src/renderer/lib/ipcLink.ts b/src/renderer/lib/ipcLink.ts index 80993f3..1daa343 100644 --- a/src/renderer/lib/ipcLink.ts +++ b/src/renderer/lib/ipcLink.ts @@ -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; } } diff --git a/vite.main.config.mts b/vite.main.config.mts index 7c4753f..4a336f6 100644 --- a/vite.main.config.mts +++ b/vite.main.config.mts @@ -5,7 +5,19 @@ export default defineConfig({ build: { rollupOptions: { // Externalize native Node modules — they're rebuilt by electron-forge - external: ['better-sqlite3', 'keytar'], + external: [ + 'better-sqlite3', + 'keytar', + '@github/copilot-sdk', + '@github/copilot', + // LangChain — externalize to avoid bundling Node.js-specific code + '@langchain/core', + '@langchain/langgraph', + '@langchain/openai', + '@langchain/anthropic', + '@langchain/langgraph-checkpoint', + '@langchain/langgraph-sdk', + ], output: { entryFileNames: 'main.js', },