Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions agent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/usr/bin/env bun

import React from "react";
import { render } from "ink";
import { program } from "@commander-js/extra-typings";
import * as pkg from "./package.json";
import { Config } from "./classes/config";
Expand All @@ -9,6 +11,7 @@ import { setOpenAIClient } from "./utils/client";
import { createMcpServer } from "./utils/tools";

import { GeneralAgent } from "./agents/general";
import { App } from "./components/App";

const config = new Config();
const logger = new Logger(config);
Expand All @@ -25,13 +28,11 @@ async function cleanup() {
}

process.on("SIGINT", async () => {
console.log("SIGINT: 👋 Bye!");
await cleanup();
process.exit(0);
});

process.on("SIGTERM", async () => {
console.log("SIGTERM: 👋 Bye!");
await cleanup();
process.exit(0);
});
Expand Down Expand Up @@ -60,15 +61,19 @@ program
await mcpServer.connect();

const agent = new GeneralAgent(config, logger);
await agent.interactiveChat(
async (input: string) => {
await agent.chat(input, [mcpServer!]);
},
message,
async () => {
await cleanup();
process.exit(0);
},

render(
React.createElement(App, {
agent,
mcpServer: mcpServer!,
logger,
config,
initialMessage: message,
onExit: async () => {
await cleanup();
process.exit(0);
},
}),
);
});

Expand Down
6 changes: 1 addition & 5 deletions agents/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { WrappedAgent } from "../classes/wrappedAgent";
import type { Config } from "../classes/config";
import type { Logger } from "../classes/logger";
import type { MCPServerStreamableHttp } from "@openai/agents";
import chalk from "chalk";

export class GeneralAgent extends WrappedAgent {
constructor(config: Config, logger: Logger) {
Expand All @@ -17,9 +16,6 @@ You are in a terminal window, and the size of the terminal is ${Bun.env.COLUMNS}
}

async chat(prompt: string, mcpServers: MCPServerStreamableHttp[] = []) {
this.logger.startSpan(chalk.gray(`Thinking...`));
const stream = await this.run(prompt, mcpServers);
this.logger.endSpan();
return stream;
return await this.run(prompt, mcpServers);
}
}
103 changes: 95 additions & 8 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions classes/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as path from "path";
import type { LogLevel } from "./logger";

