mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 03:01:56 +00:00
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
/**
|
|
* Main entry point for the coding agent
|
|
*/
|
|
|
|
import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
import chalk from "chalk";
|
|
import { type Args, parseArgs, printHelp } from "./cli/args.js";
|
|
import { processFileArguments } from "./cli/file-processor.js";
|
|
import { selectSession } from "./cli/session-picker.js";
|
|
import { getModelsPath, VERSION } from "./config.js";
|
|
import { AgentSession } from "./core/agent-session.js";
|
|
import { exportFromFile } from "./core/export-html.js";
|
|
import { messageTransformer } from "./core/messages.js";
|
|
import { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js";
|
|
import { resolveModelScope, restoreModelFromSession, type ScopedModel } from "./core/model-resolver.js";
|
|
import { SessionManager } from "./core/session-manager.js";
|
|
import { SettingsManager } from "./core/settings-manager.js";
|
|
import { loadSlashCommands } from "./core/slash-commands.js";
|
|
import { buildSystemPrompt, loadProjectContextFiles } from "./core/system-prompt.js";
|
|
import { allTools, codingTools } from "./core/tools/index.js";
|
|
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
|
import { initTheme } from "./modes/interactive/theme/theme.js";
|
|
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
|
|
import { ensureTool } from "./utils/tools-manager.js";
|
|
|
|
/** Check npm registry for new version (non-blocking) */
|
|
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
|
|
try {
|
|
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
|
|
if (!response.ok) return null;
|
|
|
|
const data = (await response.json()) as { version?: string };
|
|
const latestVersion = data.version;
|
|
|
|
if (latestVersion && latestVersion !== currentVersion) {
|
|
return latestVersion;
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
// Silently fail - don't disrupt the user experience
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Run interactive mode with TUI */
|
|
async function runInteractiveMode(
|
|
session: AgentSession,
|
|
version: string,
|
|
changelogMarkdown: string | null,
|
|
modelFallbackMessage: string | null,
|
|
versionCheckPromise: Promise<string | null>,
|
|
initialMessages: string[],
|
|
initialMessage?: string,
|
|
initialAttachments?: Attachment[],
|
|
fdPath: string | null = null,
|
|
): Promise<void> {
|
|
const mode = new InteractiveMode(session, version, changelogMarkdown, fdPath);
|
|
|
|
// Initialize TUI (subscribes to agent events internally)
|
|
await mode.init();
|
|
|
|
// Handle version check result when it completes (don't block)
|
|
versionCheckPromise.then((newVersion) => {
|
|
if (newVersion) {
|
|
mode.showNewVersionNotification(newVersion);
|
|
}
|
|
});
|
|
|
|
// Render any existing messages (from --continue mode)
|
|
mode.renderInitialMessages(session.state);
|
|
|
|
// Show model fallback warning at the end of the chat if applicable
|
|
if (modelFallbackMessage) {
|
|
mode.showWarning(modelFallbackMessage);
|
|
}
|
|
|
|
// Process initial message with attachments if provided (from @file args)
|
|
if (initialMessage) {
|
|
try {
|
|
await session.prompt(initialMessage, { attachments: initialAttachments });
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
mode.showError(errorMessage);
|
|
}
|
|
}
|
|
|
|
// Process remaining initial messages if provided (from CLI args)
|
|
for (const message of initialMessages) {
|
|
try {
|
|
await session.prompt(message);
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
mode.showError(errorMessage);
|
|
}
|
|
}
|
|
|
|
// Interactive loop
|
|
while (true) {
|
|
const userInput = await mode.getUserInput();
|
|
|
|
// Process the message
|
|
try {
|
|
await session.prompt(userInput);
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
mode.showError(errorMessage);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Prepare initial message from @file arguments */
|
|
function prepareInitialMessage(parsed: Args): {
|
|
initialMessage?: string;
|
|
initialAttachments?: Attachment[];
|
|
} {
|
|
if (parsed.fileArgs.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
const { textContent, imageAttachments } = processFileArguments(parsed.fileArgs);
|
|
|
|
// Combine file content with first plain text message (if any)
|
|
let initialMessage: string;
|
|
if (parsed.messages.length > 0) {
|
|
initialMessage = textContent + parsed.messages[0];
|
|
parsed.messages.shift(); // Remove first message as it's been combined
|
|
} else {
|
|
initialMessage = textContent;
|
|
}
|
|
|
|
return {
|
|
initialMessage,
|
|
initialAttachments: imageAttachments.length > 0 ? imageAttachments : undefined,
|
|
};
|
|
}
|
|
|
|
export async function main(args: string[]) {
|
|
const parsed = parseArgs(args);
|
|
|
|
if (parsed.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
// Handle --export flag: convert session file to HTML and exit
|
|
if (parsed.export) {
|
|
try {
|
|
const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;
|
|
const result = exportFromFile(parsed.export, outputPath);
|
|
console.log(`Exported to: ${result}`);
|
|
return;
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : "Failed to export session";
|
|
console.error(chalk.red(`Error: ${message}`));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Validate: RPC mode doesn't support @file arguments
|
|
if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
|
|
console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
|
|
process.exit(1);
|
|
}
|
|
|
|
// Process @file arguments
|
|
const { initialMessage, initialAttachments } = prepareInitialMessage(parsed);
|
|
|
|
// Initialize theme (before any TUI rendering)
|
|
const settingsManager = new SettingsManager();
|
|
const themeName = settingsManager.getTheme();
|
|
initTheme(themeName);
|
|
|
|
// Setup session manager
|
|
const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session);
|
|
|
|
if (parsed.noSession) {
|
|
sessionManager.disable();
|
|
}
|
|
|
|
// Handle --resume flag: show session selector
|
|
if (parsed.resume) {
|
|
const selectedSession = await selectSession(sessionManager);
|
|
if (!selectedSession) {
|
|
console.log(chalk.dim("No session selected"));
|
|
return;
|
|
}
|
|
sessionManager.setSessionFile(selectedSession);
|
|
}
|
|
|
|
// Resolve model scope early if provided
|
|
let scopedModels: ScopedModel[] = [];
|
|
if (parsed.models && parsed.models.length > 0) {
|
|
scopedModels = await resolveModelScope(parsed.models);
|
|
}
|
|
|
|
// Determine mode and output behavior
|
|
const isInteractive = !parsed.print && parsed.mode === undefined;
|
|
const mode = parsed.mode || "text";
|
|
const shouldPrintMessages = isInteractive;
|
|
|
|
// Find initial model
|
|
let initialModel = await findInitialModelForSession(parsed, scopedModels, settingsManager);
|
|
let initialThinking: ThinkingLevel = "off";
|
|
|
|
// Get thinking level from scoped models if applicable
|
|
if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
|
initialThinking = scopedModels[0].thinkingLevel;
|
|
} else {
|
|
// Try saved thinking level
|
|
const savedThinking = settingsManager.getDefaultThinkingLevel();
|
|
if (savedThinking) {
|
|
initialThinking = savedThinking;
|
|
}
|
|
}
|
|
|
|
// Non-interactive mode: fail early if no model available
|
|
if (!isInteractive && !initialModel) {
|
|
console.error(chalk.red("No models available."));
|
|
console.error(chalk.yellow("\nSet an API key environment variable:"));
|
|
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
|
|
console.error(chalk.yellow(`\nOr create ${getModelsPath()}`));
|
|
process.exit(1);
|
|
}
|
|
|
|
// Non-interactive mode: validate API key exists
|
|
if (!isInteractive && initialModel) {
|
|
const apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel));
|
|
if (!apiKey) {
|
|
console.error(chalk.red(`No API key found for ${initialModel.provider}`));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Build system prompt
|
|
const systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools, parsed.appendSystemPrompt);
|
|
|
|
// Handle session restoration
|
|
let modelFallbackMessage: string | null = null;
|
|
|
|
if (parsed.continue || parsed.resume || parsed.session) {
|
|
const savedModel = sessionManager.loadModel();
|
|
if (savedModel) {
|
|
const result = await restoreModelFromSession(
|
|
savedModel.provider,
|
|
savedModel.modelId,
|
|
initialModel,
|
|
shouldPrintMessages,
|
|
);
|
|
|
|
if (result.model) {
|
|
initialModel = result.model;
|
|
}
|
|
modelFallbackMessage = result.fallbackMessage;
|
|
}
|
|
|
|
// Load and restore thinking level
|
|
const thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel;
|
|
if (thinkingLevel) {
|
|
initialThinking = thinkingLevel;
|
|
if (shouldPrintMessages) {
|
|
console.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI --thinking flag takes highest priority
|
|
if (parsed.thinking) {
|
|
initialThinking = parsed.thinking;
|
|
}
|
|
|
|
// Determine which tools to use
|
|
const selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;
|
|
|
|
// Create agent
|
|
const agent = new Agent({
|
|
initialState: {
|
|
systemPrompt,
|
|
model: initialModel as any, // Can be null in interactive mode
|
|
thinkingLevel: initialThinking,
|
|
tools: selectedTools,
|
|
},
|
|
messageTransformer,
|
|
queueMode: settingsManager.getQueueMode(),
|
|
transport: new ProviderTransport({
|
|
getApiKey: async () => {
|
|
const currentModel = agent.state.model;
|
|
if (!currentModel) {
|
|
throw new Error("No model selected");
|
|
}
|
|
|
|
if (parsed.apiKey) {
|
|
return parsed.apiKey;
|
|
}
|
|
|
|
const key = await getApiKeyForModel(currentModel);
|
|
if (!key) {
|
|
throw new Error(
|
|
`No API key found for provider "${currentModel.provider}". Please set the appropriate environment variable or update ${getModelsPath()}`,
|
|
);
|
|
}
|
|
return key;
|
|
},
|
|
}),
|
|
});
|
|
|
|
// If initial thinking was requested but model doesn't support it, reset to off
|
|
if (initialThinking !== "off" && initialModel && !initialModel.reasoning) {
|
|
agent.setThinkingLevel("off");
|
|
}
|
|
|
|
// Load previous messages if continuing, resuming, or using --session
|
|
if (parsed.continue || parsed.resume || parsed.session) {
|
|
const messages = sessionManager.loadMessages();
|
|
if (messages.length > 0) {
|
|
agent.replaceMessages(messages);
|
|
}
|
|
}
|
|
|
|
// Log loaded context files
|
|
if (shouldPrintMessages && !parsed.continue && !parsed.resume) {
|
|
const contextFiles = loadProjectContextFiles();
|
|
if (contextFiles.length > 0) {
|
|
console.log(chalk.dim("Loaded project context from:"));
|
|
for (const { path: filePath } of contextFiles) {
|
|
console.log(chalk.dim(` - ${filePath}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load file commands for slash command expansion
|
|
const fileCommands = loadSlashCommands();
|
|
|
|
// Create session
|
|
const session = new AgentSession({
|
|
agent,
|
|
sessionManager,
|
|
settingsManager,
|
|
scopedModels,
|
|
fileCommands,
|
|
});
|
|
|
|
// Route to appropriate mode
|
|
if (mode === "rpc") {
|
|
await runRpcMode(session);
|
|
} else if (isInteractive) {
|
|
// Check for new version in the background
|
|
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
|
|
|
|
// Check if we should show changelog
|
|
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
|
|
|
|
// Show model scope if provided
|
|
if (scopedModels.length > 0) {
|
|
const modelList = scopedModels
|
|
.map((sm) => {
|
|
const thinkingStr = sm.thinkingLevel !== "off" ? `:${sm.thinkingLevel}` : "";
|
|
return `${sm.model.id}${thinkingStr}`;
|
|
})
|
|
.join(", ");
|
|
console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
|
|
}
|
|
|
|
// Ensure fd tool is available for file autocomplete
|
|
const fdPath = await ensureTool("fd");
|
|
|
|
await runInteractiveMode(
|
|
session,
|
|
VERSION,
|
|
changelogMarkdown,
|
|
modelFallbackMessage,
|
|
versionCheckPromise,
|
|
parsed.messages,
|
|
initialMessage,
|
|
initialAttachments,
|
|
fdPath,
|
|
);
|
|
} else {
|
|
// Non-interactive mode (--print flag or --mode flag)
|
|
await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
|
|
}
|
|
}
|
|
|
|
/** Find initial model based on CLI args, scoped models, settings, or available models */
|
|
async function findInitialModelForSession(parsed: Args, scopedModels: ScopedModel[], settingsManager: SettingsManager) {
|
|
// 1. CLI args take priority
|
|
if (parsed.provider && parsed.model) {
|
|
const { model, error } = findModel(parsed.provider, parsed.model);
|
|
if (error) {
|
|
console.error(chalk.red(error));
|
|
process.exit(1);
|
|
}
|
|
if (!model) {
|
|
console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));
|
|
process.exit(1);
|
|
}
|
|
return model;
|
|
}
|
|
|
|
// 2. Use first model from scoped models (skip if continuing/resuming)
|
|
if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
|
return scopedModels[0].model;
|
|
}
|
|
|
|
// 3. Try saved default from settings
|
|
const defaultProvider = settingsManager.getDefaultProvider();
|
|
const defaultModelId = settingsManager.getDefaultModel();
|
|
if (defaultProvider && defaultModelId) {
|
|
const { model, error } = findModel(defaultProvider, defaultModelId);
|
|
if (error) {
|
|
console.error(chalk.red(error));
|
|
process.exit(1);
|
|
}
|
|
if (model) {
|
|
return model;
|
|
}
|
|
}
|
|
|
|
// 4. Try first available model with valid API key
|
|
const { models: availableModels, error } = await getAvailableModels();
|
|
|
|
if (error) {
|
|
console.error(chalk.red(error));
|
|
process.exit(1);
|
|
}
|
|
|
|
if (availableModels.length > 0) {
|
|
return availableModels[0];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Get changelog markdown to display (only for new sessions with updates) */
|
|
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
|
|
if (parsed.continue || parsed.resume) {
|
|
return null;
|
|
}
|
|
|
|
const lastVersion = settingsManager.getLastChangelogVersion();
|
|
const changelogPath = getChangelogPath();
|
|
const entries = parseChangelog(changelogPath);
|
|
|
|
if (!lastVersion) {
|
|
// First run - show all entries
|
|
if (entries.length > 0) {
|
|
settingsManager.setLastChangelogVersion(VERSION);
|
|
return entries.map((e) => e.content).join("\n\n");
|
|
}
|
|
} else {
|
|
// Check for new entries since last version
|
|
const newEntries = getNewEntries(entries, lastVersion);
|
|
if (newEntries.length > 0) {
|
|
settingsManager.setLastChangelogVersion(VERSION);
|
|
return newEntries.map((e) => e.content).join("\n\n");
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|