import chalk from "chalk" import { createInterface } from "readline" import OpenAI from "openai" import { Conversation } from "../agent/conversation" import { ToolRegistry } from "../tools/registry" import { runAgentLoop } from "../agent/loop" import { estimateTokens } from "../utils/tokens" import { readTool } from "../tools/tools/read" import { writeTool } from "../tools/tools/write" import { editTool } from "../tools/tools/edit" import { bashTool } from "../tools/tools/bash" import { globTool } from "../tools/tools/glob" import { grepTool } from "../tools/tools/grep" import { webFetchTool } from "../tools/tools/web-fetch" import { askUserTool } from "../tools/tools/ask-user" import { compactConversation, uncompactConversation, type CompactResult } from "../compact/service" import { analyzeContextUsage, formatContextAnalysis } from "../context/analyzer" import { SkillRegistry, type TrustLevel } from "../skills/registry" import { loadAllSkills } from "../skills/loader" import { isToolAllowedForSkill, resolveTrustLevel } from "../skills/trust" import { initMemoryFiles, buildMemoryPrompt, readAllMemory, type MemoryEntry } from "../memory/service" import { startSessionLog, logEvent, endSessionLog, type TelemetryEvent } from "../telemetry/logger" import { loadConfig } from "../auth/config" import { getConfigDir } from "../auth/config" import * as fs from "fs" import * as path from "path" export interface ReplOptions { client: OpenAI model: string stream: boolean } const HELP_TEXT = ` ${chalk.bold("LLMTune CLI - Commands:")} ${chalk.cyan("/help")} Show this help ${chalk.cyan("/clear")} Clear conversation history ${chalk.cyan("/context")} Show detailed context usage breakdown ${chalk.cyan("/compact")} Compact conversation (LLM summary) ${chalk.cyan("/uncompact")} Restore conversation from before compaction ${chalk.cyan("/model ")} Switch model ${chalk.cyan("/stream")} Toggle streaming mode ${chalk.cyan("/verbose")} Toggle verbose tool output ${chalk.cyan("/trust ")} Trust a tool (skip confirmations) ${chalk.cyan("/skills")} List available skills ${chalk.cyan("/memory")} Show memory contents ${chalk.cyan("/save")} Save conversation to file ${chalk.cyan("/exit")} Exit REPL ${chalk.gray("Type your message and press Enter to chat.")} ${chalk.gray("Multi-line: end a line with '\\' to continue.")} `.trim() export async function startRepl(options: ReplOptions): Promise { const registry = new ToolRegistry() const cwd = process.cwd() const trustedTools = new Set() registry.register(readTool) registry.register(writeTool) registry.register(editTool) registry.register(bashTool) registry.register(globTool) registry.register(grepTool) registry.register(webFetchTool) registry.register(askUserTool) const conversation = new Conversation(options.model) // Load skills const skills = loadAllSkills(cwd) const skillList = skills.listUserInvocable() // Initialize memory initMemoryFiles() // Start telemetry startSessionLog(conversation.id, { model: options.model, tools: registry.listSpecs().map((s) => s.name), cwd, }) let currentModel = options.model let streamMode = options.stream let verbose = false console.log(chalk.cyan(`\nLLMTune CLI v0.1.0`)) console.log(chalk.dim(`Model: ${currentModel}`)) console.log(chalk.dim(`Tools: ${registry.listSpecs().map((s) => s.name).join(", ")}`)) if (skillList.length > 0) { console.log(chalk.dim(`Skills: ${skillList.map((s) => s.name).join(", ")}`)) } console.log(chalk.dim(`Type /help for commands, /exit to quit.\n`)) const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: chalk.blue("> "), }) rl.prompt() let multiLineBuffer = "" rl.on("line", async (line: string) => { const trimmed = line.trim() if (trimmed.endsWith("\\")) { multiLineBuffer += trimmed.slice(0, -1) + "\n" process.stdout.write(chalk.dim("... ")) return } const fullInput = multiLineBuffer + trimmed multiLineBuffer = "" if (!fullInput) { rl.prompt() return } // Check for skill execution: /skill-name [args] if (fullInput.startsWith("/") && !fullInput.startsWith("/help") && !fullInput.startsWith("/exit") && !fullInput.startsWith("/quit") && !fullInput.startsWith("/clear") && !fullInput.startsWith("/context") && !fullInput.startsWith("/compact") && !fullInput.startsWith("/uncompact") && !fullInput.startsWith("/model") && !fullInput.startsWith("/stream") && !fullInput.startsWith("/verbose") && !fullInput.startsWith("/trust") && !fullInput.startsWith("/skills") && !fullInput.startsWith("/memory") && !fullInput.startsWith("/save")) { const parts = fullInput.slice(1).split(/\s+/) const skillName = parts[0] const skillArgs = parts.slice(1) const execution = skills.prepareExecution(skillName, skillArgs) if (execution) { const skill = skills.get(skillName)! console.log(chalk.magenta(`Executing skill: ${skill.name}`)) const trustLevel = resolveTrustLevel(skill) const toolNames = registry.listSpecs().map((s) => s.name) for (const toolName of toolNames) { if (!isToolAllowedForSkill(toolName, trustLevel)) { console.log(chalk.yellow(` Warning: tool "${toolName}" not allowed for skill trust level "${trustLevel}"`)) } } // Add skill system prompt and user message conversation.addSystemMessage(execution.systemPrompt) conversation.addUserMessage(execution.userMessage) try { const result = await runAgentLoop(options.client, conversation, registry, execution.userMessage, { model: currentModel, maxTurns: 50, verbose, cwd, workspaceRoot: cwd, }) logEvent({ event: "tool_call", tool: skillName, latency_ms: 0 }) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) console.log(chalk.red(`\nSkill error: ${msg}`)) } console.log("") rl.prompt() return } } if (fullInput.startsWith("/")) { await handleCommand(fullInput, { rl, conversation, registry, skills, trustedTools, cwd, client: options.client, getModel: () => currentModel, setModel: (m: string) => { currentModel = m }, getStream: () => streamMode, setStream: (s: boolean) => { streamMode = s }, getVerbose: () => verbose, setVerbose: (v: boolean) => { verbose = v }, }) rl.prompt() return } // Normal chat try { const result = await runAgentLoop(options.client, conversation, registry, fullInput, { model: currentModel, maxTurns: 50, verbose, cwd, workspaceRoot: cwd, }) if (result.totalTokensIn > 0 || result.totalTokensOut > 0) { const cost = estimateCostFromUsage(result.totalTokensIn, result.totalTokensOut) console.log( chalk.dim( ` [${result.turns} turn${result.turns !== 1 ? "s" : ""} | ` + `${result.totalToolCalls} tool calls | ` + `${result.totalTokensIn + result.totalTokensOut} tokens | ` + `~$${cost.toFixed(4)}]`, ), ) logEvent({ event: "llm_response", model: currentModel, tokens_in: result.totalTokensIn, tokens_out: result.totalTokensOut, cost, latency_ms: 0, }) } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) console.log(chalk.red(`\nError: ${msg}`)) logEvent({ event: "error", source: "agent_loop", error: msg } as TelemetryEvent) } console.log("") rl.prompt() }) rl.on("close", () => { endSessionLog({ totalToolCalls: 0, totalTokens: 0 }) console.log(chalk.dim("\nGoodbye!")) process.exit(0) }) } interface CommandContext { rl: ReturnType conversation: Conversation registry: ToolRegistry skills: SkillRegistry trustedTools: Set cwd: string client: OpenAI getModel: () => string setModel: (m: string) => void getStream: () => boolean setStream: (s: boolean) => void getVerbose: () => boolean setVerbose: (v: boolean) => void } async function handleCommand(input: string, ctx: CommandContext): Promise { const parts = input.split(/\s+/) const cmd = parts[0].toLowerCase() const args = parts.slice(1) switch (cmd) { case "/help": case "/h": case "/?": console.log(HELP_TEXT) break case "/exit": case "/quit": case "/q": console.log(chalk.dim("Goodbye!")) process.exit(0) case "/clear": ctx.conversation.messages.length = 0 console.log(chalk.green("Conversation cleared.")) break case "/context": { const analysis = analyzeContextUsage({ systemPrompt: "LLMTune coding agent", toolSpecs: ctx.registry.listSpecs(), messages: ctx.conversation.getApiMessages().map((m) => ({ role: m.role, content: m.content, })), skillsContent: ctx.skills.list().map((s) => s.content).join("\n") || undefined, memoryContent: buildMemoryPrompt() || undefined, model: ctx.getModel(), }) console.log(formatContextAnalysis(analysis)) break } case "/compact": { if (ctx.conversation.messages.length < 3) { console.log(chalk.yellow("Not enough messages to compact (need at least 3).")) break } console.log(chalk.dim("Compacting conversation...")) try { const result = await compactConversation(ctx.client, ctx.getModel(), ctx.conversation) console.log(chalk.green(`\nCompacted: ${result.preCompactMessages} -> ${result.postCompactMessages} messages`)) console.log(chalk.dim(` Tokens saved: ~${result.tokensSaved.toLocaleString()}`)) console.log(chalk.dim(` Use /uncompact to restore full history.`)) logEvent({ event: "compaction", tokens_saved: result.tokensSaved, messages_before: result.preCompactMessages, messages_after: result.postCompactMessages, trigger: "manual" }) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) console.log(chalk.red(`Compaction failed: ${msg}`)) } break } case "/uncompact": { const restored = uncompactConversation(ctx.conversation) if (restored) { console.log(chalk.green(`Restored conversation from before compaction (${ctx.conversation.messages.length} messages).`)) } else { console.log(chalk.yellow("No raw history found to restore.")) } break } case "/model": if (args[0]) { ctx.setModel(args[0]) console.log(chalk.green(`Model set to: ${args[0]}`)) } else { console.log(chalk.dim(`Current model: ${ctx.getModel()}`)) } break case "/stream": ctx.setStream(!ctx.getStream()) console.log(chalk.green(`Streaming: ${ctx.getStream() ? "on" : "off"}`)) break case "/verbose": ctx.setVerbose(!ctx.getVerbose()) console.log(chalk.green(`Verbose: ${ctx.getVerbose() ? "on" : "off"}`)) break case "/trust": if (args[0]) { ctx.trustedTools.add(args[0].toLowerCase()) console.log(chalk.green(`Trusting tool: ${args[0]} (no confirmation needed)`)) } else { console.log(chalk.dim("Usage: /trust ")) } break case "/skills": { const allSkills = ctx.skills.listUserInvocable() if (allSkills.length === 0) { console.log(chalk.dim("No skills loaded. Create skills in ~/.llmtune/skills/ or .llmtune/skills/")) console.log(chalk.dim("Each skill is a directory with a SKILL.md file.")) } else { console.log(chalk.bold("\nAvailable Skills:")) for (const skill of allSkills) { const trustLabel = skill.trustLevel console.log(` ${chalk.cyan(`/${skill.name}`)} - ${skill.description} ${chalk.dim(`[${trustLabel}]`)}`) } console.log("") } break } case "/memory": { const entries = readAllMemory() if (entries.length === 0) { console.log(chalk.dim("\nNo memory entries. The agent will auto-populate memory as you chat.")) console.log(chalk.dim(`Memory directory: ${getConfigDir()}/memory/\n`)) } else { console.log(chalk.bold("\nMemory:")) for (const entry of entries) { if (entry.content.trim()) { const preview = entry.content.slice(0, 100).replace(/\n/g, " ") console.log(` ${chalk.cyan(entry.category)}: ${chalk.dim(preview)}${entry.content.length > 100 ? "..." : ""}`) } } console.log("") } break } case "/save": { const savePath = path.join(process.cwd(), `llmtune-session-${Date.now()}.json`) fs.writeFileSync(savePath, JSON.stringify(ctx.conversation.getApiMessages(), null, 2), "utf-8") console.log(chalk.green(`Session saved to: ${savePath}`)) break } default: console.log(chalk.yellow(`Unknown command: ${cmd}. Type /help for available commands.`)) } } function estimateCostFromUsage(inputTokens: number, outputTokens: number): number { return (inputTokens * 3 + outputTokens * 15) / 1_000_000 }