copilot update:

- first version can perform CRUD ops on the .rowboat file
This commit is contained in:
tusharmagar 2025-11-13 13:03:54 +05:30
parent 80ceba4b11
commit e914aa2832
9 changed files with 413 additions and 866 deletions

View file

@ -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 laterinclude 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<ChatCommandT> {
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<string> {
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<unknown>) {
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<void> {
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<any> {
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();
}