first commit

This commit is contained in:
Ramnique Singh 2025-10-28 13:17:06 +05:30
parent 476654af80
commit 6014437479
20 changed files with 2231 additions and 0 deletions

View file

@ -0,0 +1,16 @@
import path from "path";
import fs from "fs";
import { McpServerConfig } from "../entities/mcp.js";
import { z } from "zod";
export const WorkDir = "/Users/ramnique/work/rb/rowboat/apps/cli/.rowboat"
function loadMcpServerConfig(): z.infer<typeof McpServerConfig> {
const configPath = path.join(WorkDir, "config", "mcp.json");
const config = fs.readFileSync(configPath, "utf8");
return McpServerConfig.parse(JSON.parse(config));
}
const { mcpServers } = loadMcpServerConfig();
export const McpServers = mcpServers;

View file

@ -0,0 +1,7 @@
import { z } from "zod";
export const Agent = z.object({
name: z.string(),
model: z.string(),
description: z.string(),
instructions: z.string(),
});

View file

@ -0,0 +1,8 @@
import z from "zod";
export const McpServerConfig = z.object({
mcpServers: z.array(z.object({
name: z.string(),
url: z.string(),
})),
});

View file

@ -0,0 +1,58 @@
import { z } from "zod";
export const TextPart = z.object({
type: z.literal("text"),
text: z.string(),
});
export const ReasoningPart = z.object({
type: z.literal("reasoning"),
text: z.string(),
});
export const ToolCallPart = z.object({
type: z.literal("tool-call"),
toolCallId: z.string(),
toolName: z.string(),
arguments: z.string(),
});
export const AssistantContentPart = z.union([
TextPart,
ReasoningPart,
ToolCallPart,
]);
export const UserMessage = z.object({
role: z.literal("user"),
content: z.string(),
});
export const AssistantMessage = z.object({
role: z.literal("assistant"),
content: z.union([
z.string(),
z.array(AssistantContentPart),
]),
});
export const SystemMessage = z.object({
role: z.literal("system"),
content: z.string(),
});
export const ToolMessage = z.object({
role: z.literal("tool"),
content: z.string(),
toolCallId: z.string(),
toolName: z.string(),
});
export const Message = z.discriminatedUnion("role", [
AssistantMessage,
SystemMessage,
ToolMessage,
UserMessage,
]);
export const MessageList = z.array(Message);

View file

@ -0,0 +1,56 @@
import { z } from "zod";
export const ReasoningStartEvent = z.object({
type: z.literal("reasoning-start"),
});
export const ReasoningDeltaEvent = z.object({
type: z.literal("reasoning-delta"),
delta: z.string(),
});
export const ReasoningEndEvent = z.object({
type: z.literal("reasoning-end"),
});
export const TextStartEvent = z.object({
type: z.literal("text-start"),
});
export const TextDeltaEvent = z.object({
type: z.literal("text-delta"),
delta: z.string(),
});
export const TextEndEvent = z.object({
type: z.literal("text-end"),
});
export const ToolCallEvent = z.object({
type: z.literal("tool-call"),
toolCallId: z.string(),
toolName: z.string(),
input: z.any(),
});
export const UsageEvent = z.object({
type: z.literal("usage"),
usage: z.object({
inputTokens: z.number().optional(),
outputTokens: z.number().optional(),
totalTokens: z.number().optional(),
reasoningTokens: z.number().optional(),
cachedInputTokens: z.number().optional(),
}),
});
export const StreamEvent = z.union([
ReasoningStartEvent,
ReasoningDeltaEvent,
ReasoningEndEvent,
TextStartEvent,
TextDeltaEvent,
TextEndEvent,
ToolCallEvent,
UsageEvent,
]);

View file

@ -0,0 +1,21 @@
import { z } from "zod";
const AgentStep = z.object({
type: z.literal("agent"),
id: z.string(),
});
const FunctionStep = z.object({
type: z.literal("function"),
id: z.string(),
});
const Step = z.discriminatedUnion("type", [AgentStep, FunctionStep]);
export const Workflow = z.object({
name: z.string(),
description: z.string(),
steps: z.array(Step),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
});

View file

