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
JavaScript
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