#!/usr/bin/env bun import { AskModule, AskModuleOptions } from "../src/ask"; import path from "path"; import fs from "fs"; import { FeedoxAIModule, IPromptConfig, IConvMessage } from "../src/providers/FeedoxAI"; import { WorkerClient } from "../src/providers/WorkerClient"; import { AiLibxClient } from "../src/providers/AiLibxClient"; import { libx } from "libx.js/build/bundles/node.essentials"; import { helpers } from "../src/helpers"; import { nodeHelpers } from "../src/helpers/node"; import { ContextManager } from "../src/ContextManager"; import { LogLevel } from "libx.js/build/modules/log"; import { supportedModels, resolveModel as ailbxResolveModel } from "ai.libx.js"; const envPath = libx.node.args.env || libx.node.args.e; console.log('envPath: ', envPath); nodeHelpers.loadEnv(envPath); const DEFAULT_WORKER_URL = 'https://ask-api-worker.feedox.workers.dev/v1'; class LocalAsk { public contextManager: ContextManager = new ContextManager(); constructor(public options?: Partial) { this.options = { ...new LocalAskModuleOptions(), ...options }; } /** * Ask a question using AskModule. If streaming, yields chunks as they arrive. Otherwise, yields the full answer as a single chunk. */ public async *ask(question: string, context?: string, _options?: IPromptConfig): AsyncIterableIterator { // Load conversation history if conversation mode is enabled let conversationHistory: IConvMessage[] = []; if (this.options?.conversationMode || this.options?.latestOnly) { conversationHistory = this.loadConversationHistory(this.options.latestOnly); if (conversationHistory.length > 0) { libx.log.v(`Loaded ${conversationHistory.length} conversation messages`); } } const askModule = new AskModule(helpers.di.modules.ai, this.options); for await (const chunk of askModule.ask( question, context, _options, conversationHistory )) { yield chunk; } } /** * Saves a question-answer pair to disk for future reference * @param question The question that was asked * @param answer The answer received from the AI */ private async saveAsk(question: string, answer: string, context: string = '') { const timestamp = helpers.getTimestamp(); const filePath = path.join(process.cwd(), this.options!.dumpFolderPrefix, `${timestamp}-ask.md`); libx.node.mkdirRecursiveSync(path.dirname(filePath)); fs.writeFileSync(filePath, `<<>>\n${context}\n<<>>\n<<>>\n${question}\n<<>>\n<<>>\n${answer}\n<<>>\n`); libx.log.i(`Ask: Saved ask trace to ${filePath}`); } /** * Load conversation history from trace files * @param latestOnly If true, only load the latest Q&A pair * @returns Array of messages representing conversation history */ private loadConversationHistory(latestOnly: boolean = false): IConvMessage[] { const messages: IConvMessage[] = []; const traceDir = path.join(process.cwd(), this.options!.dumpFolderPrefix); libx.log.v(`Loading conversation history from: ${traceDir}, latestOnly: ${latestOnly}`); if (!fs.existsSync(traceDir)) { libx.log.v('Trace directory does not exist'); return messages; } // Get all trace files sorted by modification time (newest first) const traceFiles = fs.readdirSync(traceDir) .filter(file => file.endsWith('-ask.md')) .map(file => ({ file, path: path.join(traceDir, file), mtime: fs.statSync(path.join(traceDir, file)).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); libx.log.v(`Found ${traceFiles.length} trace files`); const filesToProcess = latestOnly ? traceFiles.slice(0, 1) : traceFiles; libx.log.v(`Processing ${filesToProcess.length} trace files`); for (const { path: filePath, file } of filesToProcess) { try { const content = fs.readFileSync(filePath, 'utf-8'); const qa = this.parseTraceFile(content); libx.log.v(`Parsed ${file}: question="${qa.question.substring(0, 50)}...", answer="${qa.answer.substring(0, 50)}..."`); if (qa.question && qa.answer) { messages.unshift( { role: "user", content: qa.question }, { role: "assistant", content: qa.answer } ); } } catch (error) { libx.log.w(`Failed to parse trace file ${filePath}: ${error.message}`); } } libx.log.v(`Loaded ${messages.length} messages into conversation history`); return messages; } /** * Parse a trace file to extract question and answer * @param content The trace file content * @returns Object with question and answer */ private parseTraceFile(content: string): { question: string; answer: string } { // Use string indexOf and substring to avoid regex issues const questionStart = content.indexOf('<<>>'); const questionEnd = content.indexOf('<<>>'); const answerStart = content.indexOf('<<>>'); const answerEnd = content.indexOf('<<>>'); let question = ''; let answer = ''; if (questionStart !== -1 && questionEnd !== -1) { question = content.substring(questionStart + 15, questionEnd).trim(); } if (answerStart !== -1 && answerEnd !== -1) { answer = content.substring(answerStart + 12, answerEnd).trim(); } return { question, answer }; } } export class LocalAskModuleOptions extends AskModuleOptions { public dumpAskTraces = false; public dumpFolderPrefix = '.tmp/desk'; public conversationMode = false; public latestOnly = false; } function isLocalPath(p: string | undefined): boolean { if (!p) return false; return ( p.startsWith('.') || p.startsWith('/') || p.endsWith('.yaml') || p.endsWith('.yml') ); } function loadLocalAskYaml(promptId: string) { let yamlPath = promptId; if (!fs.existsSync(yamlPath)) { throw new Error(`Local ask.yaml not found at: ${yamlPath}`); } const yamlContent = fs.readFileSync(yamlPath, 'utf8'); const parsed = require('yaml').parse(yamlContent); return parsed; } function loadInputFile(inputPath: string) { if (!fs.existsSync(inputPath)) { throw new Error(`Input file not found at: ${inputPath}`); } try { const fileContent = fs.readFileSync(inputPath, 'utf8'); const extension = path.extname(inputPath).toLowerCase(); if (extension === '.json') { return JSON.parse(fileContent); } else if (extension === '.yaml' || extension === '.yml') { const yaml = require('yaml'); return yaml.parse(fileContent); } else { throw new Error(`Unsupported file format: ${extension}. Supported formats: .json, .yaml, .yml`); } } catch (error) { if (error.message.includes('Unsupported file format')) { throw error; } throw new Error(`Failed to parse input file: ${error.message}`); } } /** * Resolves a model name using ai.libx.js's fuzzy matching. * Delegates to ai.libx.js resolveModel() function. * @param modelInput - The model name to resolve (can be partial or alias) * @returns The full model key (provider/model) or original input if no match found */ function resolveModelName(modelInput: string): string { if (!modelInput) return modelInput; const resolved = ailbxResolveModel(modelInput); // Log if verbose mode is enabled and resolution happened if (libx.log.filterLevel <= LogLevel.Verbose && resolved !== modelInput) { libx.log.v(`Model resolved: "${modelInput}" → "${resolved}"`); } return resolved; } function getMergedOptions() { const env = (key: string) => process.env[key]; // Only use actual env var, not fallback const args = libx.node.args; const defaults = new LocalAskModuleOptions(); // Load input file (JSON or YAML) if provided let inputFileData = undefined; if (args.input || args.i) { const inputPath = args.input || args.i; try { inputFileData = loadInputFile(inputPath); libx.log.v('Loaded input file:', inputPath); } catch (error) { console.error(`Error loading input file: ${error.message}`); process.exit(1); } } let workspaceId = args.workspaceid || args.wsid || args.w || process.env.WORKSPACE_ID || defaults.workspaceId; let promptId = args.promptid || args.pid || args.p || process.env.PROMPT_ID || defaults.promptId; let localPrompt = undefined; let localSettings = undefined; // Only support local workspaceId for now if (isLocalPath(promptId)) { const promptYaml = loadLocalAskYaml(promptId); if (promptYaml.prompt) localPrompt = promptYaml.prompt; if (promptYaml.settings) localSettings = promptYaml.settings; // If ask.yaml has a promptId, use it if (promptYaml.promptId) promptId = promptYaml.promptId; workspaceId = null; promptId = null; } // Updated parameter precedence: CLI args > input file > env vars > local YAML > defaults function getOpt(argVal: any, inputVal: any, envVal: any, localVal: any, defVal: any) { if (argVal !== undefined && argVal !== null && argVal !== '') return argVal; if (inputVal !== undefined && inputVal !== null && inputVal !== '') return inputVal; if (envVal !== undefined && envVal !== null && envVal !== '') return envVal; if (localVal !== undefined && localVal !== null && localVal !== '') return localVal; return defVal; } const maxTokens = Number(getOpt( args.maxTokens || args.max_tokens || args.k, inputFileData?.maxTokens, process.env.MAX_TOKENS, localSettings?.maxTokens, defaults.maxTokens )); const model = getOpt( args.model || args.m, inputFileData?.model, process.env.MODEL, localSettings?.model, undefined ); const temperature = Number(getOpt( args.temperature || args.temp || args.t, inputFileData?.temperature, process.env.TEMPERATURE, localSettings?.temperature, undefined )); // Handle context from input file (can be array or string) let contextArg = args.context || args.c; if (!contextArg && inputFileData?.context) { contextArg = Array.isArray(inputFileData.context) ? inputFileData.context.join(',') : inputFileData.context; } if (process.env.DEBUG_LOCAL_ASK) { libx.log.d('[DEBUG] getMergedOptions:', { workspaceId, promptId, maxTokensRaw: localSettings?.maxTokens, maxTokens, model, temperature, localSettings, inputFileData: inputFileData ? 'loaded' : 'none', }); } return { workspaceId: workspaceId, promptId: promptId, maxTokens, model, temperature, dumpAskTraces: !!(args.save || args.s || inputFileData?.save || process.env.DUMP_ASK_TRACES || defaults.dumpAskTraces), dumpFolderPrefix: getOpt(args.output || args.o, inputFileData?.outputFolder, process.env.OUTPUT_FOLDER, null, defaults.dumpFolderPrefix), verbose: !!(args.verbose || args.v || inputFileData?.verbose), conversationMode: !!(args.conversation || args.C || inputFileData?.conversationMode), latestOnly: !!(args.latest || args.L || inputFileData?.latestOnly), contextArg, defaultContextFolder: getOpt(null, inputFileData?.defaultContextFolder, process.env.DEFAULT_CONTEXT_FOLDER, null, null), stream: !!(args.stream || args.S || inputFileData?.stream || process.env.STREAM), localPrompt, localSettings, inputFileData, }; } // Extract question from parsed args with fallback for flags that might consume the question export function getQuestionFromArgs(parsedArgs: any): string { const positional = Array.isArray(parsedArgs._) ? parsedArgs._.join(' ') : ''; if (positional && positional.trim() !== '') return positional.trim(); // Check if any boolean flags accidentally consumed the question as their value const flagsToCheck = [ parsedArgs?.s ?? parsedArgs?.save, // -s/--save parsedArgs?.l ?? parsedArgs?.latest, // -l/--latest parsedArgs?.L, // -L (alias for --latest) parsedArgs?.v ?? parsedArgs?.verbose, // -v/--verbose parsedArgs?.S ?? parsedArgs?.stream, // -S/--stream parsedArgs?.C ?? parsedArgs?.conversation, // -C/--conversation ]; for (const flagVal of flagsToCheck) { if (typeof flagVal === 'string' && flagVal.trim() !== '') { return flagVal.trim(); } } return ''; } // --- List models utility --- function printSupportedModelsAndExit() { const models = supportedModels as Record; // Support filter: --list-models or -M const args = libx.node.args; let filter = ''; if (args['list-models']) filter = args['list-models']; else if (args.M && args.M != true) filter = args.M; else if (Array.isArray(args._) && args._.length > 0) filter = args._[0]; filter = (filter.toString() || '').toLowerCase(); // Group models by provider const grouped: Record = {}; for (const [key, val] of Object.entries(models)) { const model = val as any; if (model.enabled === false) continue; const keyStr = key.toLowerCase(); const nameStr = (model.displayName || '').toLowerCase(); if (filter && !keyStr.includes(filter) && !nameStr.includes(filter)) continue; const provider = key.split('/')[0]; if (!grouped[provider]) grouped[provider] = []; grouped[provider].push({ key, ...model }); } console.log('\nšŸ¤– Supported Models' + (filter ? ` (filter: "${filter}")` : '')); console.log('═'.repeat(80)); for (const [provider, providerModels] of Object.entries(grouped).sort()) { console.log(`\nšŸ“¦ ${provider.toUpperCase()} (${providerModels.length} models)`); console.log('─'.repeat(80)); for (const model of providerModels) { const badges = []; if (model.reasoning) badges.push('🧠 reasoning'); if (model.imageInput) badges.push('šŸ–¼ļø vision'); if (model.imageGen) badges.push('šŸŽØ image-gen'); if (model.noSystem) badges.push('āš ļø no-system'); const badgeStr = badges.length > 0 ? ` [${badges.join(', ')}]` : ''; console.log(` ${model.key}`); if (model.displayName) { console.log(` ↳ ${model.displayName}${badgeStr}`); } } } console.log('\n' + '═'.repeat(80)); console.log(`Total: ${Object.values(grouped).flat().length} models across ${Object.keys(grouped).length} providers\n`); process.exit(0); } if (libx.node.args['list-models'] || libx.node.args.M) { printSupportedModelsAndExit(); } // --- Help print utility --- function printHelpAndExit() { console.log(`\nUsage: ask [options] \n Options: -h, -? \tShow this help message and exit (use -h, not --help) --list-models, -M \tList/filter all supported models (env: LIST_MODELS) --model, -m \tModel to use (env: MODEL) --maxTokens, -k \tMax tokens (env: MAX_TOKENS, default: 8192) --temperature, -t \tSampling temperature (env: TEMPERATURE) --stream, -S \tEnable streaming mode (env: STREAM, default: false) --save, -s \tSave Q&A trace to current directory (env: DUMP_ASK_TRACES, default: false) --output, -o \tCustom output folder for traces (env: OUTPUT_FOLDER, default: .tmp/desk) --conversation, -C \tConversation mode: load all previous Q&A as message history --latest, -L \tUse only latest Q&A pair as message history --context, -c \tContext file(s)/dir(s), comma-separated (env: DEFAULT_CONTEXT_FOLDER) --input, -i \tJSON/YAML input file with execution parameters --promptid, -p \tPrompt ID (env: PROMPT_ID) --workspaceid, -w \tWorkspace ID (env: WORKSPACE_ID) --verbose, -v \tVerbose logging Providers (mutually exclusive, set via environment): USE_AILBX=true \tUse ai.libx.js (unified API bridge for AI models) USE_WORKER=true \tUse Worker API (default if WORKER_API_URL is set) (default) \tUse FeedoxAI (legacy) Authentication (for Worker API): ASK_API_KEY \tUse remote key management (all vendor keys stored in DB) (individual vendor keys) \tFallback: OPENAI_API_KEY, CLAUDE_API_KEY, etc. Note: Use 'ask -h' for detailed help. 'ask --help' shows Bun's generic help. Examples: ask --model gpt-4 --context src/ "What does this code do?" ask -m gpt-4 -c src/,README.md "Summarize the project." ask -s -o "my-traces/" "Save to custom folder" ask -C "Continue our previous conversation" ask -L "Elaborate on your last answer" ask -C -o "conversations/" "Multi-turn chat in custom folder" ask -i config.json ask -i config.yaml "Override question from YAML" ask --list-models gpt-4 ask -M gemini ask --stream "Stream the answer" `); process.exit(0); } // Check for --help or -h before any main logic if (libx.node.args.help || libx.node.args.h || libx.node.args['?']) { printHelpAndExit(); } // Direct script execution support nodeHelpers.handleSelfExecution(async () => { const options = getMergedOptions(); // Choose between ai.libx.js, Worker, and FeedoxAI based on environment const useAiLibx = process.env.USE_AILBX === 'true'; const useWorker = process.env.USE_WORKER === 'true' || !!process.env.WORKER_API_URL; if (useAiLibx) { libx.log.v('ask: using ai.libx.js client'); helpers.di.register('ai', new AiLibxClient({ defaultProvider: process.env.DEFAULT_PROVIDER, defaultModel: process.env.DEFAULT_MODEL, enableLogging: process.env.AILBX_ENABLE_LOGGING === 'true', // Pass all API keys from environment openaiApiKey: process.env.OPENAI_API_KEY, anthropicApiKey: process.env.ANTHROPIC_API_KEY, groqApiKey: process.env.GROQ_API_KEY, googleApiKey: process.env.GOOGLE_AI_API_KEY, mistralApiKey: process.env.MISTRAL_API_KEY, openrouterApiKey: process.env.OPENROUTER_API_KEY, cohereApiKey: process.env.COHERE_API_KEY, xaiApiKey: process.env.XAI_API_KEY, deepseekApiKey: process.env.DEEPSEEK_API_KEY, ai21ApiKey: process.env.AI21_API_KEY, cloudflareApiKey: process.env.CLOUDFLARE_API_KEY, cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID, })); } else if (useWorker) { libx.log.v('ask: using Worker client'); helpers.di.register('ai', new WorkerClient({ baseUrl: process.env.WORKER_API_URL ?? DEFAULT_WORKER_URL, defaultProvider: process.env.DEFAULT_PROVIDER, defaultModel: process.env.DEFAULT_MODEL, // If ASK_API_KEY is present, use it for remote key management askApiKey: process.env.ASK_API_KEY, // Otherwise fall back to individual vendor keys openaiApiKey: process.env.OPENAI_API_KEY, claudeApiKey: process.env.CLAUDE_API_KEY, groqApiKey: process.env.GROQ_API_KEY, googleApiKey: process.env.GOOGLE_AI_API_KEY, mistralApiKey: process.env.MISTRAL_API_KEY, openrouterApiKey: process.env.OPENROUTER_API_KEY, cohereApiKey: process.env.COHERE_API_KEY, xaiApiKey: process.env.XAI_API_KEY, deepseekApiKey: process.env.DEEPSEEK_API_KEY, ai21ApiKey: process.env.AI21_API_KEY, cloudflareApiKey: process.env.CLOUDFLARE_API_KEY, cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID, })); } else { libx.log.v('ask: using FeedoxAI'); helpers.di.register('ai', new FeedoxAIModule({ baseUrl: process.env.FEEDOX_AI_URL, })); } libx.log.filterLevel = LogLevel.Info; if (options.verbose) { libx.log.filterLevel = LogLevel.All; } libx.log.v('ask: executing as shell', options); const askModule = new LocalAsk({ workspaceId: options.workspaceId, promptId: options.promptId, maxTokens: options.maxTokens, dumpAskTraces: options.dumpAskTraces, dumpFolderPrefix: options.dumpFolderPrefix, conversationMode: options.conversationMode, latestOnly: options.latestOnly, }); // FeedoxAIModule workspaceId (only needed for FeedoxAI) if (options.workspaceId && !useWorker && !useAiLibx) { (helpers.di.modules.ai.options = helpers.di.modules.ai.options || {}).workspaceId = options.workspaceId; } let context = ''; let contextPaths: string[] | undefined = options.contextArg?.split(',').map((p: string) => path.resolve(p.trim())); if (!contextPaths && options.defaultContextFolder) { contextPaths = [path.resolve(options.defaultContextFolder)]; } if (contextPaths) { for (const ctxPath of contextPaths) { if (fs.existsSync(ctxPath)) { if (fs.statSync(ctxPath).isDirectory()) { await askModule.contextManager.addDirectory(ctxPath); } else { await askModule.contextManager.addFile(ctxPath); } } } context = await askModule.contextManager.contextToString(); if (context) libx.log.v('ask: context', { context }); } const promptConfig: IPromptConfig = {}; // Parse model string to extract provider and model if (options.model) { // Resolve model name from supported models list const resolvedModel = resolveModelName(options.model); // Check if model contains provider (e.g., "anthropic/claude-opus-4-1") if (resolvedModel.includes('/')) { const [provider, model] = resolvedModel.split('/', 2); promptConfig.provider = provider; promptConfig.model = model; } else { promptConfig.model = resolvedModel; // Try to infer provider from model name if (resolvedModel.includes('claude') || resolvedModel.includes('anthropic')) { promptConfig.provider = 'anthropic'; } else if (resolvedModel.includes('gpt') || resolvedModel.includes('o1') || resolvedModel.includes('o3')) { promptConfig.provider = 'openai'; } else if (resolvedModel.includes('kimi') || resolvedModel.includes('groq') || resolvedModel.includes('llama') || resolvedModel.includes('mixtral') || resolvedModel.includes('gemma')) { promptConfig.provider = 'groq'; } else if (resolvedModel.includes('gemini') || resolvedModel.includes('palm')) { promptConfig.provider = 'google'; } else if (resolvedModel.includes('mistral') || resolvedModel.includes('codestral') || resolvedModel.includes('pixtral')) { promptConfig.provider = 'mistral'; } else if (resolvedModel.includes('command') || resolvedModel.includes('aya')) { promptConfig.provider = 'cohere'; } else if (resolvedModel.includes('grok')) { promptConfig.provider = 'xai'; } else if (resolvedModel.includes('deepseek')) { promptConfig.provider = 'deepseek'; } else if (resolvedModel.includes('j2') || resolvedModel.includes('jurassic')) { promptConfig.provider = 'ai21'; } else if (resolvedModel.includes('@cf') || resolvedModel.includes('@hf') || resolvedModel.includes('cloudflare')) { promptConfig.provider = 'cloudflare'; } } } if (!isNaN(options.temperature)) promptConfig.temperature = options.temperature; if (options.workspaceId && !useWorker && !useAiLibx) promptConfig.wsId = options.workspaceId; if (!isNaN(options.maxTokens)) promptConfig.maxTokens = options.maxTokens; if (options.stream) promptConfig.stream = true; // For Worker and ai.libx.js, ensure we have a provider if ((useWorker || useAiLibx) && !promptConfig.provider) { promptConfig.provider = process.env.DEFAULT_PROVIDER || 'openai'; } let systemPrompt: string = null; // Use helper so we can unit-test argument parsing fallback let question = getQuestionFromArgs(libx.node.args); // If no question provided via CLI args but input file has a question, use it if (!question && options.inputFileData?.question) { question = options.inputFileData.question; } if (options.localPrompt) { // If localPrompt is a template, fill in context if (typeof options.localPrompt === 'string' && options.localPrompt.includes('{{context}}')) { systemPrompt = libx.extensions.string.format.call(options.localPrompt, { context }); // question = options.localPrompt.replace('{{context}}', context); } else { systemPrompt = options.localPrompt; } // Pass the local prompt as systemPrompt to the config promptConfig.systemPrompt = systemPrompt; context = ''; // don't pass context to the prompt as it's already in the system prompt } let answer = ''; if (options.stream) { for await (const chunk of askModule.ask( question, context, promptConfig )) { process.stdout.write(chunk ?? ''); answer += chunk ?? ''; } process.stdout.write('\n'); } else { answer = await AskModule.collectAllChunks( askModule.ask( question, context, promptConfig ) ); console.log(answer); } if (options.dumpAskTraces) { await askModule['saveAsk'](question, answer, context); } }); export { getMergedOptions };