Agent package + coding agent WIP, refactored web-ui prompts

This commit is contained in:
Mario Zechner 2025-10-17 11:47:01 +02:00
parent 4e7a340460
commit ffc9be8867
58 changed files with 5138 additions and 2206 deletions

View file

@ -0,0 +1,52 @@
{
"name": "@mariozechner/coding-agent",
"version": "0.5.44",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"bin": {
"coding-agent": "dist/cli.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"clean": "rm -rf dist",
"build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"check": "tsc --noEmit",
"test": "vitest --run",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@mariozechner/pi-agent": "^0.5.44",
"@mariozechner/pi-ai": "^0.5.44",
"@mariozechner/pi-tui": "^0.5.44",
"chalk": "^5.5.0",
"glob": "^11.0.3"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
},
"keywords": [
"coding-agent",
"ai",
"llm",
"cli",
"tui",
"agent"
],
"author": "Mario Zechner",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/badlogic/pi-mono.git",
"directory": "packages/coding-agent"
},
"engines": {
"node": ">=20.0.0"
}
}

View 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);
});

View 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";

View 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()}`));
}

View 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;
}
}

View 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,
};
}
},
};

View 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 };
}
},
};

View 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];

View 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 };
}
},
};

View 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 };
}
},
};

View file

@ -0,0 +1,133 @@
import { mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { bashTool } from "../src/tools/bash.js";
import { editTool } from "../src/tools/edit.js";
import { readTool } from "../src/tools/read.js";
import { writeTool } from "../src/tools/write.js";
describe("Coding Agent Tools", () => {
let testDir: string;
beforeEach(() => {
// Create a unique temporary directory for each test
testDir = join(tmpdir(), `coding-agent-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
// Clean up test directory
rmSync(testDir, { recursive: true, force: true });
});
describe("read tool", () => {
it("should read file contents", async () => {
const testFile = join(testDir, "test.txt");
const content = "Hello, world!";
writeFileSync(testFile, content);
const result = await readTool.execute("test-call-1", { path: testFile });
expect(result.output).toBe(content);
expect(result.details).toBeUndefined();
});
it("should handle non-existent files", async () => {
const testFile = join(testDir, "nonexistent.txt");
const result = await readTool.execute("test-call-2", { path: testFile });
expect(result.output).toContain("Error");
expect(result.output).toContain("File not found");
});
});
describe("write tool", () => {
it("should write file contents", async () => {
const testFile = join(testDir, "write-test.txt");
const content = "Test content";
const result = await writeTool.execute("test-call-3", { path: testFile, content });
expect(result.output).toContain("Successfully wrote");
expect(result.output).toContain(testFile);
expect(result.details).toBeUndefined();
});
it("should create parent directories", async () => {
const testFile = join(testDir, "nested", "dir", "test.txt");
const content = "Nested content";
const result = await writeTool.execute("test-call-4", { path: testFile, content });
expect(result.output).toContain("Successfully wrote");
});
});
describe("edit tool", () => {
it("should replace text in file", async () => {
const testFile = join(testDir, "edit-test.txt");
const originalContent = "Hello, world!";
writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-call-5", {
path: testFile,
oldText: "world",
newText: "testing",
});
expect(result.output).toContain("Successfully replaced");
expect(result.details).toBeUndefined();
});
it("should fail if text not found", async () => {
const testFile = join(testDir, "edit-test.txt");
const originalContent = "Hello, world!";
writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-call-6", {
path: testFile,
oldText: "nonexistent",
newText: "testing",
});
expect(result.output).toContain("Could not find the exact text");
});
it("should fail if text appears multiple times", async () => {
const testFile = join(testDir, "edit-test.txt");
const originalContent = "foo foo foo";
writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-call-7", {
path: testFile,
oldText: "foo",
newText: "bar",
});
expect(result.output).toContain("Found 3 occurrences");
});
});
describe("bash tool", () => {
it("should execute simple commands", async () => {
const result = await bashTool.execute("test-call-8", { command: "echo 'test output'" });
expect(result.output).toContain("test output");
expect(result.details).toBeUndefined();
});
it("should handle command errors", async () => {
const result = await bashTool.execute("test-call-9", { command: "exit 1" });
expect(result.output).toContain("Command failed");
});
it("should respect timeout", async () => {
const result = await bashTool.execute("test-call-10", { command: "sleep 35" });
expect(result.output).toContain("Command failed");
}, 35000);
});
});

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
testTimeout: 30000, // 30 seconds for API calls
}
});