@justinechang39/maki
Version:
AI-powered CLI agent for file operations, CSV manipulation, todo management, and web content fetching using OpenRouter
622 lines (621 loc) • 28.9 kB
JavaScript
import { ThemeProvider, defaultTheme } from '@inkjs/ui';
import { Box, Text, render, useApp, useInput } from 'ink';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ChatInterface } from '../components/ChatInterface.js';
import { ModelSelector } from '../components/ModelSelector.js';
import { ThreadManager } from '../components/ThreadManager.js';
import { ThreadSelector } from '../components/ThreadSelector.js';
import fs from 'fs';
import { AVAILABLE_MODELS, DATABASE_PATH, OPENROUTER_API_KEY, setSelectedModel } from '../core/config.js';
import { ThreadDatabase } from '../core/database.js';
import { createMakiAgent, executeAgent, executeAgentWithProgress } from '../core/langchain-agent.js';
import { createMemoryFromHistory } from '../core/langchain-memory.js';
import { SYSTEM_PROMPT } from '../core/system-prompt.js';
// Helper function to format file sizes
const formatBytes = (bytes) => {
if (bytes === 0)
return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const App = () => {
const [messages, setMessages] = useState([]);
const [isProcessing, setIsProcessing] = useState(false);
const [inputKey, setInputKey] = useState(0);
const [conversationHistory, setConversationHistory] = useState([]);
const [currentThreadId, setCurrentThreadId] = useState(null);
const [isSelectingModel, setIsSelectingModel] = useState(true);
const [isSelectingThread, setIsSelectingThread] = useState(false);
const [isManagingThread, setIsManagingThread] = useState(false);
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
const [selectedThread, setSelectedThread] = useState(null);
const [threads, setThreads] = useState([]);
const [selectedThreadIndex, setSelectedThreadIndex] = useState(0);
const [threadManagementIndex, setThreadManagementIndex] = useState(0);
const [isLoadingThreads, setIsLoadingThreads] = useState(true);
const [isDeletingThread, setIsDeletingThread] = useState(false);
const [lastUsage, setLastUsage] = useState(null);
const isCreatingThread = useRef(false);
const isDeletingThreadRef = useRef(false);
const { exit } = useApp();
const updateTimeoutRef = useRef(null);
// Create agent instance once and reuse it
const agentRef = useRef(null);
// Format tool results for better display - no need for useCallback, it's a pure function
const formatToolResult = (toolName, args, result) => {
if (result.error) {
return `${toolName} failed: ${result.error}`;
}
if (!result.success && result.message) {
return `${toolName}: ${result.message}`;
}
// Format based on tool type
switch (toolName) {
case 'listFiles':
const fileList = result.files
?.slice(0, 8)
.map((f) => `📄 ${f}`)
.join('\n') || '';
const moreFiles = result.files?.length > 8
? `\n... and ${result.files.length - 8} more files`
: '';
return `Listed ${result.fileCount || 0} files in ${result.directory || args.path || '.'}\n${fileList}${moreFiles}`;
case 'listFolders':
const folderList = result.folders
?.slice(0, 8)
.map((f) => `📁 ${f}`)
.join('\n') || '';
const moreFolders = result.folders?.length > 8
? `\n... and ${result.folders.length - 8} more folders`
: '';
return `Listed ${result.folderCount || 0} folders in ${result.directory || args.path || '.'}\n${folderList}${moreFolders}`;
case 'readFile':
const content = result.content || '';
const lines = content.split('\n').length;
const sizeDesc = content.length > 1000
? `${Math.round(content.length / 1000)}KB`
: `${content.length} chars`;
return `Read ${result.path || args.path} (${lines} lines, ${sizeDesc})`;
case 'writeFile':
const sizeDesc2 = args.content?.length > 1000
? `${Math.round(args.content.length / 1000)}KB`
: `${args.content?.length || 0} chars`;
return `File written: ${args.path} (${sizeDesc2})`;
case 'updateFile':
return `File updated: ${args.path} (${args.operation}${result.linesModified ? `, ${result.linesModified} lines` : ''})`;
case 'deleteFile':
return `File deleted: ${args.path}`;
case 'createFolder':
return `Folder created: ${args.path}`;
case 'deleteFolder':
return `Folder deleted: ${args.path}${args.recursive ? ' (recursive)' : ''}`;
case 'findFiles':
const resultCount = result.resultCount || 0;
const resultPreview = result.results
?.slice(0, 4)
.map((r) => {
const icon = r.type === 'filename'
? '📄'
: r.type === 'folder'
? '📁'
: '📝';
return `${icon} ${r.path}${r.line ? `:${r.line}` : ''}${r.preview
? ` - ${r.preview.substring(0, 50)}${r.preview.length > 50 ? '...' : ''}`
: ''}`;
})
.join('\n') || '';
const searchTypeDesc = args.searchType === 'both'
? 'files & content'
: args.searchType === 'content'
? 'content'
: args.searchType === 'folders'
? 'folders'
: args.searchType === 'all'
? 'files, folders & content'
: 'files';
const searchPath = args.path ? ` in ${args.path}` : '';
const typeFilter = args.fileType ? ` (${args.fileType} files)` : '';
return `🔍 Found ${resultCount} ${searchTypeDesc} matches for "${args.pattern}"${searchPath}${typeFilter}${result.hasMore ? ' (limited)' : ''}\n${resultPreview}${resultCount > 4 ? `\n... and ${resultCount - 4} more matches` : ''}`;
case 'getFileInfo':
return `File info: ${result.path}\nType: ${result.type} | Size: ${result.size} bytes | Modified: ${new Date(result.modified).toLocaleDateString()}`;
case 'copyFile':
case 'renameFile':
return `${result.message}`;
case 'todo_read':
const todos = result.todos || [];
const todoPreview = todos
.slice(0, 4)
.map((t) => `${t.status === 'completed'
? '✅'
: t.status === 'in-progress'
? '🔄'
: '⭕'} ${t.content}`)
.join('\n') || 'No todos found';
return `Current todos (${todos.length}):\n${todoPreview}${todos.length > 4 ? `\n... and ${todos.length - 4} more` : ''}`;
case 'todo_write':
return `Updated todo list (${args.todos?.length || 0} items)`;
case 'parseCSV':
const rowCount = result.data?.length || 0;
const colCount = result.headers?.length || 0;
return `Parsed CSV: ${rowCount} rows, ${colCount} columns\nHeaders: ${result.headers
?.slice(0, 4)
.join(', ')}${colCount > 4 ? '...' : ''}`;
case 'writeCSV':
return `CSV written to ${args.path} (${result.rowCount || 0} rows)`;
case 'fetchWebContent':
const contentLength = result.content?.length || 0;
const sizeDesc3 = contentLength > 1000
? `${Math.round(contentLength / 1000)}KB`
: `${contentLength} chars`;
const title = result.title ? `\nTitle: ${result.title}` : '';
return `Fetched ${args.url} (${sizeDesc3})${title}`;
case 'downloadFile':
if (result.success) {
return `📥 Downloaded ${result.filename} (${result.sizeFormatted}) to ${result.filePath}`;
}
return `❌ Download failed: ${result.error || 'Unknown error'}`;
case 'checkUrlStatus':
if (result.success) {
const status = result.accessible
? '✅ Accessible'
: '❌ Not accessible';
return `${status} - ${args.url} (${result.statusCode} ${result.statusText})\nContent-Type: ${result.contentType}\nSize: ${result.contentLength}`;
}
return `❌ URL check failed: ${result.error || 'Unknown error'}`;
case 'extractLinksFromPage':
if (result.success) {
const linkCount = result.totalFound || 0;
const summary = Object.entries(result.links || {})
.map(([type, links]) => `${type}: ${Array.isArray(links) ? links.length : 0}`)
.join(', ');
return `🔗 Found ${linkCount} links on ${args.url}\n${summary}`;
}
return `❌ Link extraction failed: ${result.error || 'Unknown error'}`;
case 'glob':
if (result.success) {
const resultCount = result.resultCount || 0;
const searchPath = result.searchPath || '.';
const pattern = result.pattern || args.pattern;
const hasMore = result.hasMore;
// Format results based on whether they're objects or strings
const results = result.results || [];
let resultPreview = '';
if (results.length > 0) {
const displayLimit = 8;
const itemsToShow = results.slice(0, displayLimit);
resultPreview = itemsToShow
.map((item) => {
if (typeof item === 'string') {
// Simple string path
return `📄 ${item}`;
}
else if (item.path) {
// Object with metadata
const icon = item.dirent?.isDirectory() ? '📁' : '📄';
const sizeInfo = item.size
? ` (${item.sizeFormatted || formatBytes(item.size)})`
: '';
return `${icon} ${item.path}${sizeInfo}`;
}
else {
return `📄 ${JSON.stringify(item)}`;
}
})
.join('\n');
if (results.length > displayLimit) {
resultPreview += `\n... and ${results.length - displayLimit} more items`;
}
}
else {
resultPreview = 'No matches found';
}
const searchDesc = searchPath !== '.' ? ` in ${searchPath}` : '';
const moreIndicator = hasMore ? ' (results limited)' : '';
return `🔍 Found ${resultCount} items matching "${pattern}"${searchDesc}${moreIndicator}\n${resultPreview}`;
}
return `❌ Glob search failed: ${result.error || 'Unknown error'}`;
default:
// Generic success message with result preview
if (result.success || result.message) {
const message = result.message || 'Operation completed';
return message;
}
return `${toolName} completed`;
}
};
// Handle model selection
const handleModelSelect = useCallback((model) => {
setSelectedModel(model);
setIsSelectingModel(false);
setIsSelectingThread(true);
}, []);
// Load threads and create agent after model selection
useEffect(() => {
if (isSelectingModel)
return; // Don't initialize until model is selected
const initializeApp = async () => {
try {
// Check if database exists, if not, create it with migration
if (!fs.existsSync(DATABASE_PATH)) {
console.log('Setting up database for first time...');
// For global installation, we need to create tables manually since we can't run migrations
// The database will be created automatically when Prisma connects
}
// Create agent fresh each time to use current model
agentRef.current = await createMakiAgent((toolName, message) => {
setMessages(prev => [
...prev,
{
role: 'assistant',
content: message,
isToolResult: true,
toolName: toolName,
showToolCalls: false
}
]);
});
const threadList = await ThreadDatabase.getAllThreads();
setThreads(threadList);
setIsLoadingThreads(false);
}
catch (error) {
console.error('Failed to initialize app:', error);
setIsLoadingThreads(false);
}
};
initializeApp();
}, [isSelectingModel]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
}
};
}, []);
// Handle thread selection
const handleThreadSelect = useCallback(async (threadIdOrNew) => {
if (threadIdOrNew === 'new') {
// Create new thread and go straight to chat
try {
// Prevent duplicate creation using ref (immediate check)
if (currentThreadId || isCreatingThread.current) {
setIsSelectingThread(false);
return;
}
isCreatingThread.current = true;
const threadId = await ThreadDatabase.createThread();
setCurrentThreadId(threadId);
const systemMessage = {
role: 'system',
content: SYSTEM_PROMPT
};
setConversationHistory([systemMessage]);
setIsSelectingThread(false);
}
catch (error) {
console.error('Failed to create thread:', error);
}
finally {
isCreatingThread.current = false;
}
}
else {
// Show thread management for existing threads
const thread = threads.find(t => t.id === threadIdOrNew);
if (thread) {
setSelectedThread(thread);
setIsSelectingThread(false);
setIsManagingThread(true);
setThreadManagementIndex(0);
}
}
}, [currentThreadId, threads]);
// Handle thread management actions
const handleContinueThread = useCallback(async () => {
if (!selectedThread)
return;
try {
setCurrentThreadId(selectedThread.id);
// Load thread history
const thread = await ThreadDatabase.getThread(selectedThread.id);
if (thread && thread.messages.length > 0) {
const loadedHistory = thread.messages.map(msg => ({
role: msg.role.toLowerCase(),
content: msg.content,
...(msg.toolCalls && { tool_calls: msg.toolCalls }),
...(msg.role === 'TOOL' &&
msg.toolResponses?.[0] && {
tool_call_id: msg.toolResponses[0].tool_call_id,
name: msg.toolResponses[0].name
})
}));
setConversationHistory(loadedHistory);
// Display loaded messages
const displayMessages = thread.messages
.filter(msg => msg.role !== 'SYSTEM')
.map(msg => ({
role: msg.role.toLowerCase(),
content: msg.content
}));
setMessages(displayMessages);
}
else {
// Add system message if no messages exist
const systemMessage = {
role: 'system',
content: SYSTEM_PROMPT
};
setConversationHistory([systemMessage]);
}
setIsManagingThread(false);
}
catch (error) {
console.error('Failed to continue thread:', error);
}
}, [selectedThread]);
const handleDeleteThread = useCallback(async () => {
if (!selectedThread || isDeletingThreadRef.current)
return;
// Use ref to prevent race conditions
isDeletingThreadRef.current = true;
setIsDeletingThread(true);
try {
console.log('Starting thread deletion for:', selectedThread.id);
await ThreadDatabase.deleteThread(selectedThread.id);
// Refresh thread list
const threadList = await ThreadDatabase.getAllThreads();
setThreads(threadList);
// Go back to thread selection
setIsManagingThread(false);
setIsSelectingThread(true);
setSelectedThread(null);
setSelectedThreadIndex(0);
console.log('Thread deletion completed successfully');
}
catch (error) {
console.error('Failed to delete thread:', error);
}
finally {
isDeletingThreadRef.current = false;
setIsDeletingThread(false);
}
}, [selectedThread]);
const handleBackToThreadList = useCallback(() => {
setIsManagingThread(false);
setIsSelectingThread(true);
setSelectedThread(null);
}, []);
// Generate thread title based on first message
const generateThreadTitle = useCallback(async (userMessage, threadId) => {
try {
const titlePrompt = [
{
role: 'system',
content: "You are a helpful assistant that generates concise, descriptive titles for conversation threads. Based on the user's first message, create a short title (max 50 characters) that captures the main topic or request. Return only the title, nothing else."
},
{
role: 'user',
content: `Generate a title for this conversation: "${userMessage}"`
}
];
const agent = await createMakiAgent();
const response = await executeAgent(agent, `Generate a title for this conversation: "${userMessage}"`, []);
const title = response.output?.trim() || 'Untitled';
// Update thread title in database
await ThreadDatabase.updateThreadTitle(threadId, title);
// Refresh thread list if we're viewing it
if (isSelectingThread || isManagingThread) {
const threadList = await ThreadDatabase.getAllThreads();
setThreads(threadList);
}
}
catch (error) {
console.error('Failed to generate thread title:', error);
}
}, [isSelectingThread, isManagingThread]);
const handleSubmit = useCallback(async (userInput) => {
if (!userInput.trim() || isProcessing)
return;
const trimmedInput = userInput.trim();
if (trimmedInput.toLowerCase() === 'exit' ||
trimmedInput.toLowerCase() === 'quit') {
exit();
return;
}
// Keep existing UI updates
const userMessage = {
role: 'user',
content: trimmedInput
};
setMessages(prev => [...prev, userMessage]);
setIsProcessing(true);
// Keep database persistence
if (currentThreadId) {
await ThreadDatabase.addMessage(currentThreadId, 'USER', trimmedInput);
const isFirstMessage = conversationHistory.length === 1 &&
conversationHistory[0].role === 'system';
if (isFirstMessage && currentThreadId) {
generateThreadTitle(trimmedInput, currentThreadId);
}
}
try {
// Use the pre-created agent instance
if (!agentRef.current) {
throw new Error('Agent not initialized');
}
const chatHistory = createMemoryFromHistory(conversationHistory);
// Execute agent with progress tracking handled by tool wrappers
const response = await executeAgentWithProgress(agentRef.current, trimmedInput, chatHistory);
// Note: Tool calls are now handled by callbacks above, no need for fallback processing
// Store usage data
if (response.usage) {
setLastUsage(response.usage);
}
// Display assistant response
if (response.output && response.output.trim()) {
setMessages(prev => [
...prev,
{
role: 'assistant',
content: response.output,
showToolCalls: false
}
]);
}
// Persist to database
if (currentThreadId && response.output) {
await ThreadDatabase.addMessage(currentThreadId, 'ASSISTANT', response.output);
}
// Update conversation history
const newHistory = [
...conversationHistory,
userMessage,
{
role: 'assistant',
content: response.output
}
];
setConversationHistory(newHistory);
}
catch (error) {
// Keep existing error handling
setMessages(prev => [
...prev,
{
role: 'assistant',
content: `❗ Error: ${error.message}`
}
]);
}
finally {
setIsProcessing(false);
}
}, [
isProcessing,
conversationHistory,
formatToolResult,
exit,
currentThreadId,
generateThreadTitle
]);
const handleInputKeyChange = useCallback(() => {
setInputKey(prev => prev + 1);
}, []);
useInput((input, key) => {
if (isSelectingModel) {
if (key.upArrow) {
setSelectedModelIndex(prev => Math.max(0, prev - 1));
}
else if (key.downArrow) {
setSelectedModelIndex(prev => Math.min(AVAILABLE_MODELS.length - 1, prev + 1));
}
else if (key.return) {
handleModelSelect(AVAILABLE_MODELS[selectedModelIndex]);
}
else if (key.ctrl && input === 'c') {
exit();
}
}
else if (isSelectingThread) {
if (key.upArrow) {
setSelectedThreadIndex(prev => Math.max(0, prev - 1));
}
else if (key.downArrow) {
setSelectedThreadIndex(prev => Math.min(threads.length, prev + 1));
}
else if (key.return) {
const items = [
{ name: 'Start a new thread', value: 'new' },
...threads.map(thread => ({ name: '', value: thread.id }))
];
handleThreadSelect(items[selectedThreadIndex].value);
}
else if (key.ctrl && input === 'c') {
exit();
}
}
else if (isManagingThread) {
if (key.upArrow && !isDeletingThread) {
setThreadManagementIndex(prev => Math.max(0, prev - 1));
}
else if (key.downArrow && !isDeletingThread) {
setThreadManagementIndex(prev => Math.min(2, prev + 1));
}
else if (key.return && !isDeletingThread) {
const actions = [
handleContinueThread,
handleDeleteThread,
handleBackToThreadList
];
actions[threadManagementIndex]();
}
else if (key.ctrl && input === 'c') {
exit();
}
}
else {
// Chat mode - TextInput component handles the input now
if (key.ctrl && input === 'c') {
exit();
}
}
});
if (isSelectingModel) {
return (React.createElement(Box, { flexDirection: "column", height: "100%" },
React.createElement(Box, { paddingY: 1 },
React.createElement(Text, { bold: true, color: "cyan" }, "\u258Cmaki")),
React.createElement(ModelSelector, { selectedIndex: selectedModelIndex, onSelect: handleModelSelect })));
}
if (isLoadingThreads) {
return (React.createElement(Box, { flexDirection: "column", height: "100%", justifyContent: "center", alignItems: "center" },
React.createElement(Box, { flexDirection: "column", alignItems: "center" },
React.createElement(Text, { bold: true, color: "cyan" }, "\u258Cmaki"),
React.createElement(Text, null, "Loading..."))));
}
if (isSelectingThread) {
return (React.createElement(Box, { flexDirection: "column", height: "100%" },
React.createElement(Box, { paddingY: 1 },
React.createElement(Text, { bold: true, color: "cyan" }, "\u258Cmaki")),
React.createElement(ThreadSelector, { threads: threads, selectedIndex: selectedThreadIndex, onSelect: handleThreadSelect })));
}
if (isManagingThread && selectedThread) {
return (React.createElement(Box, { flexDirection: "column", height: "100%" },
React.createElement(Box, { paddingY: 1 },
React.createElement(Text, { bold: true, color: "cyan" }, "\u258Cmaki")),
React.createElement(ThreadManager, { thread: selectedThread, selectedIndex: threadManagementIndex, onContinue: handleContinueThread, onDelete: handleDeleteThread, onBack: handleBackToThreadList, isDeleting: isDeletingThread })));
}
return (React.createElement(ChatInterface, { messages: messages, isProcessing: isProcessing, inputKey: inputKey, onSubmit: handleSubmit, onInputKeyChange: handleInputKeyChange, usage: lastUsage }));
};
export function startInkInterface() {
console.log('🚀 Starting maki...');
console.log(`📁 Working directory: ${process.cwd()}`);
// Check for API key before starting
if (!OPENROUTER_API_KEY) {
console.error('❌ Error: OPENROUTER_API_KEY environment variable is required.');
console.error('');
console.error('Please set your OpenRouter API key:');
console.error(' export OPENROUTER_API_KEY="your-api-key-here"');
console.error('');
console.error('Or add it to your shell profile (~/.bashrc, ~/.zshrc, etc.):');
console.error(' echo \'export OPENROUTER_API_KEY="your-api-key-here"\' >> ~/.zshrc');
console.error('');
console.error('You can get an API key from: https://openrouter.ai/settings/keys');
process.exit(1);
}
console.log('✅ API key found');
console.log('🚀 Starting Ink interface...');
try {
render(React.createElement(ThemeProvider, { theme: defaultTheme },
React.createElement(App, null)));
}
catch (error) {
console.error('❌ Error starting interface:', error);
process.exit(1);
}
}
// Always run when this file is executed
startInkInterface();