diff --git a/package-lock.json b/package-lock.json index 244a00c..4ff5f94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@github/copilot-sdk": "^0.1.18", - "axios": "^1.13.3" + "axios": "^1.13.3", + "openai": "^6.22.0" }, "bin": { "quote": "bin/quote.js" @@ -21,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", @@ -856,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", @@ -883,6 +927,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", @@ -897,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 6678456..ef385a4 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "typescript": "^5.9.3" }, "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@github/copilot-sdk": "^0.1.18", - "axios": "^1.13.3" + "axios": "^1.13.3", + "openai": "^6.22.0" } } diff --git a/src/agent/copilot.ts b/src/agent/copilot.ts deleted file mode 100644 index 8e3b305..0000000 --- a/src/agent/copilot.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { CopilotClient, CopilotSession } from "@github/copilot-sdk"; -import { EventEmitter } from "events"; -import { currencyConversionTool, servicePricingLookupTool } from "./tools.js"; -import { resolveSettings } from "../lib/settings.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. - ` -; - -let client: CopilotClient | null = null; - -async function getClient(): Promise { - if (!client) { - client = new CopilotClient(); - await client.start(); - } - 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({ - autoStart: true, - autoRestart: false, - }); - - try { - await client.start(); - const status = await client.getAuthStatus(); - await client.stop(); - return { - isAuthenticated: status.isAuthenticated, - login: status.login, - authType: status.authType, - statusMessage: status.statusMessage, - }; - } catch { - try { - await client.forceStop(); - } catch { - // Ignore cleanup errors - } - return { isAuthenticated: false }; - } -} - -export class QuotationChatbot extends EventEmitter { - private client: CopilotClient | null = null; - private session: CopilotSession | null = null; - private accumulatedContent: string = ""; - - constructor(brief: string) { - super(); - } - - on(event: K, listener: SessionEvents[K]): this { - return super.on(event, listener); - } - - emit(event: K, ...args: Parameters): boolean { - return super.emit(event, ...args); - } - - async init() { - this.client = await getClient(); - // Check authentication status before creating session - const authStatus = await this.client.getAuthStatus(); - if (!authStatus.isAuthenticated) { - throw new Error( - "Not authenticated with GitHub Copilot.\n\n" + - " Run 'copilot auth' in your terminal to log in." - ); - } - } - - async startSession( - brief: string, - options?: { skipInitialMessage?: boolean } - ): 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 - - const { systemPrompt } = await resolveSettings(); - const promptToUse = - systemPrompt && systemPrompt.trim().length > 0 ? systemPrompt : DEFAULT_SYSTEM_PROMPT; - - this.session = await copilot.createSession({ - sessionId: `quote-session-${Date.now()}`, - model: "gpt-4o-mini", - streaming: true, - tools: [currencyConversionTool, servicePricingLookupTool], - systemMessage: { - "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) => { - try { - if (DEBUG_MODE) { - console.log(`[copilot] session event: ${data.type}`); - } - - switch (data.type) { - case "assistant.message": - // Skip complete messages when streaming is enabled to avoid duplication - if (DEBUG_MODE) { - console.log(`[copilot] skipping assistant.message event (using streaming deltas instead)`); - } - break; - - case "assistant.message_delta": - // Accumulate streaming content updates - if ("deltaContent" in data.data && data.data.deltaContent) { - this.accumulatedContent += data.data.deltaContent; - if (DEBUG_MODE) { - console.log(`[copilot] accumulated content length: ${this.accumulatedContent.length}`); - } - } - break; - - case "tool.execution_start": - if (DEBUG_MODE) { - console.log(`[copilot] tool execution started: ${data.data?.toolName || 'unknown'}`); - } - break; - - case "tool.execution_complete": - if (DEBUG_MODE) { - console.log(`[copilot] tool execution completed successfully: ${data.data?.success}`); - } - break; - - case "session.idle": - if (DEBUG_MODE) { - console.log(`[copilot] session is now idle`); - } - - // Emit the complete accumulated content when session is idle - if (this.accumulatedContent.trim()) { - if (DEBUG_MODE) { - console.log(`[copilot] emitting accumulated content (${this.accumulatedContent.length} chars)`); - } - this.emit("message", { - type: "assistant.message", - content: this.accumulatedContent - } as AgentMessageReceived); - - // Reset accumulated content for next interaction - this.accumulatedContent = ""; - } - break; - - case "session.error": - if (DEBUG_MODE) { - console.error(`[copilot] session error:`, data.data?.message || 'unknown error'); - } - this.emit("error", new Error(data.data?.message || "Session error occurred")); - break; - - default: - if (DEBUG_MODE) { - console.log(`[copilot] unhandled session event type: ${data.type}`); - } - break; - } - } catch (err) { - if (DEBUG_MODE) { - console.error("[copilot] error handling session event:", err); - } - this.emit("error", err as Error); - } - }); - - return this.session; - } - catch (error) { - console.log("\n❌ Failed to start Copilot session. Please check your authentication and try again.\n"); - throw error; - } - - } - - async sendMessage(message: string) { - // Reset accumulated content for new interaction - this.accumulatedContent = ""; - await this.session?.send({ prompt: message }); - } - - async endSession() { - if (this.session) { - await this.session.destroy(); - this.emit("ended"); - } - } -} - -export async function listSessions() { - const copilot = await getClient(); - const sessions = await copilot.listSessions(); - return sessions; -} diff --git a/src/agent/providers/anthropic.provider.ts b/src/agent/providers/anthropic.provider.ts new file mode 100644 index 0000000..e53081a --- /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-4.6-20240924", + 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/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 new file mode 100644 index 0000000..bed34c1 --- /dev/null +++ b/src/agent/providers/copilot.provider.ts @@ -0,0 +1,218 @@ +import { CopilotClient, CopilotSession } from "@github/copilot-sdk"; +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 = PROMPT.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 { + if (!client) { + client = new CopilotClient(); + await client.start(); + } + return client; +} + +export async function checkAuth(): Promise { + const copilotClient = new CopilotClient({ + autoStart: true, + autoRestart: false, + }); + + try { + await copilotClient.start(); + const status = await copilotClient.getAuthStatus(); + await copilotClient.stop(); + return { + isAuthenticated: status.isAuthenticated, + login: status.login, + authType: status.authType, + statusMessage: status.statusMessage, + }; + } catch { + try { + await copilotClient.forceStop(); + } catch { + // Ignore cleanup errors + } + return { isAuthenticated: false }; + } +} + +/** + * 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() { + 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 { + if (!this.copilotClient) { + this.copilotClient = await getClient(); + } + + const authStatus = await this.copilotClient.getAuthStatus(); + if (!authStatus.isAuthenticated) { + throw new Error( + "Not authenticated with GitHub Copilot.\n\n" + + " Run 'copilot auth' in your terminal to log in." + ); + } + } + + async startSession( + brief: string, + options?: { skipInitialMessage?: boolean } + ): Promise { + try { + if (!this.copilotClient) await this.initialize(); + + const { systemPrompt } = await resolveSettings(); + const promptToUse = systemPrompt?.trim() ? systemPrompt : DEFAULT_SYSTEM_PROMPT; + + 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 + } + }); + + // Subscribe to session events and re-emit them + this.session.on((data) => { + try { + if (DEBUG_MODE) { + console.log(`[copilot] session event: ${data.type}`); + } + + switch (data.type) { + case "assistant.message": + // Skip complete messages when streaming is enabled to avoid duplication + if (DEBUG_MODE) { + console.log(`[copilot] skipping assistant.message event (using streaming deltas instead)`); + } + break; + + case "assistant.message_delta": + // Accumulate streaming content updates + if ("deltaContent" in data.data && data.data.deltaContent) { + this.accumulatedContent += data.data.deltaContent; + if (DEBUG_MODE) { + console.log(`[copilot] accumulated content length: ${this.accumulatedContent.length}`); + } + } + break; + + case "tool.execution_start": + if (DEBUG_MODE) { + console.log(`[copilot] tool execution started: ${data.data?.toolName || 'unknown'}`); + } + break; + + case "tool.execution_complete": + if (DEBUG_MODE) { + console.log(`[copilot] tool execution completed successfully: ${data.data?.success}`); + } + break; + + case "session.idle": + if (DEBUG_MODE) { + console.log(`[copilot] session is now idle`); + } + + // Emit the complete accumulated content when session is idle + if (this.accumulatedContent.trim()) { + if (DEBUG_MODE) { + console.log(`[copilot] emitting accumulated content (${this.accumulatedContent.length} chars)`); + } + this.emit("message", { + type: "assistant.message", + content: this.accumulatedContent + } as AgentMessageReceived); + + // Reset accumulated content for next interaction + this.accumulatedContent = ""; + } + break; + + case "session.error": + if (DEBUG_MODE) { + console.error(`[copilot] session error:`, data.data?.message || 'unknown error'); + } + this.emit("error", new Error(data.data?.message || "Session error occurred")); + break; + + default: + if (DEBUG_MODE) { + console.log(`[copilot] unhandled session event type: ${data.type}`); + } + break; + } + } catch (err) { + if (DEBUG_MODE) { + console.error("[copilot] error handling session event:", err); + } + this.emit("error", err as 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): Promise { + // Reset accumulated content for new interaction + this.accumulatedContent = ""; + await this.session?.send({ prompt: message }); + } + + async endSession(): Promise { + if (this.session) { + await this.session.destroy(); + this.emit("ended"); + } + } +} + +export async function listSessions() { + const copilotClient = await getClient(); + const sessions = await copilotClient.listSessions(); + return sessions; +} \ No newline at end of file diff --git a/src/agent/providers/openai.provider.ts b/src/agent/providers/openai.provider.ts new file mode 100644 index 0000000..16398bd --- /dev/null +++ b/src/agent/providers/openai.provider.ts @@ -0,0 +1,190 @@ +// 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"; +import { fetchPricingFromNotion, convertCurrency, openaiToolsDefinitions } 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[] = []; + + public session = new EventEmitter(); + + 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) { + 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 { + 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")); + } + } + + /** + * 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, + }); + + 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; + } + } + } + } + + // 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(); + } + + // 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); + } + } + + async endSession(): Promise { + 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 new file mode 100644 index 0000000..8cda298 --- /dev/null +++ b/src/agent/providers/types.ts @@ -0,0 +1,49 @@ +/** + * 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; + + /** + * 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 + */ + 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; +} + +/* + * 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/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 diff --git a/src/index.ts b/src/index.ts index b403e11..721895c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ - import * as readline from "readline"; import { displayHeader } from "./ui/header.js"; -import { checkAuth, QuotationChatbot } from "./agent/copilot.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 { appendQuoteSession, @@ -11,16 +13,26 @@ 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(); + switch (settings.selectedProvider) { + case "openai": return new OpenAIProvider(); + case "anthropic": return new AnthropicProvider(); // <-- DODANE + default: 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 => { @@ -35,45 +47,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"); } @@ -88,15 +95,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 */ } @@ -122,7 +127,8 @@ const collectAgentMessages = ( } }, maxWaitMs); - agent.on("message", messageHandler); + // Register our callback using the AIProvider interface method + agent.onMessage(messageHandler); }); }; @@ -174,33 +180,38 @@ 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/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 = {}; + 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; + if (anthropicApiKey !== undefined) newSettings.anthropicApiKey = anthropicApiKey; await updateSettings(newSettings); @@ -208,9 +219,12 @@ 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)}`); + console.log(`ANTHROPIC_API_KEY: ${status(anthropicApiKey)}`); } catch (error) { console.log("\n❌ Failed to update settings.\n"); if (DEBUG_MODE) console.error(error); @@ -291,155 +305,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(); - - // init the chatbot session here - - const agent = new QuotationChatbot(brief.trim()); - 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 - // 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." - ); - 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(); } } @@ -486,7 +436,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 = await getAgent(); const storedMessages: StoredMessage[] = [...session.messages]; let finalSummary: string | undefined = session.finalSummary; @@ -713,32 +663,29 @@ async function listQuotesFlow(mainRl: readline.Interface): Promise { async function runCli() { try { - displayHeader(); - // Check that the user has access to Copilot and is logged in + // 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" - const spinner = createSpinner("Checking Copilot authentication..."); + // Initialize with a non-blocking approach + 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."); - exit(1); + try { + await agent.initialize(); + spinner.stop(`\n\x1b[1;94m✅ Connected to ${agentName}! Ready to work.\x1b[0m\n`); + } catch (err: any) { + // 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`); } - 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 +737,4 @@ async function runCli() { } } -runCli(); +runCli(); \ No newline at end of file diff --git a/src/lib/settings.ts b/src/lib/settings.ts index c50959d..2212f48 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -4,6 +4,9 @@ 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; systemPrompt?: string | undefined; @@ -50,6 +53,9 @@ export async function updateSettings( notionPageId?: string | null; exchangeRateApiKey?: string | null; systemPrompt?: string | null; + openaiApiKey?: string | undefined; + anthropicApiKey?: string | undefined; + selectedProvider?: string | undefined; } ): Promise { const existing = await loadSettings(); @@ -63,12 +69,21 @@ 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 nextAnthropicApiKey = + partial.anthropicApiKey === null ? undefined : partial.anthropicApiKey ?? existing.anthropicApiKey; + 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 } : {}), + ...(nextAnthropicApiKey !== undefined ? { anthropicApiKey: nextAnthropicApiKey } : {}), + ...(nextSelectedProvider !== undefined ? { selectedProvider: nextSelectedProvider } : {}), }; await saveSettings(merged); return merged; @@ -79,6 +94,9 @@ export async function resolveSettings(): Promise<{ notionPageId?: string; exchangeRateApiKey?: string; systemPrompt?: string; + openaiApiKey?: string | undefined; + anthropicApiKey?: string | undefined; + selectedProvider?: string | undefined; }> { const settings = await loadSettings(); return { @@ -86,5 +104,8 @@ 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), + anthropicApiKey: settings.anthropicApiKey ?? (process.env.ANTHROPIC_API_KEY as string), + selectedProvider: settings.selectedProvider ?? "github-copilot", }; }