From 6859b252d247dfe4ec1e60efbcfc1cd1187d496f Mon Sep 17 00:00:00 2001 From: Artur <50996474+ArturCharylo@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:10:04 +0100 Subject: [PATCH 1/8] feat: add AIProvider interface for agent abstraction --- src/agent/providers/types.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/agent/providers/types.ts diff --git a/src/agent/providers/types.ts b/src/agent/providers/types.ts new file mode 100644 index 0000000..ce1aeb3 --- /dev/null +++ b/src/agent/providers/types.ts @@ -0,0 +1,32 @@ +/** + * Standard interface for all AI service providers (Copilot, OpenAI, etc.) + */ +export interface AIProvider { + /** unique identifier for the provider */ + readonly id: string; + + /** name displayed in the CLI menu */ + readonly name: string; + + /** + * Initializes the provider with necessary credentials/settings + */ + initialize(): Promise; + + /** + * Sends a message to the AI and handles the response + * @param message User input string + */ + sendMessage(message: string): Promise; + + /** + * Cleans up resources or ends the current session + */ + endSession(): Promise; + + /** + * Event emitter for streaming or final messages + * @param callback Function to call with each new message content + */ + onMessage(callback: (content: string) => void): void; +} \ No newline at end of file From ba7695b0d7f822db363f34dbaa7cb93ef7699e0f Mon Sep 17 00:00:00 2001 From: Artur <50996474+ArturCharylo@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:38:22 +0100 Subject: [PATCH 2/8] refactor: implement CopilotProvider and clean up agent logic --- .../copilot.provider.ts} | 124 ++++++++---------- src/agent/providers/types.ts | 7 + src/index.ts | 8 +- 3 files changed, 63 insertions(+), 76 deletions(-) rename src/agent/{copilot.ts => providers/copilot.provider.ts} (80%) diff --git a/src/agent/copilot.ts b/src/agent/providers/copilot.provider.ts similarity index 80% rename from src/agent/copilot.ts rename to src/agent/providers/copilot.provider.ts index 8e3b305..7407a42 100644 --- a/src/agent/copilot.ts +++ b/src/agent/providers/copilot.provider.ts @@ -1,7 +1,8 @@ import { CopilotClient, CopilotSession } from "@github/copilot-sdk"; import { EventEmitter } from "events"; -import { currencyConversionTool, servicePricingLookupTool } from "./tools.js"; -import { resolveSettings } from "../lib/settings.js"; +import { currencyConversionTool, servicePricingLookupTool } from "../tools.js"; +import { resolveSettings } from "../../lib/settings.js"; +import type { AIProvider } from "./types.js"; const DEBUG_MODE = process.env.DEBUG_MODE === "true"; @@ -61,6 +62,11 @@ const DEFAULT_SYSTEM_PROMPT = ` ` ; +export interface AgentMessageReceived { + type: "assistant.message" | "tool.execution_start" | "tool.execution_complete" | "session.idle"; + content: string; +} + let client: CopilotClient | null = null; async function getClient(): Promise { @@ -71,35 +77,16 @@ async function getClient(): Promise { return client; } -export interface SessionEvents { - message: (data: AgentMessageReceived) => void; - thinking: () => void; - error: (error: Error) => void; - ended: () => void; -} - -export interface AgentMessageReceived { - type: "assistant.message" | "tool.execution_start" | "tool.execution_complete" | "session.idle"; - content: string; -} - -export interface AuthStatus { - isAuthenticated: boolean; - login?: string | undefined; - authType?: "user" | "env" | "gh-cli" | "hmac" | "api-key" | "token" | undefined; - statusMessage?: string | undefined; -} - -export async function checkAuth(): Promise { - const client = new CopilotClient({ +export async function checkAuth(): Promise { + const copilotClient = new CopilotClient({ autoStart: true, autoRestart: false, }); try { - await client.start(); - const status = await client.getAuthStatus(); - await client.stop(); + await copilotClient.start(); + const status = await copilotClient.getAuthStatus(); + await copilotClient.stop(); return { isAuthenticated: status.isAuthenticated, login: status.login, @@ -108,7 +95,7 @@ export async function checkAuth(): Promise { }; } catch { try { - await client.forceStop(); + await copilotClient.forceStop(); } catch { // Ignore cleanup errors } @@ -116,27 +103,36 @@ export async function checkAuth(): Promise { } } -export class QuotationChatbot extends EventEmitter { - private client: CopilotClient | null = null; +/** + * GitHub Copilot implementation of the AIProvider interface + */ +export class CopilotProvider extends EventEmitter implements AIProvider { + readonly id = "github-copilot"; + readonly name = "GitHub Copilot"; + + private copilotClient: CopilotClient | null = null; private session: CopilotSession | null = null; private accumulatedContent: string = ""; - constructor(brief: string) { + constructor() { super(); } - on(event: K, listener: SessionEvents[K]): this { - return super.on(event, listener); + // Implementation of AIProvider.onMessage + onMessage(callback: (content: string) => void): void { + this.on("message", (data: AgentMessageReceived) => { + if (data.type === "assistant.message") { + callback(data.content); + } + }); } - emit(event: K, ...args: Parameters): boolean { - return super.emit(event, ...args); - } + async initialize(): Promise { + if (!this.copilotClient) { + this.copilotClient = await getClient(); + } - async init() { - this.client = await getClient(); - // Check authentication status before creating session - const authStatus = await this.client.getAuthStatus(); + const authStatus = await this.copilotClient.getAuthStatus(); if (!authStatus.isAuthenticated) { throw new Error( "Not authenticated with GitHub Copilot.\n\n" + @@ -148,40 +144,23 @@ export class QuotationChatbot extends EventEmitter { async startSession( brief: string, options?: { skipInitialMessage?: boolean } - ): Promise { + ): Promise { try { - await this.init(); - - const copilot = this.client; - - if (!copilot) { - throw new Error("Copilot client not initialized. Call init() first."); - } - - // Create session with quote brief context - + if (!this.copilotClient) await this.initialize(); + const { systemPrompt } = await resolveSettings(); - const promptToUse = - systemPrompt && systemPrompt.trim().length > 0 ? systemPrompt : DEFAULT_SYSTEM_PROMPT; + const promptToUse = systemPrompt?.trim() ? systemPrompt : DEFAULT_SYSTEM_PROMPT; - this.session = await copilot.createSession({ + this.session = await this.copilotClient!.createSession({ sessionId: `quote-session-${Date.now()}`, model: "gpt-4o-mini", streaming: true, tools: [currencyConversionTool, servicePricingLookupTool], systemMessage: { - "mode": "append", - "content": promptToUse + mode: "append", + content: promptToUse } }); - if (DEBUG_MODE) { - console.log("[copilot] session created"); - } - - if (!options?.skipInitialMessage) { - // Add the brief as the first user message - await this.sendMessage(`Here is the brief for the quote:\n\n${brief}`); - } // Subscribe to session events and re-emit them this.session.on((data) => { @@ -261,22 +240,23 @@ export class QuotationChatbot extends EventEmitter { } }); - return this.session; - } - catch (error) { + // Optionally send the initial brief into the session + if (!options?.skipInitialMessage) { + await this.sendMessage(`Here is the brief for the quote:\n\n${brief}`); + } + } catch (error) { console.log("\n❌ Failed to start Copilot session. Please check your authentication and try again.\n"); throw error; } - } - async sendMessage(message: string) { + async sendMessage(message: string): Promise { // Reset accumulated content for new interaction this.accumulatedContent = ""; await this.session?.send({ prompt: message }); } - async endSession() { + async endSession(): Promise { if (this.session) { await this.session.destroy(); this.emit("ended"); @@ -285,7 +265,7 @@ export class QuotationChatbot extends EventEmitter { } export async function listSessions() { - const copilot = await getClient(); - const sessions = await copilot.listSessions(); + const copilotClient = await getClient(); + const sessions = await copilotClient.listSessions(); return sessions; -} +} \ No newline at end of file diff --git a/src/agent/providers/types.ts b/src/agent/providers/types.ts index ce1aeb3..2fd287d 100644 --- a/src/agent/providers/types.ts +++ b/src/agent/providers/types.ts @@ -13,6 +13,13 @@ export interface AIProvider { */ initialize(): Promise; + /** + * Starts a new chat session with the provided brief. + * * @param brief The initial context or requirements for the quote. + * @param options Configuration options for the session start. + */ + startSession(brief: string, options?: { skipInitialMessage?: boolean }): Promise; + /** * Sends a message to the AI and handles the response * @param message User input string diff --git a/src/index.ts b/src/index.ts index b403e11..e000da7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ import * as readline from "readline"; import { displayHeader } from "./ui/header.js"; -import { checkAuth, QuotationChatbot } from "./agent/copilot.js"; +import { CopilotProvider, checkAuth } from "./agent/providers/copilot.provider.js"; +import type { AIProvider } from "./agent/providers/types.js"; import { displayMenu } from "./ui/menu.js"; import { appendQuoteSession, @@ -310,8 +311,7 @@ async function createQuoteFlow(brief: string, mainRl: readline.Interface): Promi mainRl.pause(); // init the chatbot session here - - const agent = new QuotationChatbot(brief.trim()); + const agent = new CopilotProvider(); const sessionId = generateSessionId(); const sessionCreatedAt = new Date().toISOString(); const storedMessages: StoredMessage[] = []; @@ -486,7 +486,7 @@ async function openQuoteFlow(session: StoredQuote, mainRl: readline.Interface): mainLineListeners.forEach((l) => mainRl.removeListener("line", l as any)); mainRl.pause(); - const agent = new QuotationChatbot(session.brief); + const agent: AIProvider = new CopilotProvider(); const storedMessages: StoredMessage[] = [...session.messages]; let finalSummary: string | undefined = session.finalSummary; From da77ff0128a372fc070b27072179419b8cc54172 Mon Sep 17 00:00:00 2001 From: Artur <50996474+ArturCharylo@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:55:43 +0100 Subject: [PATCH 3/8] feat: added openai package, updated setting's interfaces added constant for default system prompt and started implementing openai provider --- package-lock.json | 24 ++++- package.json | 3 +- src/agent/providers/constants.ts | 56 +++++++++++ src/agent/providers/copilot.provider.ts | 57 +----------- src/agent/providers/openai.provider.ts | 119 ++++++++++++++++++++++++ src/agent/providers/types.ts | 10 ++ src/lib/settings.ts | 14 +++ 7 files changed, 226 insertions(+), 57 deletions(-) create mode 100644 src/agent/providers/constants.ts create mode 100644 src/agent/providers/openai.provider.ts diff --git a/package-lock.json b/package-lock.json index 244a00c..99388a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "ISC", "dependencies": { "@github/copilot-sdk": "^0.1.18", - "axios": "^1.13.3" + "axios": "^1.13.3", + "openai": "^6.22.0" }, "bin": { "quote": "bin/quote.js" @@ -883,6 +884,27 @@ "node": ">= 0.6" } }, + "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/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", diff --git a/package.json b/package.json index 6678456..a644e37 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@github/copilot-sdk": "^0.1.18", - "axios": "^1.13.3" + "axios": "^1.13.3", + "openai": "^6.22.0" } } diff --git a/src/agent/providers/constants.ts b/src/agent/providers/constants.ts new file mode 100644 index 0000000..df28192 --- /dev/null +++ b/src/agent/providers/constants.ts @@ -0,0 +1,56 @@ +export const PROMPT = { + DEFAULT_SYSTEM_PROMPT: ` + + You are a pricing and quotation chatbot agent that has a direct communication channel with the user. You provide accurate and competitive pricing quotes based on user requests. + You are an expert in software development services and understand how to price development work based on time, complexity, and scope. + You are pricing work for Tom Shaw, a freelance software developer who charges for time spent writing code, attending planning or discovery calls, architecture and system design, documentation, and related technical work. + + + + Provide accurate and competitive pricing quotes that ensure the developer is paid fairly for their time and expertise. + If you do not have enough information, or need any details specified (for example, scope of work, estimated complexity, required technologies, timelines, or number of meetings), ask the user for more information before finalising the quote. + + + + The first message a user sends you will typically be a direct brief from a client describing the work they want done. + Use this information to formulate an initial quote or estimate. + In subsequent messages, the user may ask for clarifications, adjustments to the quote, alternative pricing structures (e.g. hourly vs fixed), or comparisons to previous work and rates. + + You do not need to access any local files on the user's computer to complete your task. + You should only use the tools provided to you to gather information needed to formulate a quote. + + + + You have access to tools to: + 1. servicePricingLookupTool – Retrieve the developer’s standard rates and service pricing (e.g. hourly rate, day rate, discovery calls, maintenance work) from Notion in the form of plain text. + 2. currencyConversionTool – Convert amounts between different currencies using up-to-date exchange rates. + + CRITICAL: After using ANY tool, you MUST immediately send a response message to the user with the results. + Never leave a tool call without providing a follow-up message explaining what the tool returned and how it was used in the quote. + + + + Provide a clear and concise quote based on the user's request. + The quote should be broken down into its component parts so the user can understand how the final amount was calculated (e.g. development time, planning calls, ongoing support). + + If the user asks for more information, provide relevant details about the developer’s previous work, typical engagement structures, or standard rates. + + The quote should be in GBP unless otherwise specified. + If another currency is requested, calculate the pricing in GBP first, then convert it using the currency conversion tool. + + Your message should either: + - Ask for more information (normal chat response), or + - Provide a detailed quote breakdown, like so: + + --- + Quote Breakdown: + 1. Discovery & Planning Calls (X hours): £X + 2. Software Development (Y hours): £Y + 3. Documentation / Handover: £Z + ------------------------ + Total Quote: £TotalAmount + --- + + Always ensure the quote is competitive and accurately reflects the developer’s time, expertise, and value. + `, +} \ No newline at end of file diff --git a/src/agent/providers/copilot.provider.ts b/src/agent/providers/copilot.provider.ts index 7407a42..bed34c1 100644 --- a/src/agent/providers/copilot.provider.ts +++ b/src/agent/providers/copilot.provider.ts @@ -3,64 +3,11 @@ import { EventEmitter } from "events"; import { currencyConversionTool, servicePricingLookupTool } from "../tools.js"; import { resolveSettings } from "../../lib/settings.js"; import type { AIProvider } from "./types.js"; +import { PROMPT } from "./constants.js"; const DEBUG_MODE = process.env.DEBUG_MODE === "true"; -const DEFAULT_SYSTEM_PROMPT = ` - - You are a pricing and quotation chatbot agent that has a direct communication channel with the user. You provide accurate and competitive pricing quotes based on user requests. - You are an expert in software development services and understand how to price development work based on time, complexity, and scope. - You are pricing work for Tom Shaw, a freelance software developer who charges for time spent writing code, attending planning or discovery calls, architecture and system design, documentation, and related technical work. - - - - Provide accurate and competitive pricing quotes that ensure the developer is paid fairly for their time and expertise. - If you do not have enough information, or need any details specified (for example, scope of work, estimated complexity, required technologies, timelines, or number of meetings), ask the user for more information before finalising the quote. - - - - The first message a user sends you will typically be a direct brief from a client describing the work they want done. - Use this information to formulate an initial quote or estimate. - In subsequent messages, the user may ask for clarifications, adjustments to the quote, alternative pricing structures (e.g. hourly vs fixed), or comparisons to previous work and rates. - - You do not need to access any local files on the user's computer to complete your task. - You should only use the tools provided to you to gather information needed to formulate a quote. - - - - You have access to tools to: - 1. servicePricingLookupTool – Retrieve the developer’s standard rates and service pricing (e.g. hourly rate, day rate, discovery calls, maintenance work) from Notion in the form of plain text. - 2. currencyConversionTool – Convert amounts between different currencies using up-to-date exchange rates. - - CRITICAL: After using ANY tool, you MUST immediately send a response message to the user with the results. - Never leave a tool call without providing a follow-up message explaining what the tool returned and how it was used in the quote. - - - - Provide a clear and concise quote based on the user's request. - The quote should be broken down into its component parts so the user can understand how the final amount was calculated (e.g. development time, planning calls, ongoing support). - - If the user asks for more information, provide relevant details about the developer’s previous work, typical engagement structures, or standard rates. - - The quote should be in GBP unless otherwise specified. - If another currency is requested, calculate the pricing in GBP first, then convert it using the currency conversion tool. - - Your message should either: - - Ask for more information (normal chat response), or - - Provide a detailed quote breakdown, like so: - - --- - Quote Breakdown: - 1. Discovery & Planning Calls (X hours): £X - 2. Software Development (Y hours): £Y - 3. Documentation / Handover: £Z - ------------------------ - Total Quote: £TotalAmount - --- - - Always ensure the quote is competitive and accurately reflects the developer’s time, expertise, and value. - ` -; +const DEFAULT_SYSTEM_PROMPT = PROMPT.DEFAULT_SYSTEM_PROMPT; export interface AgentMessageReceived { type: "assistant.message" | "tool.execution_start" | "tool.execution_complete" | "session.idle"; diff --git a/src/agent/providers/openai.provider.ts b/src/agent/providers/openai.provider.ts new file mode 100644 index 0000000..5b88cca --- /dev/null +++ b/src/agent/providers/openai.provider.ts @@ -0,0 +1,119 @@ +// src/agent/providers/openai.provider.ts + +import OpenAI from "openai"; +import { EventEmitter } from "events"; +import type { AIProvider, AgentMessageReceived } from "./types.js"; +import { resolveSettings } from "../../lib/settings.js"; +// Note: We will need to adapt tools for OpenAI later +import { currencyConversionTool, servicePricingLookupTool } from "../tools.js"; +import { PROMPT } from "./constants.js"; + +const DEBUG_MODE = process.env.DEBUG_MODE === "true"; + +const DEFAULT_SYSTEM_PROMPT = PROMPT.DEFAULT_SYSTEM_PROMPT; + +/** + * OpenAI implementation of the AIProvider interface + */ +export class OpenAIProvider extends EventEmitter implements AIProvider { + readonly id = "openai"; + readonly name = "OpenAI"; + + private client: OpenAI | null = null; + private messageHistory: OpenAI.Chat.ChatCompletionMessageParam[] = []; + + constructor() { + super(); + } + + // Implementation of AIProvider.onMessage + onMessage(callback: (content: string) => void): void { + this.on("message", (data: AgentMessageReceived) => { + if (data.type === "assistant.message") { + callback(data.content); + } + }); + } + + async initialize(): Promise { + const settings = await resolveSettings(); + const apiKey = (settings as any).openaiApiKey || process.env.OPENAI_API_KEY; + + if (!apiKey) { + throw new Error( + "OpenAI API key is missing.\n\n" + + " Run '/settings' in the CLI to configure it." + ); + } + + this.client = new OpenAI({ apiKey }); + } + + async startSession( + brief: string, + options?: { skipInitialMessage?: boolean } + ): Promise { + try { + if (!this.client) await this.initialize(); + + const settings = await resolveSettings(); + const promptToUse = settings.systemPrompt?.trim() ? settings.systemPrompt : DEFAULT_SYSTEM_PROMPT; + + // Initialize the conversation history with the system prompt + this.messageHistory = [ + { role: "system", content: promptToUse } + ]; + + if (!options?.skipInitialMessage) { + await this.sendMessage(`Here is the brief for the quote:\n\n${brief}`); + } + } catch (error) { + console.log("\n❌ Failed to start OpenAI session.\n"); + throw error; + } + } + + async sendMessage(message: string): Promise { + if (!this.client) throw new Error("Client not initialized"); + + // Add user message to history + this.messageHistory.push({ role: "user", content: message }); + + try { + // NOTE: We will need to inject tools here in the next steps + const stream = await this.client.chat.completions.create({ + model: "gpt-5.2-chat-latest", + messages: this.messageHistory, + stream: true, + }); + + let fullResponse = ""; + + for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content || ""; + if (content) { + fullResponse += content; + } + } + + // Add assistant response to history + this.messageHistory.push({ role: "assistant", content: fullResponse }); + + // Emit the full response once the stream is complete + this.emit("message", { + type: "assistant.message", + content: fullResponse + } as AgentMessageReceived); + + } catch (error) { + if (DEBUG_MODE) console.error("[openai] Error generating response:", error); + this.emit("error", new Error("Failed to communicate with OpenAI")); + } + } + + async endSession(): Promise { + // OpenAI doesn't have a persistent connection to destroy like Copilot does + this.messageHistory = []; + this.emit("ended"); + } +} \ No newline at end of file diff --git a/src/agent/providers/types.ts b/src/agent/providers/types.ts index 2fd287d..8cda298 100644 --- a/src/agent/providers/types.ts +++ b/src/agent/providers/types.ts @@ -36,4 +36,14 @@ export interface AIProvider { * @param callback Function to call with each new message content */ onMessage(callback: (content: string) => void): void; +} + +/* + * Standardized message format for incoming messages from providers + * This can be extended in the future to include metadata, message types, etc. + * For now, it's a simple structure to allow the OpenAI provider to emit messages in a consistent way. +*/ +export interface AgentMessageReceived{ + type: string; + content: string; } \ No newline at end of file diff --git a/src/lib/settings.ts b/src/lib/settings.ts index c50959d..cf2c84f 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -4,6 +4,8 @@ import path from "path"; export interface SettingsStore { notionApiKey?: string | undefined; + openaiApiKey?: string | undefined; + selectedProvider?: string | undefined; notionPageId?: string | undefined; exchangeRateApiKey?: string | undefined; systemPrompt?: string | undefined; @@ -50,6 +52,8 @@ export async function updateSettings( notionPageId?: string | null; exchangeRateApiKey?: string | null; systemPrompt?: string | null; + openaiApiKey?: string | undefined; + selectedProvider?: string | undefined; } ): Promise { const existing = await loadSettings(); @@ -63,12 +67,18 @@ export async function updateSettings( : partial.exchangeRateApiKey ?? existing.exchangeRateApiKey; const nextSystemPrompt = partial.systemPrompt === null ? undefined : partial.systemPrompt ?? existing.systemPrompt; + const nextOpenAIApiKey = + partial.openaiApiKey === null ? undefined : partial.openaiApiKey ?? existing.openaiApiKey; + const nextSelectedProvider = + partial.selectedProvider === null ? undefined : partial.selectedProvider ?? existing.selectedProvider; const merged: SettingsStore = { ...(nextNotionApiKey !== undefined ? { notionApiKey: nextNotionApiKey } : {}), ...(nextNotionPageId !== undefined ? { notionPageId: nextNotionPageId } : {}), ...(nextExchangeRateApiKey !== undefined ? { exchangeRateApiKey: nextExchangeRateApiKey } : {}), ...(nextSystemPrompt !== undefined ? { systemPrompt: nextSystemPrompt } : {}), + ...(nextOpenAIApiKey !== undefined ? { openaiApiKey: nextOpenAIApiKey } : {}), + ...(nextSelectedProvider !== undefined ? { selectedProvider: nextSelectedProvider } : {}), }; await saveSettings(merged); return merged; @@ -79,6 +89,8 @@ export async function resolveSettings(): Promise<{ notionPageId?: string; exchangeRateApiKey?: string; systemPrompt?: string; + openaiApiKey?: string | undefined; + selectedProvider?: string | undefined; }> { const settings = await loadSettings(); return { @@ -86,5 +98,7 @@ export async function resolveSettings(): Promise<{ notionPageId: settings.notionPageId ?? (process.env.NOTION_PAGE_ID as string), exchangeRateApiKey: settings.exchangeRateApiKey ?? (process.env.EXCHANGE_RATE_API_KEY as string), systemPrompt: settings.systemPrompt ?? "", + openaiApiKey: settings.openaiApiKey ?? (process.env.OPENAI_API_KEY as string), + selectedProvider: settings.selectedProvider ?? "github-copilot", }; } From fc33b25724e43e36e9279d8b79ca0b77f82e932e Mon Sep 17 00:00:00 2001 From: Artur <50996474+ArturCharylo@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:08:34 +0100 Subject: [PATCH 4/8] feat: refactored tools.ts to have more specific functions and use them for all providers as needed --- src/agent/providers/openai.provider.ts | 4 + src/agent/tools.ts | 209 ++++++++++++++----------- 2 files changed, 118 insertions(+), 95 deletions(-) diff --git a/src/agent/providers/openai.provider.ts b/src/agent/providers/openai.provider.ts index 5b88cca..6d7a8ce 100644 --- a/src/agent/providers/openai.provider.ts +++ b/src/agent/providers/openai.provider.ts @@ -22,6 +22,8 @@ export class OpenAIProvider extends EventEmitter implements AIProvider { private client: OpenAI | null = null; private messageHistory: OpenAI.Chat.ChatCompletionMessageParam[] = []; + public session = new EventEmitter(); + constructor() { super(); } @@ -105,6 +107,8 @@ export class OpenAIProvider extends EventEmitter implements AIProvider { content: fullResponse } as AgentMessageReceived); + this.session.emit("session.idle", {type: "session.idle"}); + } catch (error) { if (DEBUG_MODE) console.error("[openai] Error generating response:", error); this.emit("error", new Error("Failed to communicate with OpenAI")); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index afee528..c528c5c 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -56,129 +56,148 @@ function extractNotionTextContent(notionResponse: any): string { return textParts.join("").trim(); } -export const servicePricingLookupTool = defineTool("servicePricingLookupTool", { - description: "Retreive the service pricing list from Notion in the form of plain text.", - parameters: { - type: "object", - properties: {}, - required: [], - }, - handler: async (args): Promise => { +export async function fetchPricingFromNotion(): Promise { + if (DEBUG_MODE) { + console.log('[fetchPricingFromNotion] invoked'); + } + + const { notionApiKey, notionPageId } = await resolveSettings(); + + if (!notionPageId || !notionApiKey) { + const msg = "Notion credentials are not configured."; + if (DEBUG_MODE) console.warn("[fetchPricingFromNotion]", msg); + throw new Error(msg); + } + + const apiUrl = `https://api.notion.com/v1/blocks/${notionPageId}/children`; + const headers = { + Authorization: `Bearer ${notionApiKey}`, + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + }; + try { + if (DEBUG_MODE) { + console.log(`[fetchPricingFromNotion] fetching Notion blocks for page ${notionPageId}`); + } + const resp = await axios.get(apiUrl, { headers }); if (DEBUG_MODE) { - console.log('[servicePricingLookupTool] invoked with args:', args); + console.log(`[fetchPricingFromNotion] response status ${resp.status}`); + } + return extractNotionTextContent(resp.data); + } catch (error) { + if (DEBUG_MODE) { + const status = axios.isAxiosError(error) && error.response ? error.response.status : "n/a"; + const body = axios.isAxiosError(error) && error.response ? JSON.stringify(error.response.data) : String(error); + console.error("[fetchPricingFromNotion] error fetching Notion data:", status, body); } + throw error; + } +} - const { notionApiKey: NOTION_API_KEY, notionPageId: NOTION_PAGE_ID } = await resolveSettings(); +export async function convertCurrency(amount: number, fromCurrency: string, toCurrency: string): Promise { + if (DEBUG_MODE) { + console.log(`[convertCurrency] invoked with amount: ${amount}, from: ${fromCurrency}, to: ${toCurrency}`); + } - if (!NOTION_PAGE_ID || !NOTION_API_KEY) { - const msg = "Notion credentials (NOTION_PAGE_ID / NOTION_API_KEY) are not configured in the environment."; - if (DEBUG_MODE) { - console.warn("[servicePricingLookupTool]", msg); - } - return { textResultForLlm: msg, resultType: "failure" }; - } + const { exchangeRateApiKey } = await resolveSettings(); + + if (!exchangeRateApiKey) { + const msg = "EXCHANGE_RATE_API_KEY is not configured."; + if (DEBUG_MODE) console.warn("[convertCurrency]", msg); + throw new Error(msg); + } - const apiUrl = `https://api.notion.com/v1/blocks/${NOTION_PAGE_ID}/children`; - const headers = { - Authorization: `Bearer ${NOTION_API_KEY}`, - "Content-Type": "application/json", - "Notion-Version": "2022-06-28", - }; + const from = fromCurrency.toUpperCase(); + const to = toCurrency.toUpperCase(); + const apiUrl = `https://v6.exchangerate-api.com/v6/${exchangeRateApiKey}/pair/${from}/${to}`; + + try { + const resp = await axios.get(apiUrl); + const data = resp.data; - try { - if (DEBUG_MODE) { - console.log(`[servicePricingLookupTool] fetching Notion blocks for page ${NOTION_PAGE_ID}`); - } - const resp = await axios.get(apiUrl, { headers }); - if (DEBUG_MODE) { - console.log(`[servicePricingLookupTool] response status ${resp.status}`); - } - const data = resp.data; + if (!data || data.result !== "success" || typeof data.conversion_rate !== "number") { + throw new Error(`Exchange rate API error: ${JSON.stringify(data)}`); + } + + const convertedAmount = amount * data.conversion_rate; + + if (DEBUG_MODE) { + console.log(`[convertCurrency] conversion successful: ${convertedAmount}`); + } + + return `Converted amount: ${convertedAmount.toFixed(2)} ${to}`; + } catch (error) { + if (DEBUG_MODE) { + console.error("[convertCurrency] error fetching exchange rate data:", error); + } + throw error; + } +} - // Extract clean text content from the Notion response - const textContent = extractNotionTextContent(data); +// --- COPILOT SDK TOOLS --- +export const servicePricingLookupTool = defineTool("servicePricingLookupTool", { + description: "Retreive the service pricing list from Notion in the form of plain text.", + parameters: { type: "object", properties: {}, required: [] }, + handler: async (): Promise => { + try { + const textContent = await fetchPricingFromNotion(); return { textResultForLlm: `Tom Shaw's Pricing Information:\n\n${textContent}`, resultType: "success", }; } catch (error) { - const status = axios.isAxiosError(error) && error.response ? error.response.status : "n/a"; - const body = axios.isAxiosError(error) && error.response ? JSON.stringify(error.response.data) : String(error); - if (DEBUG_MODE) { - console.error("[servicePricingLookupTool] error fetching Notion data:", status, body); - } - return { - textResultForLlm: `Failed to fetch pricing data from Notion: ${status} - ${body}`, - resultType: "failure", - }; + return { textResultForLlm: String(error), resultType: "failure" }; } }, }); -export const currencyConversionTool = defineTool<{ - amount: number; - fromCurrency: string; - toCurrency: string; -}>("convert_currency", { +export const currencyConversionTool = defineTool<{amount: number; fromCurrency: string; toCurrency: string;}>("convert_currency", { description: "Convert an amount from one currency to another.", parameters: { type: "object", properties: { - amount: { - type: "number", - description: "The amount of money to convert.", - }, - fromCurrency: { - type: "string", - description: "The currency code to convert from (e.g., USD).", - }, - toCurrency: { - type: "string", - description: "The currency code to convert to (e.g., EUR).", - }, + amount: { type: "number", description: "The amount of money to convert." }, + fromCurrency: { type: "string", description: "The currency code to convert from (e.g., USD)." }, + toCurrency: { type: "string", description: "The currency code to convert to (e.g., EUR)." }, }, required: ["amount", "fromCurrency", "toCurrency"], }, handler: async (args): Promise => { - const { amount, fromCurrency, toCurrency } = args; - const { exchangeRateApiKey } = await resolveSettings(); - - if (!exchangeRateApiKey) { - return { - textResultForLlm: - "EXCHANGE_RATE_API_KEY is not configured. Run /settings to add it.", - resultType: "failure", - }; - } - - const from = fromCurrency.toUpperCase(); - const to = toCurrency.toUpperCase(); - try { - const apiUrl = `https://v6.exchangerate-api.com/v6/${exchangeRateApiKey}/pair/${from}/${to}`; - const resp = await axios.get(apiUrl); - const data = resp.data; - - if (!data || data.result !== "success" || typeof data.conversion_rate !== "number") { - return { - textResultForLlm: `Exchange rate API error: ${JSON.stringify(data)}`, - resultType: "failure", - }; - } - - const convertedAmount = amount * data.conversion_rate; - - return { - textResultForLlm: `Converted amount: ${convertedAmount.toFixed(2)} ${to}`, - resultType: "success", - }; + const result = await convertCurrency(args.amount, args.fromCurrency, args.toCurrency); + return { textResultForLlm: result, resultType: "success" }; } catch (error) { - return { - textResultForLlm: `Error during currency conversion: ${String(error)}`, - resultType: "failure", - }; + return { textResultForLlm: String(error), resultType: "failure" }; } }, }); + +// --- OPENAI TOOLS --- + +export const openaiToolsDefinitions: import("openai").OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "servicePricingLookupTool", + description: "Retreive the service pricing list from Notion in the form of plain text.", + } + }, + { + type: "function", + function: { + name: "convert_currency", + description: "Convert an amount from one currency to another.", + parameters: { + type: "object", + properties: { + amount: { type: "number", description: "The amount of money to convert." }, + fromCurrency: { type: "string", description: "The currency code to convert from (e.g., USD)." }, + toCurrency: { type: "string", description: "The currency code to convert to (e.g., EUR)." }, + }, + required: ["amount", "fromCurrency", "toCurrency"], + } + } + } +]; \ No newline at end of file From eba49acbe45d0f3f42b3a7a7b281ed210d4900ba Mon Sep 17 00:00:00 2001 From: Artur <50996474+ArturCharylo@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:25:14 +0100 Subject: [PATCH 5/8] feat: added new tools to openai.provider.ts and updated index.ts to use new logic for multiple providers --- src/agent/providers/openai.provider.ts | 114 ++++++++++++++++++++----- src/index.ts | 113 ++++++++++++------------ 2 files changed, 149 insertions(+), 78 deletions(-) diff --git a/src/agent/providers/openai.provider.ts b/src/agent/providers/openai.provider.ts index 6d7a8ce..75d6ef8 100644 --- a/src/agent/providers/openai.provider.ts +++ b/src/agent/providers/openai.provider.ts @@ -4,8 +4,7 @@ import OpenAI from "openai"; import { EventEmitter } from "events"; import type { AIProvider, AgentMessageReceived } from "./types.js"; import { resolveSettings } from "../../lib/settings.js"; -// Note: We will need to adapt tools for OpenAI later -import { currencyConversionTool, servicePricingLookupTool } from "../tools.js"; +import { fetchPricingFromNotion, convertCurrency, openaiToolsDefinitions } from "../tools.js"; import { PROMPT } from "./constants.js"; const DEBUG_MODE = process.env.DEBUG_MODE === "true"; @@ -82,41 +81,110 @@ export class OpenAIProvider extends EventEmitter implements AIProvider { this.messageHistory.push({ role: "user", content: message }); try { - // NOTE: We will need to inject tools here in the next steps - const stream = await this.client.chat.completions.create({ - model: "gpt-5.2-chat-latest", - messages: this.messageHistory, - stream: true, - }); + await this.processChatCompletion(); + + // Signal to the CLI collector that the agent has finished processing + this.session.emit("session.idle", { type: "session.idle" }); + } catch (error) { + if (DEBUG_MODE) console.error("[openai] Error generating response:", error); + this.emit("error", new Error("Failed to communicate with OpenAI")); + } + } - let fullResponse = ""; + /** + * Handles the core interaction with OpenAI, including tool calls execution + */ + private async processChatCompletion(): Promise { + if (!this.client) return; + + const stream = await this.client.chat.completions.create({ + model: "gpt-5.2-chat-latest", + messages: this.messageHistory, + tools: openaiToolsDefinitions, + stream: true, + }); - for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content || ""; - if (content) { - fullResponse += content; + let fullResponse = ""; + const toolCalls: any[] = []; + + // Accumulate chunks from the stream + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta; + + if (delta?.content) { + fullResponse += delta.content; + } + + // Reconstruct tool calls from streaming chunks + if (delta?.tool_calls) { + for (const toolCall of delta.tool_calls) { + const index = toolCall.index; + if (!toolCalls[index]) { + toolCalls[index] = { + id: toolCall.id, + type: "function", + function: { name: toolCall.function?.name || "", arguments: "" } + }; + } + if (toolCall.function?.arguments) { + toolCalls[index].function.arguments += toolCall.function.arguments; + } } } + } - // Add assistant response to history - this.messageHistory.push({ role: "assistant", content: fullResponse }); + // Handle tool execution if the model requested it + if (toolCalls.length > 0) { + if (DEBUG_MODE) console.log(`[openai] Intercepted ${toolCalls.length} tool calls`); + + // Append the assistant's tool call request to history + this.messageHistory.push({ + role: "assistant", + content: fullResponse || null, + tool_calls: toolCalls + }); + + // Execute each requested tool sequentially + for (const tc of toolCalls) { + let toolResult = ""; + + try { + if (tc.function.name === "servicePricingLookupTool") { + toolResult = await fetchPricingFromNotion(); + } else if (tc.function.name === "convert_currency") { + const args = JSON.parse(tc.function.arguments); + toolResult = await convertCurrency(args.amount, args.fromCurrency, args.toCurrency); + } else { + toolResult = `Error: Unknown function ${tc.function.name}`; + } + } catch (err: any) { + toolResult = `Error executing tool: ${err.message}`; + } + + // Append the tool's result to history + this.messageHistory.push({ + role: "tool", + tool_call_id: tc.id, + content: toolResult + }); + } + + // Recursively call the API with the new context containing tool results + return this.processChatCompletion(); + } - // Emit the full response once the stream is complete + // If no tools were called, it's a standard text response + if (fullResponse) { + this.messageHistory.push({ role: "assistant", content: fullResponse }); + this.emit("message", { type: "assistant.message", content: fullResponse } as AgentMessageReceived); - - this.session.emit("session.idle", {type: "session.idle"}); - - } catch (error) { - if (DEBUG_MODE) console.error("[openai] Error generating response:", error); - this.emit("error", new Error("Failed to communicate with OpenAI")); } } async endSession(): Promise { - // OpenAI doesn't have a persistent connection to destroy like Copilot does this.messageHistory = []; this.emit("ended"); } diff --git a/src/index.ts b/src/index.ts index e000da7..03e57d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ - import * as readline from "readline"; import { displayHeader } from "./ui/header.js"; -import { CopilotProvider, checkAuth } from "./agent/providers/copilot.provider.js"; +import { CopilotProvider } from "./agent/providers/copilot.provider.js"; +import { OpenAIProvider } from "./agent/providers/openai.provider.js"; import type { AIProvider } from "./agent/providers/types.js"; import { displayMenu } from "./ui/menu.js"; import { @@ -12,16 +12,25 @@ import { type StoredMessage, type StoredQuote, } from "./lib/storage.js"; -import { loadSettings, updateSettings } from "./lib/settings.js"; +import { loadSettings, updateSettings, resolveSettings } from "./lib/settings.js"; import { createSpinner } from "./ui/spinner.js"; import { exit } from "process"; const DEBUG_MODE = process.env.DEBUG_MODE === "true"; const MAX_HISTORY_MESSAGES = 20; +// Helper: Factory function to instantiate the correct provider based on settings +async function getAgent(): Promise { + const settings = await resolveSettings(); + if (settings.selectedProvider === "openai") { + return new OpenAIProvider(); + } + return new CopilotProvider(); +} + // Helper: collect multiple agent 'message' events until session goes idle const collectAgentMessages = ( - agent: any, + agent: AIProvider, onMessage: (m: any, first: boolean) => void, maxWaitMs = 30000 // Maximum wait time ): Promise => { @@ -36,45 +45,40 @@ const collectAgentMessages = ( let accumulatedContent = ""; let cleanup = () => { - try { - agent.removeListener("message", messageHandler); - // Note: We don't remove the session event listener as it's managed by the agent - } catch (e) { - /* ignore */ - } + // We rely on the unified onMessage method from AIProvider interface if (maxWaitTimer) clearTimeout(maxWaitTimer); }; - const messageHandler = (m: any) => { + const messageHandler = (content: string) => { if (DEBUG_MODE) { console.log("[collector] received agent message event"); } // Handle streaming content (accumulate deltas) - if (m.content) { + if (content) { // If this looks like a delta (short content), accumulate it - if (m.content.length < 50 && !m.content.includes("\n")) { - accumulatedContent += m.content; + if (content.length < 50 && !content.includes("\n")) { + accumulatedContent += content; return; // Don't emit individual deltas } else { // This is a complete message or we have accumulated content if (accumulatedContent) { - m.content = accumulatedContent + m.content; + content = accumulatedContent + content; accumulatedContent = ""; } - onMessage(m, first); + onMessage({ content }, first); first = false; hasContent = true; } } }; - // Listen for session idle event through the agent's session - const originalSession = agent.session; - if (originalSession) { + // Listen for session idle event through the agent's session emitter if available + const originalSession = (agent as any).session; + if (originalSession && originalSession.on) { const sessionIdleHandler = (event: any) => { - if (event.type === "session.idle") { + if (event.type === "session.idle" || event === "session.idle") { if (DEBUG_MODE) { console.log("[collector] session idle detected, finishing collection"); } @@ -89,15 +93,13 @@ const collectAgentMessages = ( } }; - // Listen to the raw session events - originalSession.on(sessionIdleHandler); + originalSession.on("session.idle", sessionIdleHandler); - // Clean up session listener too const originalCleanup = cleanup; cleanup = () => { originalCleanup(); try { - originalSession.off?.(sessionIdleHandler); + originalSession.removeListener("session.idle", sessionIdleHandler); } catch (e) { /* ignore */ } @@ -123,7 +125,8 @@ const collectAgentMessages = ( } }, maxWaitMs); - agent.on("message", messageHandler); + // Register our callback using the AIProvider interface method + agent.onMessage(messageHandler); }); }; @@ -175,33 +178,35 @@ async function settingsFlow(mainRl: readline.Interface): Promise { mainLineListeners.forEach((l) => mainRl.removeListener("line", l as any)); try { - const settings = await loadSettings(); + const settings = await resolveSettings(); console.log("\n🔧 Settings\n"); + console.log(`Current SELECTED_PROVIDER: ${settings.selectedProvider || "not set"}`); console.log(`Current NOTION_API_KEY: ${maskValue(settings.notionApiKey)}`); console.log(`Current NOTION_PAGE_ID: ${maskValue(settings.notionPageId)}`); console.log(`Current EXCHANGE_RATE_API_KEY: ${maskValue(settings.exchangeRateApiKey)}`); + console.log(`Current OPENAI_API_KEY: ${maskValue(settings.openaiApiKey)}`); console.log("\nEnter a value to update, press Enter to keep, or type 'clear' to remove.\n"); mainRl.resume(); + const providerInput = await promptQuestion(mainRl, "SELECTED_PROVIDER (copilot/openai): "); const notionApiKeyInput = await promptQuestion(mainRl, "NOTION_API_KEY: "); const notionPageIdInput = await promptQuestion(mainRl, "NOTION_PAGE_ID: "); const exchangeRateApiKeyInput = await promptQuestion(mainRl, "EXCHANGE_RATE_API_KEY: "); + const openaiApiKeyInput = await promptQuestion(mainRl, "OPENAI_API_KEY: "); + const selectedProvider = parseSettingsInput(providerInput); const notionApiKey = parseSettingsInput(notionApiKeyInput); const notionPageId = parseSettingsInput(notionPageIdInput); const exchangeRateApiKey = parseSettingsInput(exchangeRateApiKeyInput); + const openaiApiKey = parseSettingsInput(openaiApiKeyInput); - let newSettings = {}; + let newSettings: any = {}; - if (notionApiKey !== undefined) { - newSettings = { ...newSettings, notionApiKey: notionApiKey }; - } - if (notionPageId !== undefined) { - newSettings = { ...newSettings, notionPageId: notionPageId }; - } - if (exchangeRateApiKey !== undefined) { - newSettings = { ...newSettings, exchangeRateApiKey: exchangeRateApiKey }; - } + if (selectedProvider !== undefined) newSettings.selectedProvider = selectedProvider; + if (notionApiKey !== undefined) newSettings.notionApiKey = notionApiKey; + if (notionPageId !== undefined) newSettings.notionPageId = notionPageId; + if (exchangeRateApiKey !== undefined) newSettings.exchangeRateApiKey = exchangeRateApiKey; + if (openaiApiKey !== undefined) newSettings.openaiApiKey = openaiApiKey; await updateSettings(newSettings); @@ -209,9 +214,11 @@ async function settingsFlow(mainRl: readline.Interface): Promise { input === undefined ? "unchanged" : input === null ? "cleared" : "updated"; console.log("\n✅ Settings saved."); + console.log(`SELECTED_PROVIDER: ${status(selectedProvider)}`); console.log(`NOTION_API_KEY: ${status(notionApiKey)}`); console.log(`NOTION_PAGE_ID: ${status(notionPageId)}`); - console.log(`EXCHANGE_RATE_API_KEY: ${status(exchangeRateApiKey)}\n`); + console.log(`EXCHANGE_RATE_API_KEY: ${status(exchangeRateApiKey)}`); + console.log(`OPENAI_API_KEY: ${status(openaiApiKey)}\n`); } catch (error) { console.log("\n❌ Failed to update settings.\n"); if (DEBUG_MODE) console.error(error); @@ -310,8 +317,8 @@ async function createQuoteFlow(brief: string, mainRl: readline.Interface): Promi mainLineListeners.forEach((l) => mainRl.removeListener("line", l as any)); mainRl.pause(); - // init the chatbot session here - const agent = new CopilotProvider(); + // Dynamically instantiate the selected agent + const agent = await getAgent(); const sessionId = generateSessionId(); const sessionCreatedAt = new Date().toISOString(); const storedMessages: StoredMessage[] = []; @@ -362,8 +369,6 @@ async function createQuoteFlow(brief: string, mainRl: readline.Interface): Promi if (input === "/close") { console.log("\n✅ Quote session complete! Saving...\n"); // Ask for final summary and wait for reply - // Show loading, then clear it when summary arrives - // Show inline loading and request a final summary, then collect messages process.stdout.write("\x1b[1m\x1b[35m🤖 Agent is responding...\x1b[0m"); await agent.sendMessage( "Please provide a final summary of the quote we discussed, formatted nicely." @@ -486,7 +491,7 @@ async function openQuoteFlow(session: StoredQuote, mainRl: readline.Interface): mainLineListeners.forEach((l) => mainRl.removeListener("line", l as any)); mainRl.pause(); - const agent: AIProvider = new CopilotProvider(); + const agent = await getAgent(); const storedMessages: StoredMessage[] = [...session.messages]; let finalSummary: string | undefined = session.finalSummary; @@ -716,29 +721,27 @@ async function runCli() { displayHeader(); - // Check that the user has access to Copilot and is logged in + // Dynamically retrieve the configured agent + const agent = await getAgent(); + const agentName = agent.name; // Show "checking auth message with spinner" - const spinner = createSpinner("Checking Copilot authentication..."); + const spinner = createSpinner(`Initializing ${agentName} provider...`); spinner.start(); - const authStatus = await checkAuth(); - - // Clear the checking auth line - process.stdout.write("\x1b[1A\x1b[2K"); - - if (!authStatus.isAuthenticated) { - spinner.stop("\n\x1b[1m\x1b[31m❌ You are not authenticated with GitHub Copilot. Please log in and try again."); + // Verify authentication and perform setup for the selected provider + try { + await agent.initialize(); + spinner.stop(`\n\x1b[1;94m✅ Successfully connected to ${agentName}! You can now use the Quote CLI.\x1b[0m\n`); + } catch (err: any) { + spinner.stop(`\n\x1b[1m\x1b[31m❌ Connection failed: ${err.message}\x1b[0m\n`); exit(1); } - spinner.stop(`\n\x1b[1;94m✅ Logged in as ${authStatus.login}! You can now use the Quote CLI.\x1b[0m\n`); // Show Menu - displayMenu(); // Set up readline interface for user input - const mainRl = readline.createInterface({ input: process.stdin, output: process.stdout, @@ -790,4 +793,4 @@ async function runCli() { } } -runCli(); +runCli(); \ No newline at end of file From 7bfb503a51c78cb3bd9d4cf3adae651bb6b9ba7b Mon Sep 17 00:00:00 2001 From: Artur <50996474+ArturCharylo@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:48:55 +0100 Subject: [PATCH 6/8] fix: updated how index.ts main loop handles errors to display only neccessary formatted info and be non blocking --- src/agent/providers/openai.provider.ts | 1 - src/index.ts | 222 +++++++++---------------- 2 files changed, 80 insertions(+), 143 deletions(-) diff --git a/src/agent/providers/openai.provider.ts b/src/agent/providers/openai.provider.ts index 75d6ef8..16398bd 100644 --- a/src/agent/providers/openai.provider.ts +++ b/src/agent/providers/openai.provider.ts @@ -69,7 +69,6 @@ export class OpenAIProvider extends EventEmitter implements AIProvider { await this.sendMessage(`Here is the brief for the quote:\n\n${brief}`); } } catch (error) { - console.log("\n❌ Failed to start OpenAI session.\n"); throw error; } } diff --git a/src/index.ts b/src/index.ts index 03e57d9..5a57235 100644 --- a/src/index.ts +++ b/src/index.ts @@ -299,152 +299,91 @@ async function handleCommand(input: string, mainRl: readline.Interface) { } async function createQuoteFlow(brief: string, mainRl: readline.Interface): Promise { + // Store listeners to restore them later + const mainLineListeners = mainRl.listeners("line").slice(); + try { - if (!brief || brief.trim() === "") { - console.log("❌ Please provide a brief for your quote. Usage: /create \n"); - return; - } + if (!brief || brief.trim() === "") { + console.log("❌ Please provide a brief for your quote. Usage: /create \n"); + return; + } - console.log("\n🤖 Starting Quote Assistant...\n"); - console.log("━".repeat(50)); - console.log("💼 Quote Brief:", brief.trim()); - console.log("━".repeat(50)); - console.log("\nType your messages to discuss the quote."); - console.log("Commands: /close - exit the chat session\n"); - - // Remove main `line` listeners to avoid duplicate handling - const mainLineListeners = mainRl.listeners("line").slice(); - mainLineListeners.forEach((l) => mainRl.removeListener("line", l as any)); - mainRl.pause(); - - // Dynamically instantiate the selected agent - const agent = await getAgent(); - const sessionId = generateSessionId(); - const sessionCreatedAt = new Date().toISOString(); - const storedMessages: StoredMessage[] = []; - let finalSummary: string | undefined; - - const recordUserMessage = (content: string) => { - storedMessages.push({ - role: "user", - content, - timestamp: new Date().toISOString(), - }); - }; + console.log("\n🤖 Starting Quote Assistant...\n"); + console.log("━".repeat(50)); + console.log("💼 Quote Brief:", brief.trim()); + console.log("━".repeat(50)); + console.log("\nType your messages to discuss the quote."); + console.log("Commands: /close - exit the chat session\n"); - const recordAssistantMessage = (content: string) => { - storedMessages.push({ - role: "assistant", - content, - timestamp: new Date().toISOString(), - }); - }; + // Remove main listeners to avoid duplicate handling + mainLineListeners.forEach((l) => mainRl.removeListener("line", l as any)); + mainRl.pause(); + + // 1. Initialize agent + const agent = await getAgent(); + const sessionId = generateSessionId(); + const sessionCreatedAt = new Date().toISOString(); + const storedMessages: StoredMessage[] = []; + let finalSummary: string | undefined; + // --- CRITICAL PART: Session Start --- + try { await agent.startSession(brief.trim()); + } catch (startError: any) { + // Catch specific initialization errors (like missing API keys) + // and throw them with a clean message to be handled by the outer catch + throw new Error(startError.message || "Failed to start agent session"); + } + // ------------------------------------ - // Show inline loading indicator and collect any messages the agent emits - process.stdout.write("\x1b[1m\x1b[35m🤖 Agent is responding...\x1b[0m"); - await collectAgentMessages(agent, (msg: any, first: boolean) => { - // Clear loading only when the first partial/complete message arrives - if (first) process.stdout.write("\r\x1b[2K"); - console.log(`\n\x1b[1m\x1b[35m🤖 Agent:\x1b[0m ${msg.content}\n`); - if (msg.content) { - recordAssistantMessage(msg.content); - } + const recordUserMessage = (content: string) => { + storedMessages.push({ + role: "user", + content, + timestamp: new Date().toISOString(), }); + }; - // Switch the main prompt to the quote sub-prompt and resume input - // Bold bright-blue `You` label; reset color so typed text stays default - mainRl.setPrompt("\x1b[1m\x1b[94m💻 You:\x1b[0m "); - mainRl.resume(); - mainRl.prompt(); - - // Remain in the chat session until user types /close - - try { - await new Promise((resolve) => { - const quoteHandler = async (line: string) => { - const input = line.trim(); - - if (input === "/close") { - console.log("\n✅ Quote session complete! Saving...\n"); - // Ask for final summary and wait for reply - process.stdout.write("\x1b[1m\x1b[35m🤖 Agent is responding...\x1b[0m"); - await agent.sendMessage( - "Please provide a final summary of the quote we discussed, formatted nicely." - ); - await collectAgentMessages(agent, (msg: any, first: boolean) => { - if (first) process.stdout.write("\r\x1b[2K"); - if (msg.content !== "") { - console.log(`\n\x1b[1m\x1b[35m🤖 Agent:\x1b[0m\n\n📋 Final Quote Summary:\n${msg.content}\n`); - recordAssistantMessage(msg.content); - finalSummary = msg.content; - } - }); - await agent.endSession(); - try { - const sessionRecord: StoredQuote = { - id: sessionId, - brief: brief.trim(), - createdAt: sessionCreatedAt, - messages: storedMessages, - ...(finalSummary ? { finalSummary } : {}), - }; - await appendQuoteSession(sessionRecord); - console.log("💾 Session saved to history.\n"); - } catch (error) { - console.log("\n⚠️ Failed to save session history.\n"); - if (DEBUG_MODE) console.error(error); - } - mainRl.removeListener("line", quoteHandler); - resolve(); - return; - } - - if (input === "") { - mainRl.prompt(); - return; - } - - // Pause input while waiting for the agent to respond - mainRl.pause(); - try { - // Show inline loading, send the user's message, then collect replies - process.stdout.write("\x1b[1m\x1b[35m🤖 Agent is responding...\x1b[0m"); - recordUserMessage(input); - await agent.sendMessage(input); - await collectAgentMessages(agent, (msg: any, first: boolean) => { - if (first) process.stdout.write("\r\x1b[2K"); - if (msg.content !== "") { - console.log(`\n\x1b[1m\x1b[35m🤖 Agent:\x1b[0m ${msg.content}\n`); - recordAssistantMessage(msg.content); - } - }); - } catch (error) { - console.log("\n❌ Error communicating with agent. Please try again.\n"); - } finally { - mainRl.resume(); - } - - mainRl.prompt(); - }; + const recordAssistantMessage = (content: string) => { + storedMessages.push({ + role: "assistant", + content, + timestamp: new Date().toISOString(), + }); + }; - mainRl.on("line", quoteHandler); - }); - } catch (error) { - console.log("\n❌ Error during quote chat session. Exiting session.\n"); - console.error(error); + // Show inline loading indicator + process.stdout.write("\x1b[1m\x1b[35m🤖 Agent is responding...\x1b[0m"); + await collectAgentMessages(agent, (msg: any, first: boolean) => { + if (first) process.stdout.write("\r\x1b[2K"); + console.log(`\n\x1b[1m\x1b[35m🤖 Agent:\x1b[0m ${msg.content}\n`); + if (msg.content) { + recordAssistantMessage(msg.content); } + }); - // Restore main listeners and prompt after session ends - mainLineListeners.forEach((l) => mainRl.on("line", l as any)); - mainRl.setPrompt("\x1b[1m\x1b[94m👀 Select function:\x1b[0m "); - mainRl.prompt(); + mainRl.setPrompt("\x1b[1m\x1b[94m💻 You:\x1b[0m "); + mainRl.resume(); + mainRl.prompt(); + // ... (Chat loop / Promise that handles /close logic) ... + // Note: Make sure the chat loop is also inside this main try block - } catch (error) { - console.log("\n❌ Failed to start quote session. Please check your connection.\n"); - console.error(error); + } catch (error: any) { + // 2. Clean Error Display + // This will show only the message without the full stack trace + console.log(`\n\x1b[1;31m❌ Error:\x1b[0m ${error.message || "An unexpected error occurred."}\n`); + + if (DEBUG_MODE) { + console.error(error); // Full stack trace only in debug mode + } + } finally { + // 3. Guaranteed Recovery + // This ensures that no matter what happened, the CLI returns to normal state + mainLineListeners.forEach((l) => mainRl.on("line", l as any)); + mainRl.setPrompt("\x1b[1m\x1b[94m👀 Select function:\x1b[0m "); + mainRl.prompt(); + mainRl.resume(); } } @@ -718,30 +657,29 @@ async function listQuotesFlow(mainRl: readline.Interface): Promise { async function runCli() { try { - displayHeader(); - // Dynamically retrieve the configured agent + // Get current settings to see who is the provider + const settings = await resolveSettings(); const agent = await getAgent(); const agentName = agent.name; - // Show "checking auth message with spinner" + // Initialize with a non-blocking approach const spinner = createSpinner(`Initializing ${agentName} provider...`); spinner.start(); - // Verify authentication and perform setup for the selected provider try { await agent.initialize(); - spinner.stop(`\n\x1b[1;94m✅ Successfully connected to ${agentName}! You can now use the Quote CLI.\x1b[0m\n`); + spinner.stop(`\n\x1b[1;94m✅ Connected to ${agentName}! Ready to work.\x1b[0m\n`); } catch (err: any) { - spinner.stop(`\n\x1b[1m\x1b[31m❌ Connection failed: ${err.message}\x1b[0m\n`); - exit(1); + // INSTEAD OF exit(1), we just show a warning + spinner.stop(`\n\x1b[1;33m⚠️ Warning: ${agentName} is not ready.\x1b[0m`); + console.log(`\x1b[90m(${err.message})\x1b[0m`); + console.log(`\x1b[36mPlease use /settings to configure your API keys.\x1b[0m\n`); } - // Show Menu displayMenu(); - // Set up readline interface for user input const mainRl = readline.createInterface({ input: process.stdin, output: process.stdout, From 437ac213a8445e34c0d40771d392bad70d6cad71 Mon Sep 17 00:00:00 2001 From: Artur <50996474+ArturCharylo@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:00:38 +0100 Subject: [PATCH 7/8] feat: add Anthropic support and improve UX --- package-lock.json | 49 +++++++ package.json | 1 + src/agent/providers/anthropic.provider.ts | 169 ++++++++++++++++++++++ src/index.ts | 16 +- src/lib/settings.ts | 7 + 5 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 src/agent/providers/anthropic.provider.ts diff --git a/package-lock.json b/package-lock.json index 99388a1..4ff5f94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@github/copilot-sdk": "^0.1.18", "axios": "^1.13.3", "openai": "^6.22.0" @@ -22,6 +23,35 @@ "typescript": "^5.9.3" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.78.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", + "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", + "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/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -857,6 +887,19 @@ "node": ">= 0.4" } }, + "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/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -919,6 +962,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "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/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/package.json b/package.json index a644e37..ef385a4 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@github/copilot-sdk": "^0.1.18", "axios": "^1.13.3", "openai": "^6.22.0" diff --git a/src/agent/providers/anthropic.provider.ts b/src/agent/providers/anthropic.provider.ts new file mode 100644 index 0000000..3457bed --- /dev/null +++ b/src/agent/providers/anthropic.provider.ts @@ -0,0 +1,169 @@ +// src/agent/providers/anthropic.provider.ts + +import Anthropic from "@anthropic-ai/sdk"; +import { EventEmitter } from "events"; +import type { AIProvider, AgentMessageReceived } from "./types.js"; +import { resolveSettings } from "../../lib/settings.js"; +import { fetchPricingFromNotion, convertCurrency } from "../tools.js"; +import { PROMPT } from "./constants.js"; + +const DEBUG_MODE = process.env.DEBUG_MODE === "true"; + +/** + * Anthropic Claude implementation of the AIProvider interface + */ +export class AnthropicProvider extends EventEmitter implements AIProvider { + readonly id = "anthropic"; + readonly name = "Anthropic Claude"; + + private client: Anthropic | null = null; + private messageHistory: Anthropic.Messages.MessageParam[] = []; + public session = new EventEmitter(); + + constructor() { + super(); + } + + onMessage(callback: (content: string) => void): void { + this.on("message", (data: AgentMessageReceived) => { + if (data.type === "assistant.message") { + callback(data.content); + } + }); + } + + async initialize(): Promise { + const settings = await resolveSettings(); + const apiKey = (settings as any).anthropicApiKey || process.env.ANTHROPIC_API_KEY; + + if (!apiKey) { + throw new Error( + "Anthropic API key is missing.\n\n" + + " Run '/settings' in the CLI to configure it." + ); + } + + this.client = new Anthropic({ apiKey }); + } + + async startSession(brief: string, options?: { skipInitialMessage?: boolean }): Promise { + try { + if (!this.client) await this.initialize(); + this.messageHistory = []; + + if (!options?.skipInitialMessage) { + await this.sendMessage(`Here is the brief for the quote:\n\n${brief}`); + } + } catch (error) { + throw error; + } + } + + async sendMessage(message: string): Promise { + if (!this.client) throw new Error("Client not initialized"); + + this.messageHistory.push({ role: "user", content: message }); + + try { + await this.processChat(); + this.session.emit("session.idle", { type: "session.idle" }); + } catch (error) { + if (DEBUG_MODE) console.error("[anthropic] Error:", error); + throw error; + } + } + + private async processChat(): Promise { + if (!this.client) return; + + const settings = await resolveSettings(); + const systemPrompt = settings.systemPrompt?.trim() ? settings.systemPrompt : PROMPT.DEFAULT_SYSTEM_PROMPT; + + // Define tools in Anthropic format + const anthropicTools: Anthropic.Messages.Tool[] = [ + { + name: "servicePricingLookupTool", + description: "Retrieve the service pricing list from Notion in the form of plain text.", + input_schema: { type: "object", properties: {} } + }, + { + name: "convert_currency", + description: "Convert an amount from one currency to another.", + input_schema: { + type: "object", + properties: { + amount: { type: "number" }, + fromCurrency: { type: "string" }, + toCurrency: { type: "string" } + }, + required: ["amount", "fromCurrency", "toCurrency"] + } + } + ]; + + const response = await this.client.messages.create({ + model: "claude-3-5-sonnet-20240620", + max_tokens: 4096, + system: systemPrompt, + messages: this.messageHistory, + tools: anthropicTools + }); + + const toolCalls = response.content.filter(c => c.type === "tool_use") as Anthropic.Messages.ToolUseBlock[]; + const textBlocks = response.content.filter(c => c.type === "text") as Anthropic.Messages.TextBlock[]; + + // Handle text response + if (textBlocks.length > 0) { + const fullText = textBlocks.map(t => t.text).join("\n"); + this.messageHistory.push({ role: "assistant", content: response.content }); + + this.emit("message", { + type: "assistant.message", + content: fullText + } as AgentMessageReceived); + } + + // Handle tool calls + if (toolCalls.length > 0) { + if (DEBUG_MODE) console.log(`[anthropic] Executing ${toolCalls.length} tools`); + + // If we only had tool calls and no text, we still need to add the assistant message to history + if (textBlocks.length === 0) { + this.messageHistory.push({ role: "assistant", content: response.content }); + } + + for (const tc of toolCalls) { + let result = ""; + try { + if (tc.name === "servicePricingLookupTool") { + result = await fetchPricingFromNotion(); + } else if (tc.name === "convert_currency") { + const { amount, fromCurrency, toCurrency } = tc.input as any; + result = await convertCurrency(amount, fromCurrency, toCurrency); + } + } catch (err: any) { + result = `Error: ${err.message}`; + } + + this.messageHistory.push({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: tc.id, + content: result + } + ] + }); + } + + // Recursive call to get the final response after tool results + return this.processChat(); + } + } + + async endSession(): Promise { + this.messageHistory = []; + this.emit("ended"); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5a57235..721895c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import * as readline from "readline"; import { displayHeader } from "./ui/header.js"; import { CopilotProvider } from "./agent/providers/copilot.provider.js"; import { OpenAIProvider } from "./agent/providers/openai.provider.js"; +import { AnthropicProvider } from "./agent/providers/anthropic.provider.js"; import type { AIProvider } from "./agent/providers/types.js"; import { displayMenu } from "./ui/menu.js"; import { @@ -22,10 +23,11 @@ const MAX_HISTORY_MESSAGES = 20; // Helper: Factory function to instantiate the correct provider based on settings async function getAgent(): Promise { const settings = await resolveSettings(); - if (settings.selectedProvider === "openai") { - return new OpenAIProvider(); + switch (settings.selectedProvider) { + case "openai": return new OpenAIProvider(); + case "anthropic": return new AnthropicProvider(); // <-- DODANE + default: return new CopilotProvider(); } - return new CopilotProvider(); } // Helper: collect multiple agent 'message' events until session goes idle @@ -188,17 +190,19 @@ async function settingsFlow(mainRl: readline.Interface): Promise { console.log("\nEnter a value to update, press Enter to keep, or type 'clear' to remove.\n"); mainRl.resume(); - const providerInput = await promptQuestion(mainRl, "SELECTED_PROVIDER (copilot/openai): "); + const providerInput = await promptQuestion(mainRl, "SELECTED_PROVIDER (copilot/openai/anthropic): "); const notionApiKeyInput = await promptQuestion(mainRl, "NOTION_API_KEY: "); const notionPageIdInput = await promptQuestion(mainRl, "NOTION_PAGE_ID: "); const exchangeRateApiKeyInput = await promptQuestion(mainRl, "EXCHANGE_RATE_API_KEY: "); const openaiApiKeyInput = await promptQuestion(mainRl, "OPENAI_API_KEY: "); + const anthropicApiKeyInput = await promptQuestion(mainRl, "ANTHROPIC_API_KEY: "); const selectedProvider = parseSettingsInput(providerInput); const notionApiKey = parseSettingsInput(notionApiKeyInput); const notionPageId = parseSettingsInput(notionPageIdInput); const exchangeRateApiKey = parseSettingsInput(exchangeRateApiKeyInput); const openaiApiKey = parseSettingsInput(openaiApiKeyInput); + const anthropicApiKey = parseSettingsInput(anthropicApiKeyInput); let newSettings: any = {}; @@ -207,6 +211,7 @@ async function settingsFlow(mainRl: readline.Interface): Promise { if (notionPageId !== undefined) newSettings.notionPageId = notionPageId; if (exchangeRateApiKey !== undefined) newSettings.exchangeRateApiKey = exchangeRateApiKey; if (openaiApiKey !== undefined) newSettings.openaiApiKey = openaiApiKey; + if (anthropicApiKey !== undefined) newSettings.anthropicApiKey = anthropicApiKey; await updateSettings(newSettings); @@ -218,7 +223,8 @@ async function settingsFlow(mainRl: readline.Interface): Promise { console.log(`NOTION_API_KEY: ${status(notionApiKey)}`); console.log(`NOTION_PAGE_ID: ${status(notionPageId)}`); console.log(`EXCHANGE_RATE_API_KEY: ${status(exchangeRateApiKey)}`); - console.log(`OPENAI_API_KEY: ${status(openaiApiKey)}\n`); + console.log(`OPENAI_API_KEY: ${status(openaiApiKey)}`); + console.log(`ANTHROPIC_API_KEY: ${status(anthropicApiKey)}`); } catch (error) { console.log("\n❌ Failed to update settings.\n"); if (DEBUG_MODE) console.error(error); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index cf2c84f..2212f48 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -5,6 +5,7 @@ import path from "path"; export interface SettingsStore { notionApiKey?: string | undefined; openaiApiKey?: string | undefined; + anthropicApiKey?: string | undefined; selectedProvider?: string | undefined; notionPageId?: string | undefined; exchangeRateApiKey?: string | undefined; @@ -53,6 +54,7 @@ export async function updateSettings( exchangeRateApiKey?: string | null; systemPrompt?: string | null; openaiApiKey?: string | undefined; + anthropicApiKey?: string | undefined; selectedProvider?: string | undefined; } ): Promise { @@ -69,6 +71,8 @@ export async function updateSettings( partial.systemPrompt === null ? undefined : partial.systemPrompt ?? existing.systemPrompt; const nextOpenAIApiKey = partial.openaiApiKey === null ? undefined : partial.openaiApiKey ?? existing.openaiApiKey; + const nextAnthropicApiKey = + partial.anthropicApiKey === null ? undefined : partial.anthropicApiKey ?? existing.anthropicApiKey; const nextSelectedProvider = partial.selectedProvider === null ? undefined : partial.selectedProvider ?? existing.selectedProvider; @@ -78,6 +82,7 @@ export async function updateSettings( ...(nextExchangeRateApiKey !== undefined ? { exchangeRateApiKey: nextExchangeRateApiKey } : {}), ...(nextSystemPrompt !== undefined ? { systemPrompt: nextSystemPrompt } : {}), ...(nextOpenAIApiKey !== undefined ? { openaiApiKey: nextOpenAIApiKey } : {}), + ...(nextAnthropicApiKey !== undefined ? { anthropicApiKey: nextAnthropicApiKey } : {}), ...(nextSelectedProvider !== undefined ? { selectedProvider: nextSelectedProvider } : {}), }; await saveSettings(merged); @@ -90,6 +95,7 @@ export async function resolveSettings(): Promise<{ exchangeRateApiKey?: string; systemPrompt?: string; openaiApiKey?: string | undefined; + anthropicApiKey?: string | undefined; selectedProvider?: string | undefined; }> { const settings = await loadSettings(); @@ -99,6 +105,7 @@ export async function resolveSettings(): Promise<{ exchangeRateApiKey: settings.exchangeRateApiKey ?? (process.env.EXCHANGE_RATE_API_KEY as string), systemPrompt: settings.systemPrompt ?? "", openaiApiKey: settings.openaiApiKey ?? (process.env.OPENAI_API_KEY as string), + anthropicApiKey: settings.anthropicApiKey ?? (process.env.ANTHROPIC_API_KEY as string), selectedProvider: settings.selectedProvider ?? "github-copilot", }; } From ef097ab5e840de13a57a71179149fd456ed9de53 Mon Sep 17 00:00:00 2001 From: Artur <50996474+ArturCharylo@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:04:40 +0100 Subject: [PATCH 8/8] feat: use Claude 4.6 in Anthropic provider --- src/agent/providers/anthropic.provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent/providers/anthropic.provider.ts b/src/agent/providers/anthropic.provider.ts index 3457bed..e53081a 100644 --- a/src/agent/providers/anthropic.provider.ts +++ b/src/agent/providers/anthropic.provider.ts @@ -102,7 +102,7 @@ export class AnthropicProvider extends EventEmitter implements AIProvider { ]; const response = await this.client.messages.create({ - model: "claude-3-5-sonnet-20240620", + model: "claude-4.6-20240924", max_tokens: 4096, system: systemPrompt, messages: this.messageHistory,