@ -0,0 +1,16 @@
import { Node, NodeOutputT } from "../nodes/node.js";
export class GetDate implements Node {
async* execute(): NodeOutputT {
yield {
type: "text-start",
};
yield {
type: "text-delta",
delta: 'The current date is ' + new Date().toISOString(),
};
yield {
type: "text-end",
};
}
}

View file

@ -0,0 +1,31 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
export async function getMcpClient(serverUrl: string, serverName: string): Promise<Client> {
let client: Client | undefined = undefined;
const baseUrl = new URL(serverUrl);
// Try to connect using Streamable HTTP transport
try {
client = new Client({
name: 'streamable-http-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
console.log(`[MCP] Connected using Streamable HTTP transport to ${serverName}`);
return client;
} catch (error) {
// If that fails with a 4xx error, try the older SSE transport
console.log(`[MCP] Streamable HTTP connection failed, falling back to SSE transport for ${serverName}`);
client = new Client({
name: 'sse-client',
version: '1.0.0'
});
const sseTransport = new SSEClientTransport(baseUrl);
await client.connect(sseTransport);
console.log(`[MCP] Connected using SSE transport to ${serverName}`);
return client;
}
}

View file

@ -0,0 +1,7 @@
import { customAlphabet } from 'nanoid';
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz-';
const nanoid = customAlphabet(alphabet, 7);
export async function randomId(): Promise<string> {
return nanoid();
}

View file

@ -0,0 +1,151 @@
import { z } from "zod";
import { StreamEvent } from "../entities/stream-event.js";
export interface StreamRendererOptions {
showHeaders?: boolean;
dimReasoning?: boolean;
jsonIndent?: number;
truncateJsonAt?: number;
}
export class StreamRenderer {
private options: Required<StreamRendererOptions>;
private reasoningActive = false;
private textActive = false;
constructor(options?: StreamRendererOptions) {
this.options = {
showHeaders: true,
dimReasoning: true,
jsonIndent: 2,
truncateJsonAt: 500,
...options,
};
}
render(event: z.infer<typeof StreamEvent>) {
switch (event.type) {
case "reasoning-start":
this.onReasoningStart();
break;
case "reasoning-delta":
this.onReasoningDelta(event.delta);
break;
case "reasoning-end":
this.onReasoningEnd();
break;
case "text-start":
this.onTextStart();
break;
case "text-delta":
this.onTextDelta(event.delta);
break;
case "text-end":
this.onTextEnd();
break;
case "tool-call":
this.onToolCall(event.toolCallId, event.toolName, event.input);
break;
case "usage":
this.onUsage(event.usage);
break;
}
}
private onReasoningStart() {
if (this.reasoningActive) return;
this.reasoningActive = true;
if (this.options.showHeaders) {
this.write("\n");
this.write(this.dim("Reasoning: "));
}
}
private onReasoningDelta(delta: string) {
if (!this.reasoningActive) this.onReasoningStart();
this.write(this.options.dimReasoning ? this.dim(delta) : delta);
}
private onReasoningEnd() {
if (!this.reasoningActive) return;
this.reasoningActive = false;
this.write(this.dim("\n"));
}
private onTextStart() {
if (this.textActive) return;
this.textActive = true;
if (this.options.showHeaders) {
this.write("\n");
this.write(this.bold("Assistant: "));
}
}
private onTextDelta(delta: string) {
if (!this.textActive) this.onTextStart();
this.write(delta);
}
private onTextEnd() {
if (!this.textActive) return;
this.textActive = false;
this.write("\n");
}
private onToolCall(toolCallId: string, toolName: string, input: unknown) {
const inputStr = this.truncate(JSON.stringify(input, null, this.options.jsonIndent));
this.write("\n");
this.write(this.cyan(`→ Tool call ${toolName} (${toolCallId})`));
this.write("\n");
this.write(this.dim(this.indent(inputStr)));
this.write("\n");
}
private onUsage(usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
cachedInputTokens?: number;
}) {
const parts: string[] = [];
if (usage.inputTokens !== undefined) parts.push(`input=${usage.inputTokens}`);
if (usage.outputTokens !== undefined) parts.push(`output=${usage.outputTokens}`);
if (usage.reasoningTokens !== undefined) parts.push(`reasoning=${usage.reasoningTokens}`);
if (usage.cachedInputTokens !== undefined) parts.push(`cached=${usage.cachedInputTokens}`);
if (usage.totalTokens !== undefined) parts.push(`total=${usage.totalTokens}`);
const line = parts.join(", ");
this.write(this.dim(`\nUsage: ${line}\n`));
}
// Formatting helpers
private write(text: string) {
process.stdout.write(text);
}
private indent(text: string): string {
return text
.split("\n")
.map((line) => (line.length ? ` ${line}` : line))
.join("\n");
}
private truncate(text: string): string {
if (text.length <= this.options.truncateJsonAt) return text;
return text.slice(0, this.options.truncateJsonAt) + "…";
}
private bold(text: string): string {
return "\x1b[1m" + text + "\x1b[0m";
}
private dim(text: string): string {
return "\x1b[2m" + text + "\x1b[0m";
}
private cyan(text: string): string {
return "\x1b[36m" + text + "\x1b[0m";
}
}

View file

@ -0,0 +1,130 @@
import { Message } from "../entities/message.js";
import { z } from "zod";
import { Node, NodeInputT, NodeOutputT } from "./node.js";
import { openai } from "@ai-sdk/openai";
import { generateText, ModelMessage, stepCountIs, streamText } from "ai";
import { Agent } from "../entities/agent.js";
import { WorkDir } from "../config/config.js";
import fs from "fs";
import path from "path";
function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
const result: ModelMessage[] = [];
for (const msg of messages) {
switch (msg.role) {
case "assistant":
if (typeof msg.content === 'string') {
result.push({
role: "assistant",
content: msg.content,
});
} else {
result.push({
role: "assistant",
content: msg.content.map(part => {
switch (part.type) {
case 'text':
return part;
case 'reasoning':
return part;
case 'tool-call':
return {
type: 'tool-call',
toolCallId: part.toolCallId,
toolName: part.toolName,
input: part.arguments,
};
}
}),
});
}
break;
case "system":
result.push({
role: "system",
content: msg.content,
});
break;
case "user":
result.push({
role: "user",
content: msg.content,
});
break;
}
}
return result;
}
export class AgentNode implements Node {
private id: string;
constructor(id: string) {
this.id = id;
}
private loadAgent(id: string): z.infer<typeof Agent> {
const agentPath = path.join(WorkDir, "agents", `${id}.json`);
const agent = fs.readFileSync(agentPath, "utf8");
return Agent.parse(JSON.parse(agent));
}
async* execute(input: NodeInputT): NodeOutputT {
const agent = this.loadAgent(this.id);
const { fullStream } = await streamText({
model: openai(agent.model),
messages: convertFromMessages(input),
system: agent.instructions,
stopWhen: stepCountIs(1),
});
for await (const event of fullStream) {
switch (event.type) {
case "reasoning-start":
yield {
type: "reasoning-start",
};
break;
case "reasoning-delta":
yield {
type: "reasoning-delta",
delta: event.text,
};
break;
case "reasoning-end":
yield {
type: "reasoning-end",
};
break;
case "text-start":
yield {
type: "text-start",
};
break;
case "text-delta":
yield {
type: "text-delta",
delta: event.text,
};
break;
case "tool-call":
yield {
type: "tool-call",
toolCallId: event.toolCallId,
toolName: event.toolName,
input: event.input,
};
break;
case "finish":
yield {
type: "usage",
usage: event.totalUsage,
};
break;
default:
// console.warn("Unknown event type", event);
continue;
}
}
}
}

View file

@ -0,0 +1,10 @@
import { MessageList } from "../entities/message.js";
import { StreamEvent } from "../entities/stream-event.js";
import { z } from "zod";
export type NodeInputT = z.infer<typeof MessageList>;
export type NodeOutputT = AsyncGenerator<z.infer<typeof StreamEvent>, void, unknown>;
export interface Node {
execute(input: NodeInputT): NodeOutputT;
}

View file

@ -0,0 +1,6 @@
import { GetDate } from "../functions/get_date.js";
import { Node } from "../nodes/node.js";
export const FunctionsRegistry: Record<string, Node> = {
get_date: new GetDate(),
} as const;