export class Config {
Expand All @@ -7,6 +8,7 @@ export class Config {
public readonly log_color: boolean = true;
public readonly log_timestamps: boolean = true;
public arcade_gateway_url: string | undefined;
public readonly context_dir: string;

constructor() {
const openai_api_key = Bun.env.OPENAI_API_KEY;
Expand All @@ -28,5 +30,7 @@ export class Config {
this.log_level = log_level as LogLevel;

this.arcade_gateway_url = Bun.env.ARCADE_GATEWAY_URL;
this.context_dir =
Bun.env.CONTEXT_DIR || path.join(process.cwd(), ".context", "arcade");
}
}
187 changes: 31 additions & 156 deletions classes/logger.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import chalk from "chalk";
import ora, { type Ora } from "ora";
import { EventEmitter } from "events";
import type { Config } from "./config";
import { Writable } from "stream";

export enum LogLevel {
DEBUG = "debug",
Expand All @@ -10,48 +8,22 @@ export enum LogLevel {
ERROR = "error",
}

const SPAN_UPDATE_INTERVAL = 200;

class LoggerStream extends Writable {
public appendedString: string = "";
private logger: Logger;
private lastUpdatedAt: number = 0;

constructor(logger: Logger) {
super();
this.logger = logger;
}

_write(
chunk: any,
encoding: string,
callback: (error?: Error | null) => void,
) {
const stringChunk = String(chunk);
this.appendedString += stringChunk;
if (Date.now() - this.lastUpdatedAt > SPAN_UPDATE_INTERVAL) {
this.logger.streamToSpan(this.appendedString);
this.lastUpdatedAt = Date.now();
}
callback();
}
export interface LogEvent {
level: LogLevel;
message: string;
timestamp: string;
}

export class Logger {
export class Logger extends EventEmitter {
private level: LogLevel;
private color: boolean;
private includeTimestamps: boolean;
private spanStartTime: number | undefined = undefined;
private spinner: Ora | undefined = undefined;
private toolCallCount: number = 0;
private updateInterval: NodeJS.Timeout | undefined = undefined;
public stream: LoggerStream;

constructor(config: Config) {
super();
this.includeTimestamps = config.log_timestamps;
this.level = config.log_level;
this.color = config.log_color;
this.stream = new LoggerStream(this);
}

public getTimestamp() {
Expand All @@ -62,156 +34,59 @@ export class Logger {
second: "2-digit",
});

return this.includeTimestamps
? this.color
? chalk.gray(`[${timestamp}]`)
: `[${timestamp}]`
: "";
return this.includeTimestamps ? `[${timestamp}]` : "";
}

private getSpanMarker() {
return this.spanStartTime !== undefined ? " ├─" : "";
}

private getDuration() {
return Math.round((Date.now() - (this.spanStartTime ?? Date.now())) / 1000);
}

private getToolCallStats() {
const duration = this.getDuration();
const toolCallText = ` 🕝 duration: ${duration}s | 🛠️ tool calls: ${this.toolCallCount}`;
return this.color ? chalk.dim(toolCallText) : toolCallText;
}

private formatMessage(message: string, color: (text: string) => string) {
return this.color ? color(message) : message;
}

private logToConsole(
message: string,
level: LogLevel,
color: (text: string) => string,
skipTimestamp: boolean = false,
) {
// Check if we should skip logging based on current log level
const shouldSkip =
private shouldSkip(level: LogLevel) {
return (
(this.level === LogLevel.ERROR && level !== LogLevel.ERROR) ||
(this.level === LogLevel.WARN &&
(level === LogLevel.INFO || level === LogLevel.DEBUG)) ||
(this.level === LogLevel.INFO && level === LogLevel.DEBUG);
if (shouldSkip) return;

const timestamp = skipTimestamp ? "" : this.getTimestamp();
const spanMarker = this.getSpanMarker();
const formattedMessage = this.formatMessage(message, color);
const output = `${timestamp}${spanMarker} ${formattedMessage}`;

if (level === LogLevel.ERROR || level === LogLevel.WARN) {
console.error(output);
} else if (level === LogLevel.DEBUG) {
console.debug(output);
} else {
console.log(output);
}
(this.level === LogLevel.INFO && level === LogLevel.DEBUG)
);
}

result(message: string | undefined) {
if (!message) return;
this.logToConsole(message, LogLevel.INFO, chalk.white, true);
private log(message: string, level: LogLevel) {
if (this.shouldSkip(level)) return;
const event: LogEvent = {
level,
message,
timestamp: this.getTimestamp(),
};
this.emit("log", event);
}

info(message: string | undefined) {
if (!message) return;
this.logToConsole(message, LogLevel.INFO, chalk.white);
this.log(message, LogLevel.INFO);
}

warn(message: string | undefined) {
if (!message) return;
this.logToConsole(message, LogLevel.WARN, chalk.yellow);
this.log(message, LogLevel.WARN);
}

error(message: string | undefined) {
if (!message) return;
this.logToConsole(message, LogLevel.ERROR, chalk.red);
this.log(message, LogLevel.ERROR);
}

debug(message: string | undefined) {
if (!message) return;
this.logToConsole(message, LogLevel.DEBUG, chalk.gray);
this.log(message, LogLevel.DEBUG);
}

incrementToolCalls() {
this.toolCallCount++;
this.updateSpanDisplay();
this.emit("toolCall");
}

private updateSpanDisplay() {
if (!this.spinner) return;
const mainMessage = this.spinner.text.split("\n")[0];
this.spinner.text = `${mainMessage}\n${this.getToolCallStats()}`;
onLog(callback: (event: LogEvent) => void) {
this.on("log", callback);
return () => this.off("log", callback);
}

startSpan(message: string) {
this.info(message);
this.spanStartTime = Date.now();
this.toolCallCount = 0;
this.stream.appendedString = "";
this.spinner = ora(this.formatMessage(message, chalk.cyan)).start();

this.updateInterval = setInterval(() => this.updateSpanDisplay(), 1000);
}

updateSpan(message: string, emoji: string) {
if (!this.spinner) return;

const originalText = this.spinner.text;
const timestamp = this.getTimestamp();
const spanMarker = this.getSpanMarker();
const formattedMessage = this.formatMessage(message, chalk.white);

this.spinner.stopAndPersist({
text: formattedMessage,
symbol: `${timestamp}${spanMarker} ${emoji}`,
});

this.spinner.start(this.formatMessage(originalText, chalk.cyan));
this.updateSpanDisplay();
}

streamToSpan(message: string) {
if (!this.spinner) return;

if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = undefined;
}

this.spinner.text = `Streaming: \r\n` + message;
}

endSpan(message?: string) {
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = undefined;
}

this.stream.appendedString = "";

const doneMessage = "Done!";
const timestamp = this.getTimestamp();
const duration = this.getDuration();

this.spinner?.stopAndPersist({
text: this.formatMessage(`${doneMessage} (${duration}s)`, chalk.cyan),
symbol: `${timestamp} ✅`,
});

this.spinner = undefined;
this.spanStartTime = undefined;
this.toolCallCount = 0;

if (message) {
this.result(`\r\n${message}\r\n`);
}
onToolCall(callback: () => void) {
this.on("toolCall", callback);
return () => this.off("toolCall", callback);
}
}
Loading