First version copilot:

- basic llm call that can perform CRUD actions over dummy workflow json files
This commit is contained in:
tusharmagar 2025-10-30 16:09:19 +08:00 committed by Ramnique Singh
parent 055dda35b9
commit 4310b1d45d
11 changed files with 327 additions and 4 deletions

View file

@ -0,0 +1,6 @@
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
- Data folders ensured automatically: `.rowboat/workflows`, `.rowboat/agents`, `.rowboat/mcp`

View file

@ -0,0 +1,12 @@
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`

View file

@ -0,0 +1,37 @@
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);
}

View file

@ -0,0 +1,105 @@
import readline from "readline";
import { z } from "zod";
import { openai } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { Workflow } from "../../application/entities/workflow.js";
import { listWorkflows, getWorkflow, upsertWorkflow, deleteWorkflow } from "./workflows/service.js";
const ChatCommand = z.object({
action: z.enum([
"help",
"list_workflows",
"get_workflow",
"create_workflow",
"update_workflow",
"delete_workflow",
"unknown",
]),
id: z.string().optional(),
updates: Workflow.partial().optional(),
});
type ChatCommandT = z.infer<typeof ChatCommand>;
const systemPrompt = `
You are a CLI assistant that converts the user's natural language into a JSON command for managing workflows.
Rules:
- Only output JSON matching the provided schema. No extra commentary.
- Choose the most appropriate action from: help, list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow, unknown.
- For actions that need an id (get/update/delete/create), set "id" to the workflow identifier (e.g. "example_workflow").
- For create/update, include only provided fields in "updates". If not provided, omit.
- Workflow shape reminder: { name: string, description: string, steps: Step[] } where Step is either { type: "function", id: string } or { type: "agent", id: string }.
- If the request is ambiguous, set action to "unknown".
`;
async function interpret(input: string): Promise<ChatCommandT> {
const { object } = await generateObject({
model: openai("gpt-4.1"),
system: systemPrompt,
prompt: input,
schema: ChatCommand,
});
return object;
}
async function execute(cmd: ChatCommandT): Promise<unknown> {
switch (cmd.action) {
case "help":
return {
usage: [
"Examples:",
"- list workflows",
"- show workflow example_workflow",
"- create workflow demo with one step calling function get_date",
"- update workflow demo: add agent step default_assistant",
"- delete workflow demo",
],
};
case "list_workflows":
return { items: listWorkflows() };
case "get_workflow":
if (!cmd.id) return { error: "id required" };
return getWorkflow(cmd.id) ?? null;
case "create_workflow":
if (!cmd.id) return { error: "id required" };
return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) });
case "update_workflow":
if (!cmd.id) return { error: "id required" };
return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) });
case "delete_workflow":
if (!cmd.id) return { error: "id required" };
return { deleted: deleteWorkflow(cmd.id) };
case "unknown":
return { error: "Could not determine intent. Try again or ask for help." };
}
}
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("Rowboat Copilot (type 'exit' to quit)");
const ask = () => rl.question("> ", async (line) => {
if (!line || line.trim().toLowerCase() === "exit") {
rl.close();
return;
}
try {
const cmd = await interpret(line);
console.log("\n=== Parsed Command ===\n" + JSON.stringify(cmd, null, 2));
const result = await execute(cmd);
console.log("\n=== Result ===\n" + JSON.stringify(result, null, 2) + "\n");
} catch (err) {
console.error("Error:", (err as Error).message);
}
ask();
});
ask();
}

View file

@ -0,0 +1,24 @@
import fs from "fs";
import path from "path";
import { z } from "zod";
import { McpServerConfig } from "../../entities/mcp.js";
import { ensureBaseDirs, getStoragePaths } from "../services/storage.js";
export function mcpConfigPath(): string {
const base = getStoragePaths();
ensureBaseDirs(base);
return path.join(base.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");
}

View file

@ -0,0 +1,70 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
export type DirKind = "workflows" | "agents" | "mcp";
export interface StoragePaths {
appRoot: string;
workDir: string; // .rowboat
}
const defaultPaths: StoragePaths = (() => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "../../../../");
const workDir = path.join(appRoot, ".rowboat");
return { appRoot, workDir };
})();
export function getStoragePaths(): StoragePaths {
return defaultPaths;
}
export function ensureBaseDirs(base: StoragePaths = defaultPaths) {
const ensure = (p: string) => {
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
};
ensure(base.workDir);
ensure(path.join(base.workDir, "workflows"));
ensure(path.join(base.workDir, "agents"));
ensure(path.join(base.workDir, "mcp"));
}
export function dirFor(kind: DirKind, base: StoragePaths = defaultPaths): string {
switch (kind) {
case "workflows":
return path.join(base.workDir, "workflows");
case "agents":
return path.join(base.workDir, "agents");
case "mcp":
return path.join(base.workDir, "mcp");
}
}
export function listJson(kind: DirKind, base: StoragePaths = defaultPaths): string[] {
const d = dirFor(kind, base);
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, base: StoragePaths = defaultPaths): T | undefined {
const p = path.join(dirFor(kind, base), `${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, base: StoragePaths = defaultPaths): void {
const p = path.join(dirFor(kind, base), `${id}.json`);
fs.writeFileSync(p, JSON.stringify(value, null, 2) + "\n", "utf8");
}
export function deleteJson(kind: DirKind, id: string, base: StoragePaths = defaultPaths): boolean {
const p = path.join(dirFor(kind, base), `${id}.json`);
if (!fs.existsSync(p)) return false;
fs.rmSync(p);
return true;
}

View file

@ -0,0 +1,44 @@
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);
}

View file

@ -1,16 +1,29 @@
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import { McpServerConfig } from "../entities/mcp.js";
import { z } from "zod";
export const WorkDir = "/Users/ramnique/work/rb/rowboat/apps/cli/.rowboat"
// Resolve app root relative to compiled file location (dist/...)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const AppRoot = path.resolve(__dirname, "../../..");
export const WorkDir = path.join(AppRoot, ".rowboat");
function ensureDirs() {
const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); };
ensure(WorkDir);
ensure(path.join(WorkDir, "workflows"));
ensure(path.join(WorkDir, "agents"));
ensure(path.join(WorkDir, "mcp"));
}
function loadMcpServerConfig(): z.infer<typeof McpServerConfig> {
const configPath = path.join(WorkDir, "config", "mcp.json");
ensureDirs();
const configPath = path.join(WorkDir, "mcp", "servers.json");
if (!fs.existsSync(configPath)) return { mcpServers: [] };
const config = fs.readFileSync(configPath, "utf8");
return McpServerConfig.parse(JSON.parse(config));
}
const { mcpServers } = loadMcpServerConfig();
export const McpServers = mcpServers;
export const McpServers = mcpServers;

9
apps/cli/src/x.ts Normal file
View file

@ -0,0 +1,9 @@
import { ensureBaseDirs } from "./application/assistant/services/storage.js";
import { startCopilot } from "./application/assistant/chat.js";
ensureBaseDirs();
startCopilot().catch((err) => {
console.error("Failed to run copilot:", err);
process.exitCode = 1;
});