UNPKG

askexperts

Version:

AskExperts SDK: build and use AI experts - ask them questions and pay with bitcoin on an open protocol

368 lines 15.1 kB
import { NostrExpert } from "../../experts/NostrExpert.js"; import { OpenaiProxyExpertBase } from "../../experts/OpenaiProxyExpertBase.js"; import { OpenaiProxyExpert } from "../../experts/OpenaiProxyExpert.js"; import { debugError, debugExpert } from "../../common/debug.js"; import { ChromaRagDB } from "../../rag/index.js"; import { createOpenAI } from "../../openai/index.js"; import { AskExpertsServer } from "../../server/AskExpertsServer.js"; import { LightningPaymentManager } from "../../payments/LightningPaymentManager.js"; import { DocStoreWebSocketClient } from "../../docstore/DocStoreWebSocketClient.js"; import { getDocstorePath } from "../../bin/commands/docstore/index.js"; import dotenv from "dotenv"; import { DocStoreLocalClient } from "../../docstore/DocStoreLocalClient.js"; /** * ExpertWorker class for managing experts */ export class ExpertWorker { /** * Get the pubkeys of all running experts * * @returns Array of pubkeys of running experts */ getRunningExpertPubkeys() { return Array.from(this.runningExperts.keys()); } /** * Create a new ExpertWorker * * @param pool SimplePool instance for Nostr communication * @param ragHost Host for RAG database * @param ragPort Port for RAG database */ constructor(pool, ragHost, ragPort) { this.runningExperts = new Map(); this.paymentManagers = new Map(); this.pool = pool; this.ragDB = new ChromaRagDB(ragHost, ragPort); debugExpert("ExpertWorker initialized with RAG database"); } /** * Start an expert * * @param expert The expert to start * @param nwcString NWC connection string for the expert's wallet * @returns Promise that resolves when the expert is started */ async startExpert(expert, nwcString) { if (this.runningExperts.has(expert.pubkey)) { debugExpert(`Expert ${expert.nickname} (${expert.pubkey}) is already running`); return; } try { // Get or create payment manager for this expert's wallet let paymentManager; if (this.paymentManagers.has(expert.wallet_id)) { paymentManager = this.paymentManagers.get(expert.wallet_id); debugExpert(`Reusing existing payment manager for wallet ID ${expert.wallet_id}`); } else { paymentManager = new LightningPaymentManager(nwcString); this.paymentManagers.set(expert.wallet_id, paymentManager); debugExpert(`Created new payment manager for wallet ID ${expert.wallet_id}`); } // Create individual stop promise for this expert let stopResolve; const expertStopPromise = new Promise((resolve) => { stopResolve = resolve; }); let result; // Start the expert based on its type if (expert.type === "nostr") { // Parse docstores - exactly one must be specified const parsedDocstores = ExpertWorker.parseDocstoreIdsList(expert.docstores); if (parsedDocstores.length !== 1) { throw new Error(`Expert ${expert.nickname} must have exactly one docstore specified, found ${parsedDocstores.length}`); } const parsedDocstore = parsedDocstores[0]; // Create the appropriate DocStoreClient based on the docstore ID format const expertDocStoreClient = ExpertWorker.createDocStoreClientFromParsed(parsedDocstore); result = await ExpertWorker.startNostrExpert(expert, this.pool, paymentManager, this.ragDB, expertDocStoreClient, expertStopPromise); } else if (expert.type === "openrouter") { result = await ExpertWorker.startOpenRouterExpert(expert, this.pool, paymentManager, expertStopPromise); } else { throw new Error(`Unknown expert type: ${expert.type}`); } // Add to running experts this.runningExperts.set(expert.pubkey, { pubkey: expert.pubkey, type: expert.type, stopFn: stopResolve, disposed: result.disposed }); debugExpert(`Started expert ${expert.nickname} (${expert.pubkey}) of type ${expert.type}`); } catch (error) { debugError(`Error starting expert ${expert.nickname} (${expert.pubkey}):`, error); throw error; } } /** * Stop an expert * * @param pubkey The pubkey of the expert to stop * @returns True if the expert was running and is now stopped, false otherwise */ stopExpert(pubkey) { const expert = this.runningExperts.get(pubkey); if (expert) { debugExpert(`Stopping expert ${pubkey}...`); expert.stopFn(); this.runningExperts.delete(pubkey); return true; } return false; } /** * Dispose all resources */ async [Symbol.asyncDispose]() { // Collect all disposed promises const disposedPromises = []; // Stop all running experts for (const [pubkey, expert] of this.runningExperts.entries()) { debugExpert(`Stopping expert ${pubkey}...`); expert.stopFn(); disposedPromises.push(expert.disposed); } // Wait for all experts to be fully disposed await Promise.all(disposedPromises); this.runningExperts.clear(); // Dispose all payment managers for (const [walletId, paymentManager] of this.paymentManagers.entries()) { debugExpert(`Disposing payment manager for wallet ID ${walletId}`); paymentManager[Symbol.dispose](); } this.paymentManagers.clear(); } /** * Start a Nostr expert * * @param expert The expert to start * @param pool SimplePool instance * @param paymentManager Payment manager * @param ragDB RAG database * @param docStoreClient DocStore client * @param onStop Promise that resolves when the expert should be stopped */ static async startNostrExpert(expert, pool, paymentManager, ragDB, docStoreClient, onStop) { // Get private key from the expert if (!expert.privkey) { throw new Error(`Expert ${expert.nickname} (${expert.pubkey}) does not have a private key`); } const privkey = new Uint8Array(Buffer.from(expert.privkey, "hex")); // Parse environment variables if not provided const expertEnvVars = dotenv.parse(expert.env); // Target nostr pubkey const nostrPubkey = expertEnvVars.NOSTR_PUBKEY; if (!nostrPubkey) { throw new Error("Nostr pubkey is required. Set NOSTR_PUBKEY in the expert's env configuration."); } // Get model and margin from environment or use defaults const model = expertEnvVars.EXPERT_MODEL || "openai/gpt-4.1"; const margin = expertEnvVars.EXPERT_MARGIN ? parseFloat(expertEnvVars.EXPERT_MARGIN) : 0.1; // Parse docstores - exactly one must be specified const parsedDocstores = ExpertWorker.parseDocstoreIdsList(expert.docstores); if (parsedDocstores.length !== 1) { throw new Error(`Expert ${expert.nickname} must have exactly one docstore specified, found ${parsedDocstores.length}`); } const parsedDocstore = parsedDocstores[0]; const docstoreId = parsedDocstore.id; // Get API key from environment const apiKey = expertEnvVars.OPENAI_API_KEY || process.env.OPENAI_API_KEY; const baseURL = expertEnvVars.OPENAI_BASE_URL || process.env.OPENAI_BASE_URL; // Create OpenAI interface instance const openai = createOpenAI({ apiKey, baseURL, margin, pool, paymentManager, }); // Create server const server = new AskExpertsServer({ privkey, pool, paymentManager, }); // Create OpenaiProxyExpertBase instance const openaiExpert = new OpenaiProxyExpertBase({ server, openai, model, }); // Create the expert const nostrExpert = new NostrExpert({ openaiExpert, pubkey: nostrPubkey, ragDB, docStoreClient, docstoreId, }); // Start the expert await nostrExpert.start(); // Create a promise that will resolve when all resources are disposed let disposeResolve; const disposedPromise = new Promise((resolve) => { disposeResolve = resolve; }); // Destroy on stop signal onStop.then(async () => { try { await nostrExpert[Symbol.asyncDispose](); await openaiExpert[Symbol.asyncDispose](); await server[Symbol.asyncDispose](); } finally { disposeResolve(); } }); return { disposed: disposedPromise }; } /** * Start an OpenRouter expert * * @param expert The expert to start * @param pool SimplePool instance * @param paymentManager Payment manager * @param onStop Promise that resolves when the expert should be stopped */ static async startOpenRouterExpert(expert, pool, paymentManager, onStop) { // Get private key from the expert if (!expert.privkey) { throw new Error(`Expert ${expert.nickname} (${expert.pubkey}) does not have a private key`); } const privkey = new Uint8Array(Buffer.from(expert.privkey, "hex")); // Parse environment variables if not provided const expertEnvVars = dotenv.parse(expert.env); const model = expertEnvVars.EXPERT_MODEL; if (!model) { throw new Error("OpenRouter model is required. Set EXPERT_MODEL in the expert's env configuration."); } // Get model and margin from environment or use defaults const margin = expertEnvVars.EXPERT_MARGIN ? parseFloat(expertEnvVars.EXPERT_MARGIN) : 0.1; // Get API key from environment const apiKey = expertEnvVars.OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY || ""; if (!apiKey) { throw new Error("OpenRouter API key is required. Set OPENROUTER_API_KEY in the expert's env configuration."); } // Create OpenAI interface instance const openai = createOpenAI({ apiKey, baseURL: "https://openrouter.ai/api/v1", margin, }); // Create server const server = new AskExpertsServer({ privkey, pool, paymentManager, }); // Create OpenaiProxyExpert instance const openaiExpert = new OpenaiProxyExpert({ server, openai, model, }); // Start the expert await openaiExpert.start(); debugExpert(`Started expert ${expert.nickname} (${expert.pubkey}) with model ${model} and margin ${margin}`); // Create a promise that will resolve when all resources are disposed let disposeResolve; const disposedPromise = new Promise((resolve) => { disposeResolve = resolve; }); // Destroy on stop signal onStop.then(async () => { try { await openaiExpert[Symbol.asyncDispose](); await server[Symbol.asyncDispose](); } finally { disposeResolve(); } }); return { disposed: disposedPromise }; } /** * Parse a docstore ID string into URL and ID components * * If the docstore ID contains a ":", it's treated as a remote docstore with format: * url:docstore_id (e.g., https://docstore.askexperts.io:docstore_id) * * @param docstoreIdStr - The docstore ID string to parse * @returns An object containing the URL (if remote) and the actual docstore ID */ static parseDocstoreId(docstoreIdStr) { // Check if the docstore ID contains a ":" character if (docstoreIdStr.includes(":")) { // Find the last ":" to separate URL from docstore ID const lastColonIndex = docstoreIdStr.lastIndexOf(":"); // Extract URL (everything before the last colon) const url = docstoreIdStr.substring(0, lastColonIndex); // Extract actual docstore ID (everything after the last colon) const id = docstoreIdStr.substring(lastColonIndex + 1); return { url, id }; } else { // No ":" in the docstore ID, use as-is return { id: docstoreIdStr }; } } /** * Parse a comma-separated list of docstore IDs * * @param docstoresStr - Comma-separated list of docstore IDs * @returns Array of parsed docstore ID objects */ static parseDocstoreIdsList(docstoresStr) { const docstores = docstoresStr .split(",") .map((d) => d.trim()) .filter((d) => d !== ""); return docstores.map(ExpertWorker.parseDocstoreId); } /** * Create the appropriate DocStoreClient based on a parsed docstore ID * * @param parsedDocstore - Parsed docstore ID object * @returns DocStoreClient instance */ static createDocStoreClientFromParsed(parsedDocstore) { if (parsedDocstore.url) { // Remote docstore debugExpert(`Creating DocStoreWebSocketClient for URL: ${parsedDocstore.url}, docstore ID: ${parsedDocstore.id}`); return new DocStoreWebSocketClient({ url: parsedDocstore.url }); } else { // Local docstore const docstorePath = getDocstorePath(); debugExpert(`Using local DocStoreSQLite at: ${docstorePath}`); return new DocStoreLocalClient(docstorePath); } } /** * Parse a docstore ID string and create the appropriate DocStoreClient * * If the docstore ID contains a ":", it's treated as a remote docstore with format: * url:docstore_id (e.g., https://docstore.askexperts.io:docstore_id) * * @param docstoreIdStr - The docstore ID string to parse * @returns An object containing the DocStoreClient and the actual docstore ID */ static createDocStoreClient(docstoreIdStr) { const parsedDocstore = ExpertWorker.parseDocstoreId(docstoreIdStr); return { client: ExpertWorker.createDocStoreClientFromParsed(parsedDocstore), docstoreId: parsedDocstore.id, }; } } //# sourceMappingURL=ExpertWorker.js.map