mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 06:02:42 +00:00
Agent package + coding agent WIP, refactored web-ui prompts
This commit is contained in:
parent
4e7a340460
commit
ffc9be8867
58 changed files with 5138 additions and 2206 deletions
8
packages/coding-agent/src/cli.ts
Normal file
8
packages/coding-agent/src/cli.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "./main.js";
|
||||
|
||||
main(process.argv.slice(2)).catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
3
packages/coding-agent/src/index.ts
Normal file
3
packages/coding-agent/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { main } from "./main.js";
|
||||
export { SessionManager } from "./session-manager.js";
|
||||
export { bashTool, codingTools, editTool, readTool, writeTool } from "./tools/index.js";
|
||||
209
packages/coding-agent/src/main.ts
Normal file
209
packages/coding-agent/src/main.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { Agent, ProviderTransport } from "@mariozechner/pi-agent";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import chalk from "chalk";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import { codingTools } from "./tools/index.js";
|
||||
|
||||
interface Args {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
systemPrompt?: string;
|
||||
continue?: boolean;
|
||||
help?: boolean;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Args {
|
||||
const result: Args = {
|
||||
messages: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
} else if (arg === "--continue" || arg === "-c") {
|
||||
result.continue = true;
|
||||
} else if (arg === "--provider" && i + 1 < args.length) {
|
||||
result.provider = args[++i];
|
||||
} else if (arg === "--model" && i + 1 < args.length) {
|
||||
result.model = args[++i];
|
||||
} else if (arg === "--api-key" && i + 1 < args.length) {
|
||||
result.apiKey = args[++i];
|
||||
} else if (arg === "--system-prompt" && i + 1 < args.length) {
|
||||
result.systemPrompt = args[++i];
|
||||
} else if (!arg.startsWith("-")) {
|
||||
result.messages.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`${chalk.bold("coding-agent")} - AI coding assistant with read, bash, edit, write tools
|
||||
|
||||
${chalk.bold("Usage:")}
|
||||
coding-agent [options] [messages...]
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
--provider <name> Provider name (default: google)
|
||||
--model <id> Model ID (default: gemini-2.5-flash)
|
||||
--api-key <key> API key (defaults to env vars)
|
||||
--system-prompt <text> System prompt (default: coding assistant prompt)
|
||||
--continue, -c Continue previous session
|
||||
--help, -h Show this help
|
||||
|
||||
${chalk.bold("Examples:")}
|
||||
# Single message
|
||||
coding-agent "List all .ts files in src/"
|
||||
|
||||
# Multiple messages
|
||||
coding-agent "Read package.json" "What dependencies do we have?"
|
||||
|
||||
# Continue previous session
|
||||
coding-agent --continue "What did we discuss?"
|
||||
|
||||
# Use different model
|
||||
coding-agent --provider openai --model gpt-4o-mini "Help me refactor this code"
|
||||
|
||||
${chalk.bold("Environment Variables:")}
|
||||
GEMINI_API_KEY - Google Gemini API key
|
||||
OPENAI_API_KEY - OpenAI API key
|
||||
ANTHROPIC_API_KEY - Anthropic API key
|
||||
CODING_AGENT_DIR - Session storage directory (default: ~/.coding-agent)
|
||||
|
||||
${chalk.bold("Available Tools:")}
|
||||
read - Read file contents
|
||||
bash - Execute bash commands
|
||||
edit - Edit files with find/replace
|
||||
write - Write files (creates/overwrites)
|
||||
`);
|
||||
}
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
|
||||
|
||||
Available tools:
|
||||
- read: Read file contents
|
||||
- bash: Execute bash commands (ls, grep, find, etc.)
|
||||
- edit: Make surgical edits to files (find exact text and replace)
|
||||
- write: Create or overwrite files
|
||||
|
||||
Guidelines:
|
||||
- Always use bash tool for file operations like ls, grep, find
|
||||
- Use read to examine files before editing
|
||||
- Use edit for precise changes (old text must match exactly)
|
||||
- Use write only for new files or complete rewrites
|
||||
- Be concise in your responses
|
||||
- Show file paths clearly when working with files
|
||||
|
||||
Current directory: ${process.cwd()}`;
|
||||
|
||||
export async function main(args: string[]) {
|
||||
const parsed = parseArgs(args);
|
||||
|
||||
if (parsed.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup session manager
|
||||
const sessionManager = new SessionManager(parsed.continue);
|
||||
|
||||
// Determine provider and model
|
||||
const provider = (parsed.provider || "google") as any;
|
||||
const modelId = parsed.model || "gemini-2.5-flash";
|
||||
|
||||
// Get API key
|
||||
let apiKey = parsed.apiKey;
|
||||
if (!apiKey) {
|
||||
const envVarMap: Record<string, string> = {
|
||||
google: "GEMINI_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
zai: "ZAI_API_KEY",
|
||||
};
|
||||
const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`;
|
||||
apiKey = process.env[envVar];
|
||||
|
||||
if (!apiKey) {
|
||||
console.error(chalk.red(`Error: No API key found for provider "${provider}"`));
|
||||
console.error(chalk.dim(`Set ${envVar} environment variable or use --api-key flag`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Create agent
|
||||
const model = getModel(provider, modelId);
|
||||
const systemPrompt = parsed.systemPrompt || DEFAULT_SYSTEM_PROMPT;
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: codingTools,
|
||||
},
|
||||
transport: new ProviderTransport({
|
||||
getApiKey: async () => apiKey!,
|
||||
}),
|
||||
});
|
||||
|
||||
// Load previous messages if continuing
|
||||
if (parsed.continue) {
|
||||
const messages = sessionManager.loadMessages();
|
||||
if (messages.length > 0) {
|
||||
console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`));
|
||||
agent.replaceMessages(messages);
|
||||
}
|
||||
}
|
||||
|
||||
// Start session
|
||||
sessionManager.startSession(agent.state);
|
||||
|
||||
// Subscribe to state updates to save messages
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "state-update") {
|
||||
// Save any new messages
|
||||
const currentMessages = event.state.messages;
|
||||
const loadedMessages = sessionManager.loadMessages();
|
||||
|
||||
if (currentMessages.length > loadedMessages.length) {
|
||||
for (let i = loadedMessages.length; i < currentMessages.length; i++) {
|
||||
sessionManager.saveMessage(currentMessages[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionManager.saveEvent(event);
|
||||
});
|
||||
|
||||
// Process messages
|
||||
if (parsed.messages.length === 0) {
|
||||
console.log(chalk.yellow("No messages provided. Use --help for usage information."));
|
||||
console.log(chalk.dim(`Session saved to: ${sessionManager.getSessionFile()}`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of parsed.messages) {
|
||||
console.log(chalk.blue(`\n> ${message}\n`));
|
||||
await agent.prompt(message);
|
||||
|
||||
// Print response
|
||||
const lastMessage = agent.state.messages[agent.state.messages.length - 1];
|
||||
if (lastMessage.role === "assistant") {
|
||||
for (const content of lastMessage.content) {
|
||||
if (content.type === "text") {
|
||||
console.log(content.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`\nSession saved to: ${sessionManager.getSessionFile()}`));
|
||||
}
|
||||
167
packages/coding-agent/src/session-manager.ts
Normal file
167
packages/coding-agent/src/session-manager.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import type { AgentEvent, AgentState } from "@mariozechner/pi-agent";
|
||||
import { randomBytes } from "crypto";
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join, resolve } from "path";
|
||||
|
||||
function uuidv4(): string {
|
||||
const bytes = randomBytes(16);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = bytes.toString("hex");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
export interface SessionHeader {
|
||||
type: "session";
|
||||
id: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface SessionMessageEntry {
|
||||
type: "message";
|
||||
timestamp: string;
|
||||
message: any; // AppMessage from agent state
|
||||
}
|
||||
|
||||
export interface SessionEventEntry {
|
||||
type: "event";
|
||||
timestamp: string;
|
||||
event: AgentEvent;
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessionId!: string;
|
||||
private sessionFile!: string;
|
||||
private sessionDir: string;
|
||||
|
||||
constructor(continueSession: boolean = false) {
|
||||
this.sessionDir = this.getSessionDirectory();
|
||||
|
||||
if (continueSession) {
|
||||
const mostRecent = this.findMostRecentlyModifiedSession();
|
||||
if (mostRecent) {
|
||||
this.sessionFile = mostRecent;
|
||||
this.loadSessionId();
|
||||
} else {
|
||||
this.initNewSession();
|
||||
}
|
||||
} else {
|
||||
this.initNewSession();
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionDirectory(): string {
|
||||
const cwd = process.cwd();
|
||||
const safePath = "--" + cwd.replace(/^\//, "").replace(/\//g, "-") + "--";
|
||||
|
||||
const configDir = resolve(process.env.CODING_AGENT_DIR || join(homedir(), ".coding-agent"));
|
||||
const sessionDir = join(configDir, "sessions", safePath);
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
return sessionDir;
|
||||
}
|
||||
|
||||
private initNewSession(): void {
|
||||
this.sessionId = uuidv4();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
private findMostRecentlyModifiedSession(): string | null {
|
||||
try {
|
||||
const files = readdirSync(this.sessionDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: join(this.sessionDir, f),
|
||||
mtime: statSync(join(this.sessionDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
return files[0]?.path || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadSessionId(): void {
|
||||
if (!existsSync(this.sessionFile)) return;
|
||||
|
||||
const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n");
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "session") {
|
||||
this.sessionId = entry.id;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
this.sessionId = uuidv4();
|
||||
}
|
||||
|
||||
startSession(state: AgentState): void {
|
||||
const entry: SessionHeader = {
|
||||
type: "session",
|
||||
id: this.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
systemPrompt: state.systemPrompt,
|
||||
model: `${state.model.provider}/${state.model.id}`,
|
||||
};
|
||||
appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n");
|
||||
}
|
||||
|
||||
saveMessage(message: any): void {
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n");
|
||||
}
|
||||
|
||||
saveEvent(event: AgentEvent): void {
|
||||
const entry: SessionEventEntry = {
|
||||
type: "event",
|
||||
timestamp: new Date().toISOString(),
|
||||
event,
|
||||
};
|
||||
appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n");
|
||||
}
|
||||
|
||||
loadMessages(): any[] {
|
||||
if (!existsSync(this.sessionFile)) return [];
|
||||
|
||||
const messages: any[] = [];
|
||||
const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "message") {
|
||||
messages.push(entry.message);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
getSessionFile(): string {
|
||||
return this.sessionFile;
|
||||
}
|
||||
}
|
||||
37
packages/coding-agent/src/tools/bash.ts
Normal file
37
packages/coding-agent/src/tools/bash.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const bashSchema = Type.Object({
|
||||
command: Type.String({ description: "Bash command to execute" }),
|
||||
});
|
||||
|
||||
export const bashTool: AgentTool<typeof bashSchema> = {
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
description:
|
||||
"Execute a bash command in the current working directory. Returns stdout and stderr. Commands run with a 30 second timeout.",
|
||||
parameters: bashSchema,
|
||||
execute: async (_toolCallId: string, { command }: { command: string }) => {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 30000,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
});
|
||||
|
||||
let output = "";
|
||||
if (stdout) output += stdout;
|
||||
if (stderr) output += stderr ? `\nSTDERR:\n${stderr}` : "";
|
||||
|
||||
return { output: output || "(no output)", details: undefined };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
output: `Error executing command: ${error.message}\nSTDOUT: ${error.stdout || ""}\nSTDERR: ${error.stderr || ""}`,
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
61
packages/coding-agent/src/tools/edit.ts
Normal file
61
packages/coding-agent/src/tools/edit.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
const editSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
||||
oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }),
|
||||
newText: Type.String({ description: "New text to replace the old text with" }),
|
||||
});
|
||||
|
||||
export const editTool: AgentTool<typeof editSchema> = {
|
||||
name: "edit",
|
||||
label: "edit",
|
||||
description:
|
||||
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
||||
parameters: editSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, oldText, newText }: { path: string; oldText: string; newText: string },
|
||||
) => {
|
||||
try {
|
||||
const absolutePath = resolve(path);
|
||||
|
||||
if (!existsSync(absolutePath)) {
|
||||
return { output: `Error: File not found: ${path}`, details: undefined };
|
||||
}
|
||||
|
||||
const content = readFileSync(absolutePath, "utf-8");
|
||||
|
||||
// Check if old text exists
|
||||
if (!content.includes(oldText)) {
|
||||
return {
|
||||
output: `Error: Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Count occurrences
|
||||
const occurrences = content.split(oldText).length - 1;
|
||||
|
||||
if (occurrences > 1) {
|
||||
return {
|
||||
output: `Error: Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Perform replacement
|
||||
const newContent = content.replace(oldText, newText);
|
||||
writeFileSync(absolutePath, newContent, "utf-8");
|
||||
|
||||
return {
|
||||
output: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
|
||||
details: undefined,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { output: `Error editing file: ${error.message}`, details: undefined };
|
||||
}
|
||||
},
|
||||
};
|
||||
11
packages/coding-agent/src/tools/index.ts
Normal file
11
packages/coding-agent/src/tools/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export { bashTool } from "./bash.js";
|
||||
export { editTool } from "./edit.js";
|
||||
export { readTool } from "./read.js";
|
||||
export { writeTool } from "./write.js";
|
||||
|
||||
import { bashTool } from "./bash.js";
|
||||
import { editTool } from "./edit.js";
|
||||
import { readTool } from "./read.js";
|
||||
import { writeTool } from "./write.js";
|
||||
|
||||
export const codingTools = [readTool, bashTool, editTool, writeTool];
|
||||
29
packages/coding-agent/src/tools/read.ts
Normal file
29
packages/coding-agent/src/tools/read.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
const readSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
|
||||
});
|
||||
|
||||
export const readTool: AgentTool<typeof readSchema> = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: "Read the contents of a file. Returns the full file content as text.",
|
||||
parameters: readSchema,
|
||||
execute: async (_toolCallId: string, { path }: { path: string }) => {
|
||||
try {
|
||||
const absolutePath = resolve(path);
|
||||
|
||||
if (!existsSync(absolutePath)) {
|
||||
return { output: `Error: File not found: ${path}`, details: undefined };
|
||||
}
|
||||
|
||||
const content = readFileSync(absolutePath, "utf-8");
|
||||
return { output: content, details: undefined };
|
||||
} catch (error: any) {
|
||||
return { output: `Error reading file: ${error.message}`, details: undefined };
|
||||
}
|
||||
},
|
||||
};
|
||||
31
packages/coding-agent/src/tools/write.ts
Normal file
31
packages/coding-agent/src/tools/write.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { dirname, resolve } from "path";
|
||||
|
||||
const writeSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
||||
content: Type.String({ description: "Content to write to the file" }),
|
||||
});
|
||||
|
||||
export const writeTool: AgentTool<typeof writeSchema> = {
|
||||
name: "write",
|
||||
label: "write",
|
||||
description:
|
||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
||||
parameters: writeSchema,
|
||||
execute: async (_toolCallId: string, { path, content }: { path: string; content: string }) => {
|
||||
try {
|
||||
const absolutePath = resolve(path);
|
||||
const dir = dirname(absolutePath);
|
||||
|
||||
// Create parent directories if needed
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
writeFileSync(absolutePath, content, "utf-8");
|
||||
return { output: `Successfully wrote ${content.length} bytes to ${path}`, details: undefined };
|
||||
} catch (error: any) {
|
||||
return { output: `Error writing file: ${error.message}`, details: undefined };
|
||||
}
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue