mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 00:46:23 +02:00
copilot update:
- first version can perform CRUD ops on the .rowboat file
This commit is contained in:
parent
80ceba4b11
commit
e914aa2832
9 changed files with 413 additions and 866 deletions
|
|
@ -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`
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<typeof Agent> | undefined {
|
||||
const raw = readJson<unknown>("agents", id);
|
||||
if (!raw) return undefined;
|
||||
return Agent.parse(raw);
|
||||
}
|
||||
|
||||
export function upsertAgent(
|
||||
id: AgentId,
|
||||
value: Partial<z.infer<typeof Agent>>
|
||||
): z.infer<typeof Agent> {
|
||||
const existing = readJson<unknown>("agents", id) as Partial<z.infer<typeof Agent>> | undefined;
|
||||
const merged = {
|
||||
name: id,
|
||||
model: "openai:gpt-4o-mini",
|
||||
description: "",
|
||||
instructions: "",
|
||||
...(existing ?? {}),
|
||||
...value,
|
||||
} satisfies Partial<z.infer<typeof Agent>>;
|
||||
const parsed = Agent.parse(merged);
|
||||
writeJson("agents", id, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function deleteAgent(id: AgentId): boolean {
|
||||
return deleteJson("agents", id);
|
||||
}
|
||||
|
|
@ -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<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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof ChatCommand>;
|
||||
|
||||
export type CommandStatus = "ok" | "error";
|
||||
|
||||
export interface CommandOutcome {
|
||||
status: CommandStatus;
|
||||
headline: string;
|
||||
details?: string;
|
||||
list?: string[];
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
function asCommandOutcome(
|
||||
outcome: Omit<CommandOutcome, "status"> & { 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<string, string>();
|
||||
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<CommandOutcome> {
|
||||
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<typeof Workflow> }> = [];
|
||||
const missing: string[] = [];
|
||||
const suggestions: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof McpServerConfig> {
|
||||
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<typeof McpServerConfig>): void {
|
||||
const p = mcpConfigPath();
|
||||
const parsed = McpServerConfig.parse(value);
|
||||
fs.writeFileSync(p, JSON.stringify(parsed, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
|
@ -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<T>(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;
|
||||
}
|
||||
|
|
@ -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<typeof Workflow> | undefined {
|
||||
const raw = readJson<unknown>("workflows", id);
|
||||
if (!raw) return undefined;
|
||||
return Workflow.parse(raw);
|
||||
}
|
||||
|
||||
export function upsertWorkflow(
|
||||
id: WorkflowId,
|
||||
value: Partial<z.infer<typeof Workflow>>
|
||||
): z.infer<typeof Workflow> {
|
||||
const existing = readJson<unknown>("workflows", id) as Partial<z.infer<typeof Workflow>> | undefined;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const defaults: Partial<z.infer<typeof Workflow>> = {
|
||||
name: id,
|
||||
description: "",
|
||||
steps: [],
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
};
|
||||
const merged = {
|
||||
...defaults,
|
||||
...(existing ?? {}),
|
||||
...value,
|
||||
updatedAt: now,
|
||||
} satisfies Partial<z.infer<typeof Workflow>>;
|
||||
|
||||
const parsed = Workflow.parse(merged);
|
||||
writeJson("workflows", id, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function deleteWorkflow(id: WorkflowId): boolean {
|
||||
return deleteJson("workflows", id);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue