diff --git a/apps/cli/src/application/assistant/README.md b/apps/cli/src/application/assistant/README.md deleted file mode 100644 index 4c776c26..00000000 --- a/apps/cli/src/application/assistant/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Rowboat Copilot (demo) - -- Entry point: `npm run copilot` (runs `src/x.ts` after building) -- Natural language interface to list/create/update/delete workflow JSON under `.rowboat/workflows` -- Uses existing zod schemas for validation; errors bubble up plainly for easy debugging -- Maintains conversational memory within a session and replies in natural language (append `--debug` or set `COPILOT_DEBUG=1` to view raw JSON commands) -- Data folders ensured automatically: `.rowboat/workflows`, `.rowboat/agents`, `.rowboat/mcp` diff --git a/apps/cli/src/application/assistant/USAGE.md b/apps/cli/src/application/assistant/USAGE.md deleted file mode 100644 index 0181a696..00000000 --- a/apps/cli/src/application/assistant/USAGE.md +++ /dev/null @@ -1,16 +0,0 @@ -Quick start - -1. `cd rowboat-V2/apps/cli` -2. `export OPENAI_API_KEY=...` -3. `npm run copilot` - -Example prompts once running: -- `list my workflows` -- `show workflow example_workflow` -- `create a workflow demo that calls function get_date` -- `add an agent step default_assistant to demo` -- `delete the demo workflow` - -While the session is open the copilot keeps conversational context, so you can ask follow-ups such as “what was the first thing I asked?” or “add that step again”. Responses are natural language summaries of the structured actions it performs. - -Need to inspect the underlying JSON command/results? Run in debug mode with `npm run copilot -- --debug` (or set `COPILOT_DEBUG=1`) to keep the raw interpreter output visible. diff --git a/apps/cli/src/application/assistant/agents/service.ts b/apps/cli/src/application/assistant/agents/service.ts deleted file mode 100644 index f18022c0..00000000 --- a/apps/cli/src/application/assistant/agents/service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { z } from "zod"; -import { Agent } from "../../entities/agent.js"; -import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js"; - -export type AgentId = string; - -export function listAgents(): AgentId[] { - return listJson("agents"); -} - -export function getAgent(id: AgentId): z.infer | undefined { - const raw = readJson("agents", id); - if (!raw) return undefined; - return Agent.parse(raw); -} - -export function upsertAgent( - id: AgentId, - value: Partial> -): z.infer { - const existing = readJson("agents", id) as Partial> | undefined; - const merged = { - name: id, - model: "openai:gpt-4o-mini", - description: "", - instructions: "", - ...(existing ?? {}), - ...value, - } satisfies Partial>; - const parsed = Agent.parse(merged); - writeJson("agents", id, parsed); - return parsed; -} - -export function deleteAgent(id: AgentId): boolean { - return deleteJson("agents", id); -} diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index bd3a2d45..12462b5d 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -1,196 +1,417 @@ -import readline from "readline"; +import { streamText, ModelMessage, tool, stepCountIs } from "ai"; import { openai } from "@ai-sdk/openai"; -import { generateObject, streamText } from "ai"; -import type { CoreMessage } from "ai"; -import { - ChatCommand, - ChatCommandT, - CommandOutcome, - executeCommand, -} from "./commands.js"; +import * as readline from "readline/promises"; +import { stdin as input, stdout as output } from "process"; +import { z } from "zod"; +import * as fs from "fs/promises"; +import * as path from "path"; -type ConversationMessage = { - role: "user" | "assistant"; - content: string; -}; +const model = openai("gpt-4.1"); +const rl = readline.createInterface({ input, output }); -const systemPrompt = ` -You are a general-purpose CLI copilot that converts the user's natural language into structured commands the Rowboat assistant runtime can execute, and you can also hold a regular conversation when no command fits. +// Base directory for file operations +const BASE_DIR = "/Users/tusharmagar/.rowboat"; -Rules: -- Only output JSON matching the provided schema. No extra commentary. -- Select the most appropriate action from: help, general_chat, list_workflows, get_workflow, describe_workflows, create_workflow, update_workflow, delete_workflow, list_agents, get_agent, create_agent, update_agent, delete_agent, list_mcp_servers, add_mcp_server, remove_mcp_server, run_workflow, unknown. -- Use describe_workflows with { scope: "all" } to show every workflow, or provide specific ids when the user names particular workflows (including pronouns like "them" or "those" referring to previously listed workflows). -- For actions that need an id (workflow/agent), set "id" to the identifier (e.g. "example_workflow"). -- For create/update actions, only include provided fields in "updates". -- Workflow shape reminder: { name: string, description: string, steps: Step[] } where Step is either { type: "function", id: string } or { type: "agent", id: string }. -- Agent shape reminder: { name: string, model: string, description: string, instructions: string }. -- MCP server shape reminder: { name: string, url: string }. -- If the request is ambiguous, set action to "unknown". -- If the user is just chatting or asking for general help or explanations, use action "general_chat" with their full prompt in "query". -`; - -const responseSystemPrompt = ` -You are Skipper, the Rowboat CLI copilot. You maintain an ongoing conversation, remember prior questions, run commands when requested, and give helpful free-form answers when a general reply is appropriate. - -Guidelines: -- Respond in natural language with short, helpful paragraphs or bullet lists when useful. -- Summarise command results plainly (lists, confirmations, errors) and mention next steps when appropriate. -- If a command could not be inferred (action "unknown"), clarify what additional detail is needed or answer the query directly using the conversation history when possible. -- Use the conversation history to answer memory questions (for example "what was the first question I asked?"). -- Avoid repeating the raw JSON command or result unless explicitly asked; focus on what the outcome means. -- Deliver everything requested in one response. Do not say you'll follow up later—include all available details right away. -- For general_chat actions, respond directly to the user's query with the best answer you can provide. -`; - -function buildMessageHistory(history: ConversationMessage[]): CoreMessage[] { - return history.map((message) => ({ - role: message.role, - content: message.content, - })); -} - -async function interpret(input: string, history: ConversationMessage[]): Promise { - const stopSpinner = startSpinner("Analyzing…", { persist: false }); - const conversation: CoreMessage[] = [ - { role: "system", content: systemPrompt }, - ...buildMessageHistory(history), - { role: "user", content: input }, - ]; - - try { - const { object } = await generateObject({ - model: openai("gpt-4.1"), - messages: conversation, - schema: ChatCommand, - }); - return object; - } finally { - stopSpinner(); - } -} - -function startSpinner( - label: string, - options: { persist?: boolean } = {} -): (finalMessage?: string) => void { - const { persist = true } = options; - const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴"]; - let index = 0; - const render = () => { - const frame = frames[index]; - index = (index + 1) % frames.length; - process.stdout.write(`\r${frame} ${label}`); - }; - render(); - const timer = setInterval(render, 80); - return (finalMessage?: string) => { - clearInterval(timer); - const doneFrame = frames[(index + frames.length - 1) % frames.length]; - const message = finalMessage ?? "done"; - const clearWidth = doneFrame.length + label.length + (persist ? message.length + 3 : 2); - const clear = " ".repeat(clearWidth); - process.stdout.write(`\r${clear}`); - if (persist) { - process.stdout.write(`\r${doneFrame} ${label} ${message}\n`); - } else { - process.stdout.write("\r"); - } - }; -} - -async function renderAssistantResponse( - input: string, - cmd: ChatCommandT, - outcome: CommandOutcome, - history: ConversationMessage[] -): Promise { - const condensedCommand = JSON.stringify(cmd, null, 2); - const condensedResult = JSON.stringify(outcome, null, 2); - - const { textStream } = await streamText({ - model: openai("gpt-4.1"), - messages: [ - { role: "system", content: responseSystemPrompt }, - ...buildMessageHistory(history), - { - role: "user", - content: [ - `Most recent request: ${input}`, - `Interpreter output:\n${condensedCommand}`, - `Command result:\n${condensedResult}`, - ].join("\n\n"), - }, - ], - }); - - let final = ""; - for await (const textChunk of textStream as AsyncIterable) { - const chunk = - typeof textChunk === "string" - ? textChunk - : typeof (textChunk as { value?: string }).value === "string" - ? (textChunk as { value?: string }).value ?? "" - : ""; - if (!chunk) continue; - process.stdout.write(chunk); - final += chunk; - } - - if (!final.endsWith("\n")) { - process.stdout.write("\n"); - } - - return final.trim(); -} - -export async function startCopilot(): Promise { - if (!process.env.OPENAI_API_KEY) { - console.error("OPENAI_API_KEY is not set. Please export it to use chat."); - process.exitCode = 1; - return; - } - - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - console.log("XRowboat Copilot (type 'exit' to quit)"); - - const debugMode = process.argv.includes("--debug") || process.env.COPILOT_DEBUG === "1"; - const conversationHistory: ConversationMessage[] = []; - - const ask = () => rl.question("> ", async (line) => { - if (!line || line.trim().toLowerCase() === "exit") { - rl.close(); - return; - } +// Ensure base directory exists +async function ensureBaseDir() { try { - const trimmed = line.trim(); - const cmd = await interpret(trimmed, conversationHistory); - let outcome: CommandOutcome; - try { - outcome = await executeCommand(cmd); - } finally { - // no-op - } - - const historyWithLatestUser: ConversationMessage[] = [ - ...conversationHistory, - { role: "user", content: trimmed }, - ]; - const assistantReply = await renderAssistantResponse(trimmed, cmd, outcome, historyWithLatestUser); - console.log(""); - - if (debugMode) { - console.log("=== Parsed Command ===\n" + JSON.stringify(cmd, null, 2)); - console.log("\n=== Outcome ===\n" + JSON.stringify(outcome, null, 2) + "\n"); - } - - conversationHistory.push({ role: "user", content: trimmed }); - conversationHistory.push({ role: "assistant", content: assistantReply }); - } catch (err) { - console.error("Error:", (err as Error).message); + await fs.access(BASE_DIR); + } catch { + await fs.mkdir(BASE_DIR, { recursive: true }); + console.log(`📁 Created directory: ${BASE_DIR}\n`); } - ask(); - }); - - ask(); +} + +// Export the main copilot function +export async function startCopilot() { + // Conversation history + const messages: ModelMessage[] = []; + + console.log("🤖 Rowboat Copilot - Your Intelligent Workflow Assistant"); + console.log(`📂 Working directory: ${BASE_DIR}`); + console.log("💡 I can help you create, manage, and understand workflows."); + console.log("Type 'exit' to quit\n"); + + // Initialize base directory + await ensureBaseDir(); + + while (true) { + // Get user input + const userInput = await rl.question("You: "); + + // Exit condition + if (userInput.toLowerCase() === "exit" || userInput.toLowerCase() === "quit") { + console.log("\n👋 Goodbye!"); + break; + } + + // Add user message to history + messages.push({ role: "user", content: userInput }); + + // Stream AI response + process.stdout.write("\nCopilot: "); + + let currentStep = 0; + const result = streamText({ + model: model, + messages: messages, + system: `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. + +REASONING & THINKING: +- Before taking action, think through what the user is asking for and put out a text with your reasoning process and the steps you will take to complete the task. +- Break down complex tasks into clear steps +- Explore existing files/structure before creating new ones +- Explain your reasoning as you work through tasks +- Be proactive in understanding context + +WORKFLOW KNOWLEDGE: +- Workflows are JSON files that orchestrate multiple agents +- Agents are JSON files defining AI assistants with specific tools and instructions +- Tools can be built-in functions or MCP (Model Context Protocol) integrations +- Common structure for workflows: { "name": "workflow_name", "description": "...", "steps": [{"type": "agent", "id": "agent_id"}, ...] } +- Common structure for agents: { "name": "agent_name", "description": "...", "model": "gpt-4o", "instructions": "...", "tools": {...} } + +CRITICAL NAMING AND ORGANIZATION RULES: +- Agent filenames MUST match the "name" field in their JSON (e.g., agent_name.json → "name": "agent_name") +- Workflow filenames MUST match the "name" field in their JSON (e.g., workflow_name.json → "name": "workflow_name") +- When referencing agents in workflow steps, the "id" field MUST match the agent's name (e.g., {"type": "agent", "id": "agent_name"}) +- All three must be identical: filename, JSON "name" field, and workflow step "id" field +- ALL workflows MUST be placed in the "workflows/" folder (e.g., workflows/workflow_name.json) +- ALL agents MUST be placed in the "agents/" folder (e.g., agents/agent_name.json) +- NEVER create workflows or agents outside these designated folders +- Always maintain this naming and organizational consistency when creating or updating files + +YOUR CAPABILITIES: +1. Explore the directory structure to understand existing workflows/agents +2. Create new workflows and agents following best practices +3. Update existing files intelligently +4. Read and analyze file contents to maintain consistency +5. Suggest improvements and ask clarifying questions when needed + +DELETION RULES: +- When a user asks to delete a WORKFLOW, you MUST: + 1. First read/analyze the workflow to identify which agents it uses + 2. List those agents to the user + 3. Ask the user if they want to delete those agents as well + 4. Wait for their response before proceeding with any deletions + 5. Only delete what the user confirms +- When a user asks to delete an AGENT, you MUST: + 1. First read/analyze the agent to identify which workflows it is used in + 2. List those workflows to the user + 3. Ask the user if they want to delete/modify those workflows as well + 4. Wait for their response before proceeding with any deletions + 5. Only delete/modify what the user confirms + +COMMUNICATION STYLE: +- Start by thinking through the request +- Explain what you're exploring and why +- Show your reasoning process +- Confirm what you've done and suggest next steps +- Be conversational but informative +- Always ask for confirmation before destructive operations!! + +Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`, + + tools: { + exploreDirectory: tool({ + description: 'Recursively explore directory structure to understand existing workflows, agents, and file organization', + inputSchema: z.object({ + subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'), + maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'), + }), + execute: async ({ subdirectory, maxDepth = 3 }) => { + async function explore(dir: string, depth: number = 0): Promise { + if (depth > maxDepth) return null; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const result: any = { files: [], directories: {} }; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isFile()) { + const ext = path.extname(entry.name); + const size = (await fs.stat(fullPath)).size; + result.files.push({ + name: entry.name, + type: ext || 'no-extension', + size: size, + relativePath: path.relative(BASE_DIR, fullPath), + }); + } else if (entry.isDirectory()) { + result.directories[entry.name] = await explore(fullPath, depth + 1); + } + } + + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; + const structure = await explore(dirPath); + + return { + success: true, + basePath: path.relative(BASE_DIR, dirPath) || '.', + structure, + }; + }, + }), + + readFile: tool({ + description: 'Read and parse file contents. For JSON files, provides parsed structure.', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'), + }), + execute: async ({ filename }) => { + try { + const filePath = path.join(BASE_DIR, filename); + const content = await fs.readFile(filePath, 'utf-8'); + + let parsed = null; + let fileType = path.extname(filename); + + if (fileType === '.json') { + try { + parsed = JSON.parse(content); + } catch { + parsed = { error: 'Invalid JSON' }; + } + } + + return { + success: true, + filename, + fileType, + content, + parsed, + path: filePath, + size: content.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + createFile: tool({ + description: 'Create a new file with content. Automatically creates parent directories if needed.', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'), + content: z.string().describe('The content to write to the file'), + description: z.string().optional().describe('Optional description of why this file is being created'), + }), + execute: async ({ filename, content, description }) => { + try { + const filePath = path.join(BASE_DIR, filename); + const dir = path.dirname(filePath); + + // Ensure directory exists + await fs.mkdir(dir, { recursive: true }); + + // Write file + await fs.writeFile(filePath, content, 'utf-8'); + + return { + success: true, + message: `File '${filename}' created successfully`, + description: description || 'No description provided', + path: filePath, + size: content.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + updateFile: tool({ + description: 'Update or overwrite the contents of an existing file', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'), + content: z.string().describe('The new content to write to the file'), + reason: z.string().optional().describe('Optional reason for the update'), + }), + execute: async ({ filename, content, reason }) => { + try { + const filePath = path.join(BASE_DIR, filename); + + // Check if file exists + await fs.access(filePath); + + // Update file + await fs.writeFile(filePath, content, 'utf-8'); + + return { + success: true, + message: `File '${filename}' updated successfully`, + reason: reason || 'No reason provided', + path: filePath, + size: content.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + deleteFile: tool({ + description: 'Delete a file from the .rowboat directory', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'), + }), + execute: async ({ filename }) => { + try { + const filePath = path.join(BASE_DIR, filename); + await fs.unlink(filePath); + + return { + success: true, + message: `File '${filename}' deleted successfully`, + path: filePath, + }; + } catch (error) { + return { + success: false, + message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + listFiles: tool({ + description: 'List all files and directories in the .rowboat directory or subdirectory', + inputSchema: z.object({ + subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'), + }), + execute: async ({ subdirectory }) => { + try { + const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + const files = entries + .filter(entry => entry.isFile()) + .map(entry => ({ + name: entry.name, + type: path.extname(entry.name) || 'no-extension', + relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)), + })); + + const directories = entries + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); + + return { + success: true, + path: dirPath, + relativePath: path.relative(BASE_DIR, dirPath) || '.', + files, + directories, + totalFiles: files.length, + totalDirectories: directories.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + analyzeWorkflow: tool({ + description: 'Read and analyze a workflow file to understand its structure, agents, and dependencies', + inputSchema: z.object({ + workflowName: z.string().describe('Name of the workflow file to analyze (with or without .json extension)'), + }), + execute: async ({ workflowName }) => { + try { + const filename = workflowName.endsWith('.json') ? workflowName : `${workflowName}.json`; + const filePath = path.join(BASE_DIR, 'workflows', filename); + + const content = await fs.readFile(filePath, 'utf-8'); + const workflow = JSON.parse(content); + + // Extract key information + const analysis = { + name: workflow.name, + description: workflow.description || 'No description', + agentCount: workflow.agents ? workflow.agents.length : 0, + agents: workflow.agents || [], + tools: workflow.tools || {}, + structure: workflow, + }; + + return { + success: true, + filePath: path.relative(BASE_DIR, filePath), + analysis, + }; + } catch (error) { + return { + success: false, + message: `Failed to analyze workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + }, + + stopWhen: stepCountIs(15), + + onStepFinish: async ({ toolResults }) => { + currentStep++; + + // Show results with clear formatting + if (toolResults && toolResults.length > 0) { + console.log(`\n[Step ${currentStep}]`); + for (const result of toolResults) { + const res = result as any; + console.log(`🔧 Tool: ${res.toolName}`); + + if (res.result && typeof res.result === 'object') { + const resultData = res.result as any; + if (resultData.success) { + console.log(`✅ ${resultData.message || 'Success'}`); + if (resultData.description) console.log(` → ${resultData.description}`); + if (resultData.reason) console.log(` → ${resultData.reason}`); + } else { + console.log(`❌ ${resultData.message || 'Failed'}`); + } + } + } + console.log(); + } + }, + }); + + // Stream and collect response + let assistantResponse = ""; + for await (const textPart of result.textStream) { + process.stdout.write(textPart); + assistantResponse += textPart; + } + console.log("\n"); + + // Add assistant response to history + messages.push({ role: "assistant", content: assistantResponse }); + + // Keep only the last 20 messages (10 user + 10 assistant pairs) + if (messages.length > 20) { + messages.splice(0, messages.length - 20); + } + } + + rl.close(); } diff --git a/apps/cli/src/application/assistant/commands.ts b/apps/cli/src/application/assistant/commands.ts deleted file mode 100644 index 9ccf86ba..00000000 --- a/apps/cli/src/application/assistant/commands.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { z } from "zod"; -import { - listWorkflows, - getWorkflow, - upsertWorkflow, - deleteWorkflow, -} from "./workflows/service.js"; -import { - listAgents, - getAgent, - upsertAgent, - deleteAgent, -} from "./agents/service.js"; -import { - readMcpConfig, - writeMcpConfig, -} from "./mcp/service.js"; -import { Agent } from "../entities/agent.js"; -import { Workflow } from "../entities/workflow.js"; - -export const ChatCommand = z.object({ - action: z.enum([ - "help", - "general_chat", - "list_workflows", - "get_workflow", - "describe_workflows", - "create_workflow", - "update_workflow", - "delete_workflow", - "list_agents", - "get_agent", - "create_agent", - "update_agent", - "delete_agent", - "list_mcp_servers", - "add_mcp_server", - "remove_mcp_server", - "run_workflow", - "unknown", - ]), - id: z.string().optional(), - query: z.string().optional(), - updates: Workflow.partial().optional(), - server: z - .object({ - name: z.string(), - url: z.string(), - }) - .optional(), - name: z.string().optional(), - clarification: z.string().optional(), - ids: z.array(z.string()).optional(), - scope: z.enum(["all"]).optional(), -}); - -export type ChatCommandT = z.infer; - -export type CommandStatus = "ok" | "error"; - -export interface CommandOutcome { - status: CommandStatus; - headline: string; - details?: string; - list?: string[]; - data?: unknown; -} - -function asCommandOutcome( - outcome: Omit & { status?: CommandStatus } -): CommandOutcome { - return { - status: outcome.status ?? "ok", - headline: outcome.headline, - details: outcome.details, - list: outcome.list, - data: outcome.data, - }; -} - -function normalizeKey(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]/g, ""); -} - -function levenshtein(a: string, b: string): number { - if (a === b) return 0; - if (a.length === 0) return b.length; - if (b.length === 0) return a.length; - - const matrix: number[][] = Array.from({ length: a.length + 1 }, (_, i) => - Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)) - ); - - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, // deletion - matrix[i][j - 1] + 1, // insertion - matrix[i - 1][j - 1] + cost // substitution - ); - } - } - - return matrix[a.length][b.length]; -} - -function resolveWorkflowId( - input: string, - existing: string[] -): { id?: string; suggestion?: string } { - const exact = existing.find((candidate) => candidate === input); - if (exact) return { id: exact }; - - const normalizedInput = normalizeKey(input); - const normalizedMap = new Map(); - for (const candidate of existing) { - const key = normalizeKey(candidate); - if (!normalizedMap.has(key)) normalizedMap.set(key, candidate); - } - const normalizedMatch = normalizedMap.get(normalizedInput); - if (normalizedMatch) return { id: normalizedMatch }; - - const ranked = existing - .map((candidate) => ({ - id: candidate, - distance: levenshtein(normalizeKey(candidate), normalizedInput), - })) - .sort((a, b) => a.distance - b.distance); - - const best = ranked[0]; - if (best && best.distance <= 2) { - return { id: best.id }; - } - - return { suggestion: best?.id }; -} - -export async function executeCommand(cmd: ChatCommandT): Promise { - switch (cmd.action) { - case "help": - return asCommandOutcome({ - headline: "Try asking for workflows, agents, or MCP servers.", - list: [ - "list workflows", - "show workflow example_workflow", - "show all workflows in detail", - "create workflow demo that calls function get_date", - "list agents", - "add mcp server staging at http://localhost:8800", - ], - }); - case "list_workflows": { - const items = listWorkflows(); - return asCommandOutcome({ - headline: - items.length === 0 - ? "No workflows saved yet." - : `Found ${items.length} workflow${items.length === 1 ? "" : "s"}.`, - list: items, - data: { items }, - }); - } - case "get_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Provide the workflow name you want to inspect.", - }); - } - const allWorkflows = listWorkflows(); - const { id: resolvedId, suggestion } = resolveWorkflowId(cmd.id, allWorkflows); - if (!resolvedId) { - return asCommandOutcome({ - status: "error", - headline: `Workflow "${cmd.id}" was not found.`, - details: suggestion ? `Did you mean "${suggestion}"?` : undefined, - }); - } - const workflow = getWorkflow(resolvedId); - if (!workflow) { - return asCommandOutcome({ - status: "error", - headline: `Workflow "${resolvedId}" could not be loaded.`, - }); - } - return asCommandOutcome({ - headline: `Loaded workflow "${resolvedId}".`, - details: workflow.description || "No description set.", - data: workflow, - list: workflow.steps.map((step, index) => `${index + 1}. ${step.type} → ${step.id}`), - }); - } - case "describe_workflows": { - const allWorkflows = listWorkflows(); - const explicitIds = cmd.ids?.map((value) => value.trim()).filter((value) => value.length > 0) ?? []; - const targetIds = - explicitIds.length > 0 ? Array.from(new Set(explicitIds)) : cmd.scope === "all" ? [...allWorkflows] : []; - - if (targetIds.length === 0) { - return asCommandOutcome({ - status: "error", - headline: "No workflows specified.", - details: - explicitIds.length === 0 && cmd.scope !== "all" - ? "Provide workflow ids or set scope to \"all\"." - : "No workflows found to describe.", - }); - } - - const described: Array<{ id: string; workflow: z.infer }> = []; - const missing: string[] = []; - const suggestions: string[] = []; - const seen = new Set(); - - if (explicitIds.length === 0 && cmd.scope === "all") { - for (const id of allWorkflows) { - const workflow = getWorkflow(id); - if (workflow && !seen.has(id)) { - seen.add(id); - described.push({ id, workflow }); - } - } - } else { - for (const requestedId of targetIds) { - const { id: resolvedId, suggestion } = resolveWorkflowId(requestedId, allWorkflows); - if (!resolvedId) { - missing.push(requestedId); - if (suggestion) suggestions.push(`${requestedId} → ${suggestion}`); - continue; - } - if (seen.has(resolvedId)) continue; - seen.add(resolvedId); - const workflow = getWorkflow(resolvedId); - if (workflow) { - described.push({ id: resolvedId, workflow }); - } else { - missing.push(requestedId); - } - } - } - - if (described.length === 0) { - return asCommandOutcome({ - status: "error", - headline: "No workflows found.", - details: `Checked: ${targetIds.join(", ")}`, - }); - } - - const list = described.map(({ workflow }) => { - const description = workflow.description ? workflow.description : "No description set."; - const steps = workflow.steps.map((step, index) => `${index + 1}. ${step.type} → ${step.id}`).join("; "); - return `${workflow.name}: ${description} Steps: ${steps || "None"}.`; - }); - - const details = - missing.length > 0 - ? `Missing workflows: ${missing.join(", ")}.${suggestions.length > 0 ? ` Closest matches: ${suggestions.join(", ")}.` : ""}` - : suggestions.length > 0 - ? `Closest matches: ${suggestions.join(", ")}.` - : undefined; - - return asCommandOutcome({ - headline: `Showing ${described.length} workflow${described.length === 1 ? "" : "s"}.`, - details, - list, - data: { - workflows: described.map(({ workflow }) => workflow), - missing, - }, - }); - } - case "general_chat": - if (!cmd.query) { - return asCommandOutcome({ - status: "error", - headline: "Need the question to answer.", - details: "Repeat your request so I can help.", - }); - } - return asCommandOutcome({ - headline: "General assistance requested.", - details: cmd.query, - data: { query: cmd.query }, - }); - case "create_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Name the workflow you want to create.", - }); - } - const created = upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); - return asCommandOutcome({ - headline: `Workflow "${cmd.id}" saved.`, - data: created, - }); - } - case "update_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Name the workflow you want to update.", - }); - } - const updated = upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); - return asCommandOutcome({ - headline: `Workflow "${cmd.id}" updated.`, - data: updated, - }); - } - case "delete_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Name the workflow you want to delete.", - }); - } - const deleted = deleteWorkflow(cmd.id); - return asCommandOutcome({ - headline: deleted - ? `Workflow "${cmd.id}" deleted.` - : `Workflow "${cmd.id}" did not exist.`, - data: { deleted }, - }); - } - case "list_agents": { - const items = listAgents(); - return asCommandOutcome({ - headline: - items.length === 0 - ? "No agents saved yet." - : `Found ${items.length} agent${items.length === 1 ? "" : "s"}.`, - list: items, - data: { items }, - }); - } - case "get_agent": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Agent id required.", - details: "Provide the agent name you want to inspect.", - }); - } - const agent = getAgent(cmd.id); - if (!agent) { - return asCommandOutcome({ - status: "error", - headline: `Agent "${cmd.id}" was not found.`, - }); - } - return asCommandOutcome({ - headline: `Loaded agent "${cmd.id}".`, - details: agent.description || "No description set.", - data: agent, - }); - } - case "create_agent": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Agent id required.", - details: "Name the agent you want to create.", - }); - } - const created = upsertAgent(cmd.id, { ...(cmd.updates ?? {}) }); - return asCommandOutcome({ - headline: `Agent "${cmd.id}" saved.`, - data: created, - }); - } - case "update_agent": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Agent id required.", - details: "Name the agent you want to update.", - }); - } - const updated = upsertAgent(cmd.id, { ...(cmd.updates ?? {}) }); - return asCommandOutcome({ - headline: `Agent "${cmd.id}" updated.`, - data: updated, - }); - } - case "delete_agent": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Agent id required.", - details: "Name the agent you want to delete.", - }); - } - const deleted = deleteAgent(cmd.id); - return asCommandOutcome({ - headline: deleted - ? `Agent "${cmd.id}" deleted.` - : `Agent "${cmd.id}" did not exist.`, - data: { deleted }, - }); - } - case "list_mcp_servers": { - const config = readMcpConfig(); - const servers = Object.keys(config.mcpServers); - - const list: string[] = []; - for (const server of servers) { - if ('url' in config.mcpServers[server]) { - list.push(`${server} → ${config.mcpServers[server].url}`); - } else { - list.push(`${server} → ${config.mcpServers[server].command}`); - } - } - - return asCommandOutcome({ - headline: - servers.length === 0 - ? "No MCP servers configured." - : `Found ${servers.length} MCP server${servers.length === 1 ? "" : "s"}.`, - list, - data: servers, - }); - } - case "add_mcp_server": { - const serverConfig = cmd.server; - if (!serverConfig) { - return asCommandOutcome({ - status: "error", - headline: "Server details required.", - details: "Provide a name and url for the MCP server.", - }); - } - const config = readMcpConfig(); - config.mcpServers[serverConfig.name] = { - url: serverConfig.url, - headers: {}, - }; - writeMcpConfig(config); - return asCommandOutcome({ - headline: `MCP server "${serverConfig.name}" saved.`, - data: config.mcpServers, - }); - } - case "remove_mcp_server": { - const name = cmd.name; - if (!name) { - return asCommandOutcome({ - status: "error", - headline: "Server name required.", - details: "Tell me which MCP server to remove.", - }); - } - const config = readMcpConfig(); - delete config.mcpServers[name]; - writeMcpConfig(config); - const removed = name in config.mcpServers; - return asCommandOutcome({ - headline: removed - ? `MCP server "${name}" removed.` - : `MCP server "${name}" was not registered.`, - data: config.mcpServers, - }); - } - case "run_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Name the workflow you want to run.", - }); - } - const workflow = getWorkflow(cmd.id); - if (!workflow) { - return asCommandOutcome({ - status: "error", - headline: `Workflow "${cmd.id}" was not found.`, - }); - } - if (workflow.steps.length === 0) { - return asCommandOutcome({ - headline: `Workflow "${cmd.id}" is empty.`, - details: "Add function or agent steps before running.", - data: workflow, - }); - } - return asCommandOutcome({ - headline: `Workflow "${cmd.id}" is ready.`, - details: - "Running from the copilot will be available once the runtime bridge is connected.", - list: workflow.steps.map((step, index) => `${index + 1}. ${step.type} → ${step.id}`), - data: workflow, - }); - } - case "unknown": - return asCommandOutcome({ - status: "error", - headline: "I need more detail before taking action.", - details: cmd.clarification ?? "Try rephrasing or be more specific about the workflow, agent, or MCP server.", - }); - } -} diff --git a/apps/cli/src/application/assistant/mcp/service.ts b/apps/cli/src/application/assistant/mcp/service.ts deleted file mode 100644 index 44014ec7..00000000 --- a/apps/cli/src/application/assistant/mcp/service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { z } from "zod"; -import { McpServerConfig } from "../../entities/mcp.js"; -import { WorkDir } from "../../config/config.js"; - -export function mcpConfigPath(): string { - return path.join(WorkDir, "mcp", "servers.json"); -} - -export function readMcpConfig(): z.infer { - const p = mcpConfigPath(); - if (!fs.existsSync(p)) return { mcpServers: {} }; - const raw = fs.readFileSync(p, "utf8"); - return McpServerConfig.parse(JSON.parse(raw)); -} - -export function writeMcpConfig(value: z.infer): void { - const p = mcpConfigPath(); - const parsed = McpServerConfig.parse(value); - fs.writeFileSync(p, JSON.stringify(parsed, null, 2) + "\n", "utf8"); -} diff --git a/apps/cli/src/application/assistant/services/storage.ts b/apps/cli/src/application/assistant/services/storage.ts deleted file mode 100644 index 4ec0f2c9..00000000 --- a/apps/cli/src/application/assistant/services/storage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { WorkDir } from "../../config/config.js"; - -export type DirKind = "workflows" | "agents" | "mcp"; - -export function dirFor(kind: DirKind): string { - switch (kind) { - case "workflows": - return path.join(WorkDir, "workflows"); - case "agents": - return path.join(WorkDir, "agents"); - case "mcp": - return path.join(WorkDir, "mcp"); - } -} - -export function listJson(kind: DirKind): string[] { - const d = dirFor(kind); - if (!fs.existsSync(d)) return []; - return fs - .readdirSync(d) - .filter((f) => f.endsWith(".json")) - .map((f) => f.replace(/\.json$/, "")); -} - -export function readJson(kind: DirKind, id: string): T | undefined { - const p = path.join(dirFor(kind), `${id}.json`); - if (!fs.existsSync(p)) return undefined; - const raw = fs.readFileSync(p, "utf8"); - return JSON.parse(raw) as T; -} - -export function writeJson(kind: DirKind, id: string, value: unknown): void { - const p = path.join(dirFor(kind), `${id}.json`); - fs.writeFileSync(p, JSON.stringify(value, null, 2) + "\n", "utf8"); -} - -export function deleteJson(kind: DirKind, id: string): boolean { - const p = path.join(dirFor(kind), `${id}.json`); - if (!fs.existsSync(p)) return false; - fs.rmSync(p); - return true; -} diff --git a/apps/cli/src/application/assistant/workflows/service.ts b/apps/cli/src/application/assistant/workflows/service.ts deleted file mode 100644 index d01d0796..00000000 --- a/apps/cli/src/application/assistant/workflows/service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { z } from "zod"; -import { Workflow } from "../../entities/workflow.js"; -import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js"; - -export type WorkflowId = string; - -export function listWorkflows(): WorkflowId[] { - return listJson("workflows"); -} - -export function getWorkflow(id: WorkflowId): z.infer | undefined { - const raw = readJson("workflows", id); - if (!raw) return undefined; - return Workflow.parse(raw); -} - -export function upsertWorkflow( - id: WorkflowId, - value: Partial> -): z.infer { - const existing = readJson("workflows", id) as Partial> | undefined; - const now = new Date().toISOString(); - - const defaults: Partial> = { - name: id, - description: "", - steps: [], - createdAt: existing?.createdAt ?? now, - }; - const merged = { - ...defaults, - ...(existing ?? {}), - ...value, - updatedAt: now, - } satisfies Partial>; - - const parsed = Workflow.parse(merged); - writeJson("workflows", id, parsed); - return parsed; -} - -export function deleteWorkflow(id: WorkflowId): boolean { - return deleteJson("workflows", id); -} diff --git a/apps/cli/src/x.ts b/apps/cli/src/x.ts index 9dbd8edb..31c0834d 100644 --- a/apps/cli/src/x.ts +++ b/apps/cli/src/x.ts @@ -6,3 +6,6 @@ export const start = () => { process.exitCode = 1; }); } + +// Run the copilot when this file is executed directly +start();