This commit is contained in:
2026-05-17 12:43:53 -07:00
commit 7c8def0aaa
7507 changed files with 1419399 additions and 0 deletions

104
src/agent-runner.ts Normal file
View File

@@ -0,0 +1,104 @@
import { createLLM, toOAITools, LLMMessage } from './llm';
import { AgentConfig } from './agents';
import { executeTool, ToolContext } from './tools';
import { resolvePrompt } from './prompts/loader';
import { Job, updateJob } from './job-store';
const MAX_TURNS = 40;
export interface RunResult {
finalText: string;
toolCallCount: number;
turns: number;
model: string;
}
/**
* Core agent execution loop — model-agnostic via the unified LLM client.
*
* Agents use their configured model tier (A/B/C) or a specific model ID.
* Tool calling uses OpenAI format throughout.
*/
export async function runAgent(
job: Job,
config: AgentConfig,
task: string,
ctx: ToolContext
): Promise<RunResult> {
const llm = createLLM(config.model, { temperature: 0.2 });
const oaiTools = toOAITools(config.tools);
const history: LLMMessage[] = [
{ role: 'user', content: task }
];
let toolCallCount = 0;
let turn = 0;
let finalText = '';
updateJob(job.id, { status: 'running', progress: `Starting ${config.name} (${llm.modelId})…` });
while (turn < MAX_TURNS) {
turn++;
const systemPrompt = resolvePrompt(config.promptId);
const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt },
...history
];
const response = await llm.chat(messages, oaiTools, 8192);
// Build assistant message for history
const assistantMsg: LLMMessage = {
role: 'assistant',
content: response.content,
tool_calls: response.tool_calls.length > 0 ? response.tool_calls : undefined
};
history.push(assistantMsg);
// No tool calls — agent is done
if (response.tool_calls.length === 0) {
finalText = response.content ?? '';
break;
}
// Execute tool calls
for (const tc of response.tool_calls) {
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
toolCallCount++;
updateJob(job.id, {
progress: `Turn ${turn}: calling ${fnName}`,
toolCalls: [...(job.toolCalls || []), {
turn,
tool: fnName,
args: fnArgs,
timestamp: new Date().toISOString()
}]
});
let result: unknown;
try {
result = await executeTool(fnName, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
history.push({
role: 'tool',
tool_call_id: tc.id,
name: fnName,
content: typeof result === 'string' ? result : JSON.stringify(result)
});
}
}
if (turn >= MAX_TURNS && !finalText) {
finalText = `Agent hit the ${MAX_TURNS}-turn safety limit. Tool calls made: ${toolCallCount}.`;
}
return { finalText, toolCallCount, turns: turn, model: llm.modelId };
}

422
src/agent-session-runner.ts Normal file
View File

@@ -0,0 +1,422 @@
/**
* agent-session-runner.ts
*
* Streaming variant of runAgent wired to a VIBN agent_sessions row.
* After every LLM turn + tool call, it PATCHes the session in the VIBN DB
* so the frontend can poll (and later WebSocket) the live output.
*
* Key differences from runAgent:
* - Accepts an `emit` callback instead of updating job-store
* - Accepts an `isStopped` check so the frontend can cancel mid-run
* - Tracks which files were written/modified for the changed_files panel
* - Calls vibn-frontend's PATCH /api/projects/[id]/agent/sessions/[sid]
*/
import { execSync } from "child_process";
import { createLLM, toOAITools, LLMMessage } from "./llm";
import { AgentConfig } from "./agents";
import { executeTool, ToolContext } from "./tools";
import { resolvePrompt } from "./prompts/loader";
import { ingestSessionEvents } from "./vibn-events-ingest";
const MAX_TURNS = 60;
export interface OutputLine {
ts: string;
type: "step" | "stdout" | "stderr" | "info" | "error" | "done";
text: string;
}
export interface SessionRunOptions {
sessionId: string;
projectId: string;
vibnApiUrl: string; // e.g. https://vibnai.com
appPath: string; // relative path within repo, e.g. "apps/admin"
repoRoot?: string; // absolute path to the git repo root (for auto-commit)
isStopped: () => boolean;
// Auto-approve: commit + push + deploy without user confirmation
autoApprove?: boolean;
giteaRepo?: string; // e.g. "mark/sportsy"
coolifyAppUuid?: string;
coolifyApiUrl?: string;
coolifyApiToken?: string;
}
// ── VIBN DB bridge ────────────────────────────────────────────────────────────
async function patchSession(
opts: SessionRunOptions,
payload: {
status?: string;
outputLine?: OutputLine;
changedFile?: { path: string; status: string };
error?: string;
},
): Promise<void> {
const url = `${opts.vibnApiUrl}/api/projects/${opts.projectId}/agent/sessions/${opts.sessionId}`;
try {
await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"x-agent-runner-secret": process.env.AGENT_RUNNER_SECRET ?? "",
},
body: JSON.stringify(payload),
});
} catch (err) {
// Log but don't crash — output will be lost for this line but loop continues
console.warn(
"[session-runner] PATCH failed:",
err instanceof Error ? err.message : err,
);
}
}
function now(): string {
return new Date().toISOString();
}
// ── File change tracking ──────────────────────────────────────────────────────
const FILE_WRITE_TOOLS = new Set([
"write_file",
"replace_in_file",
"create_file",
]);
function extractChangedFile(
toolName: string,
args: Record<string, unknown>,
workspaceRoot: string,
appPath: string,
): { path: string; status: string } | null {
if (!FILE_WRITE_TOOLS.has(toolName)) return null;
const rawPath = String(args.path ?? args.file_path ?? "");
if (!rawPath) return null;
// Make path relative to appPath for display
const fullPrefix = `${workspaceRoot}/${appPath}/`;
const appPrefix = `${appPath}/`;
let displayPath = rawPath.replace(fullPrefix, "").replace(appPrefix, "");
const fileStatus = toolName === "write_file" ? "added" : "modified";
return { path: displayPath, status: fileStatus };
}
// ── Auto-commit helper ────────────────────────────────────────────────────────
async function autoCommitAndDeploy(
opts: SessionRunOptions,
task: string,
emit: (line: OutputLine) => Promise<void>,
): Promise<void> {
const repoRoot = opts.repoRoot;
if (!repoRoot || !opts.giteaRepo) {
await emit({
ts: now(),
type: "info",
text: "Auto-approve skipped — no repo root available.",
});
return;
}
const gitOpts = { cwd: repoRoot, stdio: "pipe" as const };
const giteaApiUrl = process.env.GITEA_API_URL || "";
const giteaUsername = process.env.GITEA_USERNAME || "agent";
const giteaToken = process.env.GITEA_API_TOKEN || "";
try {
try {
execSync('git config user.email "agent@vibnai.com"', gitOpts);
execSync('git config user.name "VIBN Agent"', gitOpts);
} catch {
/* already set */
}
execSync("git add -A", gitOpts);
const status = execSync("git status --porcelain", gitOpts)
.toString()
.trim();
if (!status) {
await emit({
ts: now(),
type: "info",
text: "✓ No file changes to commit.",
});
await patchSession(opts, { status: "approved" });
return;
}
const commitMsg = `agent: ${task.slice(0, 72)}`;
const msgFile = require("path").join(
opts.repoRoot || process.cwd(),
".git",
"COMMIT_EDITMSG",
);
require("fs").writeFileSync(msgFile, commitMsg, "utf8");
execSync("git commit -F .git/COMMIT_EDITMSG", gitOpts);
try {
require("fs").unlinkSync(msgFile);
} catch {}
await emit({
ts: now(),
type: "info",
text: `✓ Committed: "${commitMsg}"`,
});
const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git`.replace(
"https://",
`https://${giteaUsername}:${giteaToken}@`,
);
execSync(`git push "${authedUrl}" HEAD:main`, gitOpts);
await emit({ ts: now(), type: "info", text: "✓ Pushed to Gitea." });
// Optional Coolify deploy
let deployed = false;
if (opts.coolifyApiUrl && opts.coolifyApiToken && opts.coolifyAppUuid) {
try {
const deployRes = await fetch(
`${opts.coolifyApiUrl}/api/v1/applications/${opts.coolifyAppUuid}/start`,
{
method: "POST",
headers: { Authorization: `Bearer ${opts.coolifyApiToken}` },
},
);
deployed = deployRes.ok;
if (deployed)
await emit({
ts: now(),
type: "info",
text: "✓ Deployment triggered.",
});
} catch {
/* best-effort */
}
}
await patchSession(opts, {
status: "approved",
outputLine: {
ts: now(),
type: "done",
text: `✓ Auto-committed & ${deployed ? "deployed" : "pushed"}. No approval needed.`,
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({
ts: now(),
type: "error",
text: `Auto-commit failed: ${msg}`,
});
// Fall back to done so user can manually approve
await patchSession(opts, { status: "done" });
}
}
// ── Main streaming execution loop ─────────────────────────────────────────────
export async function runSessionAgent(
config: AgentConfig,
task: string,
ctx: ToolContext,
opts: SessionRunOptions,
): Promise<void> {
const llm = createLLM(config.model, { temperature: 0.2 });
const oaiTools = toOAITools(config.tools);
const emit = async (line: OutputLine) => {
console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
await Promise.all([
patchSession(opts, { outputLine: line }),
ingestSessionEvents(opts.vibnApiUrl, opts.projectId, opts.sessionId, [
{
type: `output.${line.type}`,
payload: { text: line.text },
ts: line.ts,
},
]),
]);
};
await emit({
ts: now(),
type: "info",
text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}`,
});
// Scope the system prompt to the specific app within the monorepo
const basePrompt = resolvePrompt(config.promptId);
const scopedPrompt = `${basePrompt}
## Active context
You are working inside the monorepo directory: ${opts.appPath}
All file paths you use should be relative to this directory unless otherwise specified.
When running commands, always cd into ${opts.appPath} first unless already there.
Do NOT run git commit or git push — the platform handles committing after you finish.
`;
const history: LLMMessage[] = [{ role: "user", content: task }];
let turn = 0;
let finalText = "";
const trackedFiles = new Map<string, string>(); // path → status
while (turn < MAX_TURNS) {
// Check for stop signal between turns
if (opts.isStopped()) {
await emit({ ts: now(), type: "info", text: "Stopped by user." });
await patchSession(opts, { status: "stopped" });
return;
}
turn++;
await emit({ ts: now(), type: "info", text: `Turn ${turn} — thinking…` });
const messages: LLMMessage[] = [
{ role: "system", content: scopedPrompt },
...history,
];
let response: Awaited<ReturnType<typeof llm.chat>>;
try {
response = await llm.chat(messages, oaiTools, 8192);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({ ts: now(), type: "error", text: `LLM error: ${msg}` });
await patchSession(opts, { status: "failed", error: msg });
return;
}
const assistantMsg: LLMMessage = {
role: "assistant",
content: response.content,
tool_calls:
response.tool_calls.length > 0 ? response.tool_calls : undefined,
};
history.push(assistantMsg);
// Agent finished — no more tool calls
if (response.tool_calls.length === 0) {
finalText = response.content ?? "Task complete.";
break;
}
// Execute each tool call
for (const tc of response.tool_calls) {
if (opts.isStopped()) break;
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try {
fnArgs = JSON.parse(tc.function.arguments || "{}");
} catch {
/* bad JSON */
}
// Human-readable step label
const stepLabel = buildStepLabel(fnName, fnArgs);
await emit({ ts: now(), type: "step", text: stepLabel });
let result: unknown;
try {
result = await executeTool(fnName, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Stream stdout/stderr if present
if (result && typeof result === "object") {
const r = result as Record<string, unknown>;
if (r.stdout && String(r.stdout).trim()) {
for (const line of String(r.stdout)
.split("\n")
.filter(Boolean)
.slice(0, 40)) {
await emit({ ts: now(), type: "stdout", text: line });
}
}
if (r.stderr && String(r.stderr).trim()) {
for (const line of String(r.stderr)
.split("\n")
.filter(Boolean)
.slice(0, 20)) {
await emit({ ts: now(), type: "stderr", text: line });
}
}
if (r.error) {
await emit({ ts: now(), type: "error", text: String(r.error) });
}
}
// Track file changes
const changed = extractChangedFile(
fnName,
fnArgs,
ctx.workspaceRoot,
opts.appPath,
);
if (changed && !trackedFiles.has(changed.path)) {
trackedFiles.set(changed.path, changed.status);
await patchSession(opts, { changedFile: changed });
await emit({
ts: now(),
type: "info",
text: `${changed.status === "added" ? "+ Created" : "~ Modified"} ${changed.path}`,
});
}
history.push({
role: "tool",
tool_call_id: tc.id,
name: fnName,
content: typeof result === "string" ? result : JSON.stringify(result),
});
}
}
if (turn >= MAX_TURNS && !finalText) {
finalText = `Hit the ${MAX_TURNS}-turn limit. Stopping.`;
}
await emit({ ts: now(), type: "done", text: finalText });
if (opts.autoApprove) {
await autoCommitAndDeploy(opts, task, emit);
} else {
await patchSession(opts, {
status: "done",
outputLine: {
ts: now(),
type: "done",
text: "✓ Complete — review changes and approve to commit.",
},
});
}
}
// ── Step label helpers ────────────────────────────────────────────────────────
function buildStepLabel(tool: string, args: Record<string, unknown>): string {
switch (tool) {
case "read_file":
return `Read ${args.path ?? args.file_path}`;
case "write_file":
return `Write ${args.path ?? args.file_path}`;
case "replace_in_file":
return `Edit ${args.path ?? args.file_path}`;
case "list_directory":
return `List ${args.path ?? "."}`;
case "find_files":
return `Find files: ${args.pattern}`;
case "search_code":
return `Search: ${args.query}`;
case "execute_command":
return `Run: ${String(args.command ?? "").slice(0, 80)}`;
case "git_commit_and_push":
return `Git commit: "${args.message}"`;
default:
return `${tool}(${JSON.stringify(args).slice(0, 60)})`;
}
}

9
src/agents/atlas.ts Normal file
View File

@@ -0,0 +1,9 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'Atlas',
description: 'PRD agent — guides users through structured product discovery and produces a comprehensive requirements document',
model: 'A', // Gemini Flash — fast, conversational, cost-effective for dialogue
promptId: 'atlas',
tools: pick(['web_search', 'finalize_prd'])
});

15
src/agents/coder.ts Normal file
View File

@@ -0,0 +1,15 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'Coder',
description: 'Senior software engineer — writes, edits, tests, commits, and pushes code',
model: 'B',
promptId: 'coder',
tools: pick([
'read_file', 'write_file', 'replace_in_file', 'list_directory', 'find_files', 'search_code',
'execute_command',
'git_commit_and_push',
'gitea_list_issues', 'gitea_close_issue',
'get_skill'
])
});

View File

@@ -0,0 +1,12 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'ImportAnalyzer',
description: 'Reads an imported codebase end-to-end and produces CODEBASE_MAP.md and MIGRATION_PLAN.md',
model: 'B',
promptId: 'import-analyzer',
tools: pick([
'read_file', 'write_file', 'list_directory', 'find_files', 'search_code',
'git_commit_and_push',
])
});

18
src/agents/index.ts Normal file
View File

@@ -0,0 +1,18 @@
// Import prompt templates first — side effects register them before agents reference promptIds
import '../prompts/orchestrator';
import '../prompts/coder';
import '../prompts/pm';
import '../prompts/marketing';
import '../prompts/atlas';
import '../prompts/import-analyzer';
// Import agent files — side effects register each agent into the registry
import './orchestrator';
import './coder';
import './pm';
import './marketing';
import './atlas';
import './import-analyzer';
// Re-export public API
export { AgentConfig, AGENTS, getAgent, allAgents, pick } from './registry';

13
src/agents/marketing.ts Normal file
View File

@@ -0,0 +1,13 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'Marketing',
description: 'Marketing specialist — copy, blog posts, release notes, landing page content',
model: 'A',
promptId: 'marketing',
tools: pick([
'read_file', 'write_file', 'replace_in_file', 'list_directory', 'find_files', 'search_code',
'git_commit_and_push',
'get_skill'
])
});

View File

@@ -0,0 +1,16 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'Orchestrator',
description: 'Master coordinator — breaks down goals and delegates to specialist agents',
model: 'B',
promptId: 'orchestrator',
tools: pick([
'gitea_create_issue', 'gitea_list_issues', 'gitea_close_issue',
'spawn_agent', 'get_job_status',
'coolify_list_projects', 'coolify_list_applications', 'coolify_deploy', 'coolify_get_logs',
'list_repos', 'list_all_issues', 'list_all_apps', 'get_app_status',
'read_repo_file', 'deploy_app', 'save_memory',
'list_skills', 'get_skill'
])
});

14
src/agents/pm.ts Normal file
View File

@@ -0,0 +1,14 @@
import { registerAgent, pick } from './registry';
registerAgent({
name: 'PM',
description: 'Product manager — docs, issue management, project health reports',
model: 'A',
promptId: 'pm',
tools: pick([
'gitea_create_issue', 'gitea_list_issues', 'gitea_close_issue',
'read_file', 'write_file', 'replace_in_file', 'list_directory', 'find_files', 'search_code',
'git_commit_and_push',
'get_skill'
])
});

41
src/agents/registry.ts Normal file
View File

@@ -0,0 +1,41 @@
import { ToolDefinition, ALL_TOOLS } from '../tools';
export interface AgentConfig {
name: string;
description: string;
model: string; // tier ('A' | 'B' | 'C') or specific model ID
promptId: string; // key into the prompt registry (src/prompts/<id>.ts)
tools: ToolDefinition[];
}
const _registry = new Map<string, AgentConfig>();
export function registerAgent(config: AgentConfig): void {
_registry.set(config.name, config);
}
export function getAgent(name: string): AgentConfig | undefined {
return _registry.get(name);
}
export function allAgents(): AgentConfig[] {
return [..._registry.values()];
}
/**
* Backwards-compatible AGENTS object — populated as agents register.
* server.ts uses AGENTS[name] and Object.values(AGENTS).
*/
export const AGENTS: Record<string, AgentConfig> = new Proxy({} as Record<string, AgentConfig>, {
get(_target, prop: string) { return _registry.get(prop); },
ownKeys() { return [..._registry.keys()]; },
getOwnPropertyDescriptor(_target, prop: string) {
const v = _registry.get(prop);
return v ? { configurable: true, enumerable: true, value: v } : undefined;
}
});
/** Pick tools from ALL_TOOLS by name. */
export function pick(names: string[]): ToolDefinition[] {
return ALL_TOOLS.filter(t => names.includes(t.name));
}

175
src/atlas.ts Normal file
View File

@@ -0,0 +1,175 @@
import { createLLM, toOAITools, LLMMessage } from './llm';
import { ALL_TOOLS, executeTool, ToolContext } from './tools';
import { resolvePrompt } from './prompts/loader';
import { prdStore } from './tools/prd';
const MAX_TURNS = 10; // Atlas is conversational — low turn count, no deep tool loops
// ---------------------------------------------------------------------------
// Session store
// ---------------------------------------------------------------------------
interface AtlasSession {
id: string;
history: LLMMessage[];
prdContent: string | null;
createdAt: string;
lastActiveAt: string;
}
const sessions = new Map<string, AtlasSession>();
function getOrCreateSession(sessionId: string): AtlasSession {
if (!sessions.has(sessionId)) {
sessions.set(sessionId, {
id: sessionId,
history: [],
prdContent: null,
createdAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString()
});
}
const session = sessions.get(sessionId)!;
session.lastActiveAt = new Date().toISOString();
return session;
}
export function clearAtlasSession(sessionId: string): void {
sessions.delete(sessionId);
}
export function listAtlasSessions() {
return Array.from(sessions.values()).map(s => ({
id: s.id,
messages: s.history.length,
prdReady: s.prdContent !== null,
createdAt: s.createdAt,
lastActiveAt: s.lastActiveAt
}));
}
// ---------------------------------------------------------------------------
// Atlas chat result
// ---------------------------------------------------------------------------
export interface AtlasChatResult {
reply: string;
sessionId: string;
history: LLMMessage[];
/** Set when Atlas has called finalize_prd — contains the full PRD markdown */
prdContent: string | null;
model: string;
}
// ---------------------------------------------------------------------------
// Main chat handler
// ---------------------------------------------------------------------------
const ATLAS_TOOLS = ALL_TOOLS.filter(t => ['finalize_prd', 'web_search'].includes(t.name));
export async function atlasChat(
sessionId: string,
userMessage: string,
ctx: ToolContext,
opts?: {
preloadedHistory?: LLMMessage[];
/** When true, the user message is an internal init trigger and should not be stored in history */
isInit?: boolean;
}
): Promise<AtlasChatResult> {
const llm = createLLM(process.env.ATLAS_MODEL ?? 'A', { temperature: 0.5 });
const session = getOrCreateSession(sessionId);
// Seed from DB history if this is a fresh in-memory session
if (opts?.preloadedHistory && opts.preloadedHistory.length > 0 && session.history.length === 0) {
session.history = [...opts.preloadedHistory];
}
const oaiTools = toOAITools(ATLAS_TOOLS);
const systemPrompt = resolvePrompt('atlas');
// Always push the user message so Gemini gets a valid conversation (requires at least one user turn).
// For init triggers, we mark it so we can strip it from the returned history — it's an internal
// prompt, not a real user message, and shouldn't appear in the conversation UI or DB.
const INIT_MARKER = '__atlas_init_marker__';
session.history.push({
role: 'user',
content: opts?.isInit ? INIT_MARKER + userMessage : userMessage
});
const buildMessages = (): LLMMessage[] => [
{ role: 'system', content: systemPrompt },
...session.history.slice(-60).map(m =>
// Strip the init marker before sending to the LLM
m.role === 'user' && typeof m.content === 'string' && m.content.startsWith(INIT_MARKER)
? { ...m, content: m.content.slice(INIT_MARKER.length) }
: m
)
];
let turn = 0;
let finalReply = '';
let prdContent: string | null = session.prdContent;
while (turn < MAX_TURNS) {
turn++;
const response = await llm.chat(buildMessages(), oaiTools, 4096);
const hasContent = response.content !== null && response.content !== '';
const hasToolCalls = response.tool_calls.length > 0;
if (hasContent || hasToolCalls) {
session.history.push({
role: 'assistant',
content: response.content,
tool_calls: hasToolCalls ? response.tool_calls : undefined
});
}
if (!hasToolCalls) {
finalReply = response.content ?? '';
break;
}
// Execute tool calls (only finalize_prd for Atlas)
for (const tc of response.tool_calls) {
let fnArgs: Record<string, unknown> = {};
try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
let result: unknown;
try {
result = await executeTool(tc.function.name, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Check if PRD was just saved
const stored = prdStore.get(ctx.workspaceRoot);
if (stored && !prdContent) {
prdContent = stored;
session.prdContent = stored;
prdStore.delete(ctx.workspaceRoot); // consume it
}
session.history.push({
role: 'tool',
tool_call_id: tc.id,
name: tc.function.name,
content: typeof result === 'string' ? result : JSON.stringify(result)
});
}
}
return {
reply: finalReply,
sessionId,
history: session.history
// Drop the internal init user turn — it's not a real user message
.filter(m => !(m.role === 'user' && typeof m.content === 'string' && m.content.startsWith(INIT_MARKER)))
.filter(m => m.role !== 'assistant' || m.content || m.tool_calls?.length)
.slice(-60),
prdContent,
model: llm.modelId
};
}

69
src/job-store.ts Normal file
View File

@@ -0,0 +1,69 @@
import { v4 as uuidv4 } from 'uuid';
// ---------------------------------------------------------------------------
// Job types
// ---------------------------------------------------------------------------
export type JobStatus = 'queued' | 'running' | 'completed' | 'failed';
export interface ToolCallRecord {
turn: number;
tool: string;
args: unknown;
timestamp: string;
}
export interface Job {
id: string;
agent: string;
task: string;
repo?: string;
status: JobStatus;
progress: string;
toolCalls: ToolCallRecord[];
result?: string;
error?: string;
createdAt: string;
updatedAt: string;
}
// ---------------------------------------------------------------------------
// In-memory store (swap for Redis/DB if scaling horizontally)
// ---------------------------------------------------------------------------
const store = new Map<string, Job>();
export function createJob(agent: string, task: string, repo?: string): Job {
const job: Job = {
id: uuidv4(),
agent,
task,
repo,
status: 'queued',
progress: 'Job queued',
toolCalls: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
store.set(job.id, job);
return job;
}
export function getJob(id: string): Job | undefined {
return store.get(id);
}
export function updateJob(id: string, updates: Partial<Job>): Job | undefined {
const job = store.get(id);
if (!job) return undefined;
const updated = { ...job, ...updates, id, updatedAt: new Date().toISOString() };
store.set(id, updated);
return updated;
}
export function listJobs(limit = 50): Job[] {
const all = Array.from(store.values());
return all
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, limit);
}

562
src/llm.ts Normal file
View File

@@ -0,0 +1,562 @@
import { GoogleAuth } from "google-auth-library";
import { GoogleGenAI } from "@google/genai";
import AnthropicVertex from "@anthropic-ai/vertex-sdk";
import { v4 as uuidv4 } from "uuid";
// =============================================================================
// Unified LLM client — OpenAI-compatible message format throughout
//
// Three backends:
// VertexOpenAIClient — GLM-5 and other Vertex MaaS models (openai-compat endpoint)
// GeminiClient — Gemini Flash/Pro via @google/genai SDK (API key)
// AnthropicVertexClient — Claude models via Anthropic Messages API on Vertex (us-east5)
//
// Model tier defaults (overridable via TIER_A/B/C_MODEL env vars):
// Tier A: gemini-3.1-pro-preview — routing, summaries (API key, high quota)
// Tier B: claude-sonnet-4-6 — coding, feature work (Anthropic Vertex, us-east5)
// Tier C: claude-sonnet-4-6 — complex decisions
// =============================================================================
// ---------------------------------------------------------------------------
// Shared message types (OpenAI format — used everywhere internally)
// ---------------------------------------------------------------------------
export interface LLMMessage {
role: "system" | "user" | "assistant" | "tool";
content: string | null;
tool_calls?: LLMToolCall[];
tool_call_id?: string; // set on role=tool messages
name?: string; // function name on role=tool messages
}
export interface LLMToolCall {
id: string;
type: "function";
function: {
name: string;
arguments: string; // JSON-encoded string
};
}
export interface LLMTool {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
export interface LLMResponse {
content: string | null;
reasoning: string | null; // GLM-5 chain-of-thought
tool_calls: LLMToolCall[];
finish_reason: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
/**
* Strips DeepSeek-specific XML tags like <tool_calls> and <think> from content
* so it doesn't leak into the model's history and cause subsequent hallucinations.
*/
function stripModelMarkup(text: string | null | undefined): string | null {
if (!text) return null;
return (
text
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim() || null
);
}
export interface LLMClient {
modelId: string;
chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens?: number,
): Promise<LLMResponse>;
}
// ---------------------------------------------------------------------------
// Vertex AI OpenAI-compatible client
// Used for: zai-org/glm-5-maas, anthropic/claude-sonnet-4-6, etc.
// ---------------------------------------------------------------------------
let _cachedToken = "";
let _tokenExpiry = 0;
// Build GoogleAuth with explicit service account credentials when available.
// GCP_SA_KEY_BASE64: base64-encoded service account JSON key — safe to pass as
// an env var since it contains no newlines or special shell characters.
// Falls back to the GCP metadata server (works on VMs with correct scopes).
function buildGoogleAuth(): GoogleAuth {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
} catch {
console.warn(
"[llm] GCP_SA_KEY_BASE64 is set but failed to decode/parse — falling back to metadata server",
);
}
}
return new GoogleAuth({
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
}
const _googleAuth = buildGoogleAuth();
async function getVertexToken(): Promise<string> {
const now = Date.now();
if (_cachedToken && now < _tokenExpiry) return _cachedToken;
const client = await _googleAuth.getClient();
const tokenResponse = await client.getAccessToken();
_cachedToken = tokenResponse.token!;
_tokenExpiry = now + 55 * 60 * 1000; // tokens last 1hr, refresh at 55min
return _cachedToken;
}
export class VertexOpenAIClient implements LLMClient {
modelId: string;
private projectId: string;
private region: string;
private temperature: number;
constructor(
modelId: string,
opts?: { projectId?: string; region?: string; temperature?: number },
) {
this.modelId = modelId;
this.projectId =
opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
this.region = opts?.region ?? "global";
this.temperature = opts?.temperature ?? 0.3;
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 4096,
): Promise<LLMResponse> {
const base =
this.region === "global"
? "https://aiplatform.googleapis.com"
: `https://${this.region}-aiplatform.googleapis.com`;
const url = `${base}/v1/projects/${this.projectId}/locations/${this.region}/endpoints/openapi/chat/completions`;
const body: Record<string, unknown> = {
model: this.modelId,
messages,
max_tokens: maxTokens,
temperature: this.temperature,
stream: false,
};
if (tools && tools.length > 0) {
body.tools = tools;
body.tool_choice = "auto";
}
// Retry with exponential backoff on 429 / 503 (rate limit / overload)
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const token = await getVertexToken();
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) {
const data = (await res.json()) as any;
const choice = data.choices?.[0];
const message = choice?.message ?? {};
return {
content: stripModelMarkup(message.content),
reasoning: stripModelMarkup(message.reasoning_content),
tool_calls: message.tool_calls ?? [],
finish_reason: choice?.finish_reason ?? "stop",
usage: data.usage,
};
}
const errText = await res.text();
// Force token refresh on 401
if (res.status === 401) _tokenExpiry = 0;
if (RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
// Check for Retry-After header, otherwise use exponential backoff
const retryAfter = res.headers.get("retry-after");
const waitMs = retryAfter
? Math.min(parseInt(retryAfter, 10) * 1000, 60_000)
: Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
console.warn(
`[llm] Vertex ${res.status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw new Error(`Vertex API ${res.status}: ${errText.slice(0, 400)}`);
}
// TypeScript requires an explicit throw after the loop (unreachable in practice)
throw new Error("Vertex API: exceeded max retries");
}
}
// ---------------------------------------------------------------------------
// Gemini client via @google/genai SDK
// Used for: Tier A (fast/cheap routing, summaries, log parsing)
// Converts to/from OpenAI message format internally.
// ---------------------------------------------------------------------------
export class GeminiClient implements LLMClient {
modelId: string;
private temperature: number;
constructor(modelId = "gemini-3.1-pro-preview", opts?: { temperature?: number }) {
this.modelId = modelId;
this.temperature = opts?.temperature ?? 0.2;
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 8192,
): Promise<LLMResponse> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) throw new Error("GOOGLE_API_KEY not set");
const genai = new GoogleGenAI({ apiKey });
const systemMsg = messages.find((m) => m.role === "system");
const nonSystem = messages.filter((m) => m.role !== "system");
const functionDeclarations = (tools ?? []).map((t) => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters as any,
}));
const response = await genai.models.generateContent({
model: this.modelId,
contents: toGeminiContents(nonSystem),
config: {
systemInstruction: systemMsg?.content ?? undefined,
tools:
functionDeclarations.length > 0
? [{ functionDeclarations }]
: undefined,
temperature: this.temperature,
maxOutputTokens: maxTokens,
},
});
const candidate = response.candidates?.[0];
if (!candidate) throw new Error("No response from Gemini");
const parts = candidate.content?.parts ?? [];
const textContent =
parts
.filter((p) => p.text)
.map((p) => p.text)
.join("") || null;
const fnCalls = parts.filter((p) => p.functionCall);
const tool_calls: LLMToolCall[] = fnCalls.map((p) => ({
id: `call_${uuidv4().replace(/-/g, "").slice(0, 12)}`,
type: "function" as const,
function: {
name: p.functionCall!.name ?? "",
arguments: JSON.stringify(p.functionCall!.args ?? {}),
},
}));
return {
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason: fnCalls.length > 0 ? "tool_calls" : "stop",
};
}
}
/** Convert OpenAI message format → Gemini Content[] format */
function toGeminiContents(messages: LLMMessage[]): any[] {
const contents: any[] = [];
for (const msg of messages) {
if (msg.role === "assistant") {
const parts: any[] = [];
if (msg.content) parts.push({ text: msg.content });
for (const tc of msg.tool_calls ?? []) {
parts.push({
functionCall: {
name: tc.function.name,
args: JSON.parse(tc.function.arguments || "{}"),
},
});
}
contents.push({ role: "model", parts });
} else if (msg.role === "tool") {
// Parse content back — could be JSON or plain text
let resultValue: unknown = msg.content;
try {
resultValue = JSON.parse(msg.content ?? "null");
} catch {
/* keep as string */
}
contents.push({
role: "user",
parts: [
{
functionResponse: {
name: msg.name ?? "tool",
response: { result: resultValue },
},
},
],
});
} else {
contents.push({ role: "user", parts: [{ text: msg.content ?? "" }] });
}
}
return contents;
}
// ---------------------------------------------------------------------------
// Anthropic Vertex client
// Used for: claude-* models via Vertex AI (proper Anthropic Messages API)
// Handles tool_calls by converting to/from Anthropic's tool_use blocks.
// ---------------------------------------------------------------------------
export class AnthropicVertexClient implements LLMClient {
modelId: string;
private projectId: string;
private region: string;
constructor(modelId: string, opts?: { projectId?: string; region?: string }) {
// Strip the "anthropic/" prefix if present — the SDK uses bare model names
this.modelId = modelId.startsWith("anthropic/")
? modelId.slice(10)
: modelId;
this.projectId =
opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
this.region = opts?.region ?? process.env.CLAUDE_REGION ?? "us-east5";
}
private buildClient(): AnthropicVertex {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
googleAuth: new GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
}) as any,
});
} catch {
console.warn(
"[llm] AnthropicVertex: SA key decode failed, falling back to metadata server",
);
}
}
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
});
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 8192,
): Promise<LLMResponse> {
const client = this.buildClient();
const system =
messages.find((m) => m.role === "system")?.content ?? undefined;
const nonSystem = messages.filter((m) => m.role !== "system");
// Convert OpenAI message format → Anthropic format
const anthropicMessages: any[] = nonSystem.map((m) => {
if (m.role === "assistant") {
const parts: any[] = [];
if (m.content) parts.push({ type: "text", text: m.content });
for (const tc of m.tool_calls ?? []) {
parts.push({
type: "tool_use",
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments || "{}"),
});
}
return {
role: "assistant",
content:
parts.length === 1 && parts[0].type === "text"
? parts[0].text
: parts,
};
}
if (m.role === "tool") {
return {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: m.tool_call_id,
content: m.content ?? "",
},
],
};
}
return { role: "user", content: m.content ?? "" };
});
const anthropicTools = (tools ?? []).map((t) => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters,
}));
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await (client.messages.create as Function)({
model: this.modelId,
max_tokens: maxTokens,
system: system ?? undefined,
messages: anthropicMessages,
tools: anthropicTools.length > 0 ? anthropicTools : undefined,
});
const textContent =
response.content
.filter((b: any) => b.type === "text")
.map((b: any) => b.text)
.join("") || null;
const tool_calls: LLMToolCall[] = response.content
.filter((b: any) => b.type === "tool_use")
.map((b: any) => ({
id: b.id,
type: "function" as const,
function: {
name: b.name,
arguments: JSON.stringify(b.input ?? {}),
},
}));
return {
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason:
response.stop_reason === "tool_use" ? "tool_calls" : "stop",
usage: response.usage
? {
prompt_tokens: response.usage.input_tokens,
completion_tokens: response.usage.output_tokens,
total_tokens:
response.usage.input_tokens + response.usage.output_tokens,
}
: undefined,
};
} catch (err: any) {
const status = err?.status ?? err?.statusCode ?? 0;
if (RETRY_STATUSES.has(status) && attempt < MAX_RETRIES) {
const waitMs = Math.min(
2 ** attempt * 2000 + Math.random() * 500,
30_000,
);
console.warn(
`[llm] Anthropic Vertex ${status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw new Error(
`Anthropic Vertex error: ${err?.message ?? String(err)}`,
);
}
}
throw new Error("Anthropic Vertex: exceeded max retries");
}
}
// ---------------------------------------------------------------------------
// Factory — createLLM(modelId | tier)
// ---------------------------------------------------------------------------
export type ModelTier = "A" | "B" | "C";
const TIER_MODELS: Record<ModelTier, string> = {
A: process.env.TIER_A_MODEL ?? "gemini-3.1-pro-preview",
B: process.env.TIER_B_MODEL ?? "claude-sonnet-4-6",
C: process.env.TIER_C_MODEL ?? "claude-sonnet-4-6",
};
export function createLLM(
modelOrTier: string | ModelTier,
opts?: { temperature?: number },
): LLMClient {
const modelId =
modelOrTier === "A" || modelOrTier === "B" || modelOrTier === "C"
? TIER_MODELS[modelOrTier]
: modelOrTier;
if (modelId.startsWith("gemini-")) {
return new GeminiClient(modelId, opts);
}
if (modelId.startsWith("anthropic/") || modelId.startsWith("claude-")) {
return new AnthropicVertexClient(modelId);
}
return new VertexOpenAIClient(modelId, { temperature: opts?.temperature });
}
// ---------------------------------------------------------------------------
// Helper — convert our ToolDefinition[] → LLMTool[] (OpenAI format)
// ---------------------------------------------------------------------------
export function toOAITools(
tools: Array<{
name: string;
description: string;
parameters: Record<string, unknown>;
}>,
): LLMTool[] {
return tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}

104
src/mcp/agent-server.ts Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env node
// =============================================================================
// vibn-agent-mcp
// -----------------------------------------------------------------------------
// Stdio MCP server exposing the vibn-agent-runner sub-agent orchestration API.
// This lets any MCP-speaking client (Goose, Claude Desktop, Cursor, etc.)
// spawn Coder / PM / Marketing jobs against the vibn-agent-runner HTTP service
// and poll their status.
//
// Config (env):
// AGENT_RUNNER_URL (default: http://localhost:3333) — URL of the runner
// =============================================================================
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import * as api from '../tools/agent-api';
import type { AgentRunnerConfig } from '../tools/agent-api';
function loadConfig(): AgentRunnerConfig {
const runnerUrl = process.env.AGENT_RUNNER_URL?.trim() || 'http://localhost:3333';
return { runnerUrl };
}
const TOOL_DEFINITIONS = [
{
name: 'spawn_agent',
description: 'Dispatch a sub-agent job to run in the background on the vibn-agent-runner. Returns a job ID.',
inputSchema: {
type: 'object' as const,
properties: {
agent: { type: 'string', description: '"Coder", "PM", or "Marketing"' },
task: { type: 'string', description: 'Detailed task description for the agent' },
repo: { type: 'string', description: 'Gitea repo in "owner/name" format' },
},
required: ['agent', 'task', 'repo'],
},
},
{
name: 'get_job_status',
description: 'Check the status of a previously spawned agent job.',
inputSchema: {
type: 'object' as const,
properties: {
job_id: { type: 'string', description: 'Job ID returned by spawn_agent' },
},
required: ['job_id'],
},
},
];
async function dispatch(cfg: AgentRunnerConfig, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case 'spawn_agent':
return api.spawnAgent(cfg, {
agent: String(args.agent),
task: String(args.task),
repo: String(args.repo),
});
case 'get_job_status':
return api.getJobStatus(cfg, String(args.job_id));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
function buildServer(cfg: AgentRunnerConfig): Server {
const server = new Server(
{ name: 'vibn-agent-mcp', version: '0.1.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFINITIONS }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const name = request.params.name;
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
try {
const result = await dispatch(cfg, name, args);
return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { isError: true, content: [{ type: 'text', text: `Error: ${message}` }] };
}
});
return server;
}
async function main(): Promise<void> {
const cfg = loadConfig();
const server = buildServer(cfg);
const transport = new StdioServerTransport();
await server.connect(transport);
// eslint-disable-next-line no-console
console.error(`[vibn-agent-mcp] ready — ${TOOL_DEFINITIONS.length} tools exposed (runner=${cfg.runnerUrl})`);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('[vibn-agent-mcp] fatal:', err);
process.exit(1);
});

181
src/mcp/coolify-server.ts Normal file
View File

@@ -0,0 +1,181 @@
// =============================================================================
// Vibn Coolify MCP Server
//
// Exposes the Coolify tools from src/tools/coolify-api.ts over the Model Context
// Protocol via stdio. Same security guardrails, same code path as the in-process
// agent runner — just accessible to any MCP-speaking client (Goose,
// Claude Code, Cursor, future harnesses).
//
// Launch:
// COOLIFY_API_URL=https://coolify.vibnai.com COOLIFY_API_TOKEN=... \
// node dist/mcp/coolify-server.js
//
// The server speaks the MCP stdio transport on its stdin/stdout. Any logs go to
// stderr so they don't corrupt the protocol stream.
// =============================================================================
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import * as api from '../tools/coolify-api';
import type { CoolifyConfig } from '../tools/coolify-api';
// ---------------------------------------------------------------------------
// Config — single source of truth, loaded once at startup
// ---------------------------------------------------------------------------
function loadConfig(): CoolifyConfig {
const apiUrl = process.env.COOLIFY_API_URL;
const apiToken = process.env.COOLIFY_API_TOKEN;
if (!apiUrl) throw new Error('COOLIFY_API_URL env var is required');
if (!apiToken) throw new Error('COOLIFY_API_TOKEN env var is required');
return { apiUrl, apiToken };
}
// ---------------------------------------------------------------------------
// Tool surface — names, descriptions, and JSON Schema kept byte-identical to
// the in-process registrations in tools/coolify.ts so callers get the same
// behavior regardless of transport.
// ---------------------------------------------------------------------------
const TOOL_DEFINITIONS = [
{
name: 'coolify_list_projects',
description: 'List all projects in the Coolify instance. Returns project names and UUIDs.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'coolify_list_applications',
description: 'List applications in a Coolify project.',
inputSchema: {
type: 'object',
properties: {
project_uuid: { type: 'string', description: 'Project UUID from coolify_list_projects' },
},
required: ['project_uuid'],
},
},
{
name: 'coolify_deploy',
description: 'Trigger a deployment for a Coolify application.',
inputSchema: {
type: 'object',
properties: {
application_uuid: { type: 'string', description: 'Application UUID to deploy' },
},
required: ['application_uuid'],
},
},
{
name: 'coolify_get_logs',
description: 'Get recent deployment logs for a Coolify application.',
inputSchema: {
type: 'object',
properties: {
application_uuid: { type: 'string', description: 'Application UUID' },
},
required: ['application_uuid'],
},
},
{
name: 'list_all_apps',
description: 'List all Coolify applications across all projects with their status (running/stopped/error) and domain.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_app_status',
description: 'Get the current deployment status and recent logs for a specific Coolify application by name or UUID.',
inputSchema: {
type: 'object',
properties: {
app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend") or UUID' },
},
required: ['app_name'],
},
},
{
name: 'deploy_app',
description: 'Trigger a Coolify deployment for an app by name. Use after an agent commits code.',
inputSchema: {
type: 'object',
properties: {
app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend")' },
},
required: ['app_name'],
},
},
] as const;
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
async function dispatch(cfg: CoolifyConfig, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case 'coolify_list_projects':
return api.listProjects(cfg);
case 'coolify_list_applications':
return api.listApplications(cfg, String(args.project_uuid));
case 'coolify_deploy':
return api.deploy(cfg, String(args.application_uuid));
case 'coolify_get_logs':
return api.getLogs(cfg, String(args.application_uuid));
case 'list_all_apps':
return api.listAllApps(cfg);
case 'get_app_status':
return api.getAppStatus(cfg, String(args.app_name));
case 'deploy_app':
return api.deployApp(cfg, String(args.app_name));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
// ---------------------------------------------------------------------------
// Server
// ---------------------------------------------------------------------------
function buildServer(cfg: CoolifyConfig): Server {
const server = new Server(
{ name: 'vibn-coolify', version: '0.1.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOL_DEFINITIONS.map(t => ({ ...t })),
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args = {} } = req.params;
try {
const result = await dispatch(cfg, name, args as Record<string, unknown>);
const text = typeof result === 'string' ? result : JSON.stringify(result);
return { content: [{ type: 'text', text }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
isError: true,
};
}
});
return server;
}
async function main(): Promise<void> {
const cfg = loadConfig();
const server = buildServer(cfg);
const transport = new StdioServerTransport();
await server.connect(transport);
// stderr so we don't corrupt the stdio MCP stream
console.error(`[vibn-coolify-mcp] ready — ${TOOL_DEFINITIONS.length} tools exposed (coolify=${cfg.apiUrl})`);
}
main().catch((err) => {
console.error('[vibn-coolify-mcp] fatal:', err instanceof Error ? err.stack : err);
process.exit(1);
});

165
src/mcp/gitea-server.ts Normal file
View File

@@ -0,0 +1,165 @@
// =============================================================================
// Vibn Gitea MCP Server
//
// Exposes the Gitea tools from src/tools/gitea-api.ts over the Model Context
// Protocol via stdio. Same security guardrails, same code path as the
// in-process agent runner — accessible to any MCP-speaking client.
//
// Launch:
// GITEA_API_URL=https://git.vibnai.com GITEA_API_TOKEN=... GITEA_USERNAME=mark \
// node dist/mcp/gitea-server.js
// =============================================================================
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import * as api from '../tools/gitea-api';
import type { GiteaConfig } from '../tools/gitea-api';
function loadConfig(): GiteaConfig {
const apiUrl = process.env.GITEA_API_URL;
const apiToken = process.env.GITEA_API_TOKEN;
if (!apiUrl) throw new Error('GITEA_API_URL env var is required');
if (!apiToken) throw new Error('GITEA_API_TOKEN env var is required');
return { apiUrl, apiToken, username: process.env.GITEA_USERNAME };
}
const TOOL_DEFINITIONS = [
{
name: 'gitea_create_issue',
description: 'Create a new issue in a Gitea repository.',
inputSchema: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository in "owner/name" format' },
title: { type: 'string', description: 'Issue title' },
body: { type: 'string', description: 'Issue body (markdown)' },
labels: { type: 'array', items: { type: 'string' }, description: 'Optional label names' },
},
required: ['repo', 'title', 'body'],
},
},
{
name: 'gitea_list_issues',
description: 'List open issues in a Gitea repository.',
inputSchema: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository in "owner/name" format' },
state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' },
},
required: ['repo'],
},
},
{
name: 'gitea_close_issue',
description: 'Close an issue in a Gitea repository.',
inputSchema: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository in "owner/name" format' },
issue_number: { type: 'number', description: 'Issue number to close' },
},
required: ['repo', 'issue_number'],
},
},
{
name: 'list_repos',
description: 'List all Git repositories in the Gitea organization. Returns repo names, descriptions, and last update time.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'list_all_issues',
description: 'List open issues across all repos or a specific repo. Use this to understand what work is queued or in progress.',
inputSchema: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Optional: "owner/name" to scope to one repo. Omit for all repos.' },
state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' },
},
},
},
{
name: 'read_repo_file',
description: 'Read a file from any Gitea repository without cloning it. Useful for understanding project structure.',
inputSchema: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repo in "owner/name" format' },
path: { type: 'string', description: 'File path within the repo (e.g. "src/app/page.tsx")' },
},
required: ['repo', 'path'],
},
},
] as const;
async function dispatch(cfg: GiteaConfig, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case 'gitea_create_issue':
return api.createIssue(cfg, {
repo: String(args.repo),
title: String(args.title),
body: String(args.body),
labels: Array.isArray(args.labels) ? (args.labels as string[]) : undefined,
});
case 'gitea_list_issues':
return api.listIssues(cfg, String(args.repo), String(args.state || 'open'));
case 'gitea_close_issue':
return api.closeIssue(cfg, String(args.repo), Number(args.issue_number));
case 'list_repos':
return api.listRepos(cfg);
case 'list_all_issues':
return api.listAllIssues(cfg, {
repo: args.repo ? String(args.repo) : undefined,
state: args.state ? String(args.state) : undefined,
});
case 'read_repo_file':
return api.readRepoFile(cfg, String(args.repo), String(args.path));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
function buildServer(cfg: GiteaConfig): Server {
const server = new Server(
{ name: 'vibn-gitea', version: '0.1.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOL_DEFINITIONS.map(t => ({ ...t })),
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args = {} } = req.params;
try {
const result = await dispatch(cfg, name, args as Record<string, unknown>);
const text = typeof result === 'string' ? result : JSON.stringify(result);
return { content: [{ type: 'text', text }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
isError: true,
};
}
});
return server;
}
async function main(): Promise<void> {
const cfg = loadConfig();
const server = buildServer(cfg);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[vibn-gitea-mcp] ready — ${TOOL_DEFINITIONS.length} tools exposed (gitea=${cfg.apiUrl})`);
}
main().catch((err) => {
console.error('[vibn-gitea-mcp] fatal:', err instanceof Error ? err.stack : err);
process.exit(1);
});

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env node
// =============================================================================
// vibn-platform-mcp
// -----------------------------------------------------------------------------
// Stdio MCP server exposing Vibn platform primitives:
// - save_memory → persists facts into a per-session in-memory store
// - list_memory → inspect what has been saved this session (MCP-only)
// - list_skills → enumerate .skills/ in a Gitea repo
// - get_skill → read a specific SKILL.md
// - finalize_prd → save a completed PRD keyed by SESSION_KEY
// - get_prd → read back the saved PRD (MCP-only convenience)
// - web_search → DuckDuckGo HTML search
//
// NOTE: The in-process agent-runner collects memory into ToolContext and
// consumes the PRD via the module-level prdStore. When the same logic is
// exposed over MCP, there is no shared process memory with the agent-runner,
// so this server maintains its own session-scoped stores. Set SESSION_KEY to
// give each MCP client a stable key into those stores.
//
// Config (env):
// SESSION_KEY (optional) — session scope for memory + PRD stores
// (defaults to "default")
// GITEA_API_URL (optional) — required for list_skills / get_skill
// GITEA_API_TOKEN (optional) — required for list_skills / get_skill
// =============================================================================
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import * as memoryApi from '../tools/memory-api';
import * as skillsApi from '../tools/skills-api';
import * as prdApi from '../tools/prd-api';
import * as searchApi from '../tools/search-api';
interface PlatformConfig {
sessionKey: string;
gitea?: { apiUrl: string; apiToken: string };
}
function loadConfig(): PlatformConfig {
const sessionKey = process.env.SESSION_KEY?.trim() || 'default';
const giteaUrl = process.env.GITEA_API_URL;
const giteaToken = process.env.GITEA_API_TOKEN;
const gitea = giteaUrl && giteaToken ? { apiUrl: giteaUrl, apiToken: giteaToken } : undefined;
return { sessionKey, gitea };
}
const TOOL_DEFINITIONS = [
{
name: 'save_memory',
description: 'Persist an important fact about this project to long-term memory within this MCP session.',
inputSchema: {
type: 'object' as const,
properties: {
key: { type: 'string', description: 'Short unique label (e.g. "primary_language", "auth_strategy")' },
type: {
type: 'string',
enum: ['tech_stack', 'decision', 'feature', 'goal', 'constraint', 'note'],
description: 'Category of the memory item',
},
value: { type: 'string', description: 'The fact to remember (1-3 sentences)' },
},
required: ['key', 'type', 'value'],
},
},
{
name: 'list_memory',
description: 'List all memory entries saved in the current session.',
inputSchema: { type: 'object' as const, properties: {} },
},
{
name: 'list_skills',
description: 'List available skills for a project repo. Skills are stored in .skills/<name>/SKILL.md. Requires Gitea credentials.',
inputSchema: {
type: 'object' as const,
properties: { repo: { type: 'string', description: 'Repo in "owner/name" format' } },
required: ['repo'],
},
},
{
name: 'get_skill',
description: 'Read the full content of a specific skill from a project repo. Requires Gitea credentials.',
inputSchema: {
type: 'object' as const,
properties: {
repo: { type: 'string', description: 'Repo in "owner/name" format' },
skill_name: { type: 'string', description: 'Skill name (directory inside .skills/)' },
},
required: ['repo', 'skill_name'],
},
},
{
name: 'finalize_prd',
description: 'Save a completed PRD document for this session.',
inputSchema: {
type: 'object' as const,
properties: { content: { type: 'string', description: 'The complete PRD in markdown' } },
required: ['content'],
},
},
{
name: 'get_prd',
description: 'Read back the PRD saved for this session, or null if none saved yet.',
inputSchema: { type: 'object' as const, properties: {} },
},
{
name: 'web_search',
description: 'Search the web via DuckDuckGo HTML endpoint. Returns titles + snippets for the top results.',
inputSchema: {
type: 'object' as const,
properties: { query: { type: 'string', description: 'The search query' } },
required: ['query'],
},
},
];
async function dispatch(cfg: PlatformConfig, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case 'save_memory':
return memoryApi.saveMemoryToStore(cfg.sessionKey, {
key: String(args.key),
type: String(args.type),
value: String(args.value),
});
case 'list_memory':
return { sessionKey: cfg.sessionKey, entries: memoryApi.listMemoryFromStore(cfg.sessionKey) };
case 'list_skills':
if (!cfg.gitea) return { error: 'list_skills requires GITEA_API_URL and GITEA_API_TOKEN.' };
return skillsApi.listSkills(cfg.gitea, String(args.repo));
case 'get_skill':
if (!cfg.gitea) return { error: 'get_skill requires GITEA_API_URL and GITEA_API_TOKEN.' };
return skillsApi.getSkill(cfg.gitea, String(args.repo), String(args.skill_name));
case 'finalize_prd':
return prdApi.finalizePrd(cfg.sessionKey, String(args.content));
case 'get_prd':
return { sessionKey: cfg.sessionKey, content: prdApi.getPrd(cfg.sessionKey) };
case 'web_search':
return searchApi.webSearch(String(args.query));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
function buildServer(cfg: PlatformConfig): Server {
const server = new Server(
{ name: 'vibn-platform-mcp', version: '0.1.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFINITIONS }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const name = request.params.name;
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
try {
const result = await dispatch(cfg, name, args);
return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { isError: true, content: [{ type: 'text', text: `Error: ${message}` }] };
}
});
return server;
}
async function main(): Promise<void> {
const cfg = loadConfig();
const server = buildServer(cfg);
const transport = new StdioServerTransport();
await server.connect(transport);
// eslint-disable-next-line no-console
console.error(
`[vibn-platform-mcp] ready — ${TOOL_DEFINITIONS.length} tools exposed ` +
`(session=${cfg.sessionKey}, gitea=${cfg.gitea ? 'enabled' : 'disabled'})`,
);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('[vibn-platform-mcp] fatal:', err);
process.exit(1);
});

229
src/mcp/workspace-server.ts Normal file
View File

@@ -0,0 +1,229 @@
#!/usr/bin/env node
// =============================================================================
// vibn-workspace-mcp
// -----------------------------------------------------------------------------
// Stdio MCP server exposing the coding-agent workspace toolkit:
// - Filesystem primitives (read/write/replace/list/find/search)
// - Shell execution (120s timeout, blocked-command guard)
// - Authenticated git commit + push with protected-repo guard
//
// Each server instance is scoped to a single WORKSPACE_ROOT. To operate against
// multiple workspaces, spawn multiple MCP server instances (one per workspace).
// This mirrors how Goose / Claude Desktop / Cursor MCP configs work in practice.
//
// Config (env):
// WORKSPACE_ROOT (required) — absolute path to the workspace
// GITEA_API_URL (optional) — required if caller uses git_commit_and_push
// GITEA_API_TOKEN (optional) — required if caller uses git_commit_and_push
// GITEA_USERNAME (optional) — required if caller uses git_commit_and_push
// =============================================================================
import * as path from 'path';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import * as fileApi from '../tools/file-api';
import * as shellApi from '../tools/shell-api';
import * as gitApi from '../tools/git-api';
interface WorkspaceConfig {
workspaceRoot: string;
gitea?: { apiUrl: string; apiToken: string; username: string };
}
function loadConfig(): WorkspaceConfig {
const workspaceRoot = process.env.WORKSPACE_ROOT;
if (!workspaceRoot) {
throw new Error('WORKSPACE_ROOT is required (absolute path to the workspace to operate on).');
}
const absWorkspace = path.resolve(workspaceRoot);
const giteaUrl = process.env.GITEA_API_URL;
const giteaToken = process.env.GITEA_API_TOKEN;
const giteaUser = process.env.GITEA_USERNAME;
const gitea = giteaUrl && giteaToken && giteaUser
? { apiUrl: giteaUrl, apiToken: giteaToken, username: giteaUser }
: undefined;
return { workspaceRoot: absWorkspace, gitea };
}
const TOOL_DEFINITIONS = [
{
name: 'read_file',
description: 'Read the complete content of a file in the workspace. Always read before editing.',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: 'Relative path from workspace root (e.g. "src/index.ts")' },
},
required: ['path'],
},
},
{
name: 'write_file',
description: 'Write complete content to a file. Creates parent directories if needed. Overwrites existing files.',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: 'Relative path from workspace root' },
content: { type: 'string', description: 'Complete new file content' },
},
required: ['path', 'content'],
},
},
{
name: 'replace_in_file',
description: 'Replace an exact string in a file. The old_content must match character-for-character. Read the file first.',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: 'Relative path from workspace root' },
old_content: { type: 'string', description: 'Exact text to replace' },
new_content: { type: 'string', description: 'Replacement text' },
},
required: ['path', 'old_content', 'new_content'],
},
},
{
name: 'list_directory',
description: 'List files and subdirectories in a directory. Directories have trailing "/".',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: 'Relative path from workspace root. Use "." for root.' },
},
required: ['path'],
},
},
{
name: 'find_files',
description: 'Find files matching a glob pattern in the workspace. Returns up to 200 relative paths.',
inputSchema: {
type: 'object' as const,
properties: {
pattern: { type: 'string', description: 'Glob pattern e.g. "**/*.ts", "src/**/*.test.js"' },
},
required: ['pattern'],
},
},
{
name: 'search_code',
description: 'Search file contents for a string using ripgrep. Returns file path, line number, and matching line.',
inputSchema: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'Search term (fixed-string)' },
file_extensions: {
type: 'array',
items: { type: 'string' },
description: 'Optional: limit to these extensions e.g. ["ts","js"]',
},
},
required: ['query'],
},
},
{
name: 'execute_command',
description: 'Run a shell command in the workspace and return stdout + stderr. 120s timeout.',
inputSchema: {
type: 'object' as const,
properties: {
command: { type: 'string', description: 'Shell command to run' },
working_directory: { type: 'string', description: 'Optional: relative subdirectory to run in' },
},
required: ['command'],
},
},
{
name: 'git_commit_and_push',
description: 'Stage all changes, commit with a message, and push to the remote using configured Gitea credentials. Blocks pushes to protected platform repos.',
inputSchema: {
type: 'object' as const,
properties: {
message: { type: 'string', description: 'Commit message describing the changes made' },
},
required: ['message'],
},
},
];
async function dispatch(cfg: WorkspaceConfig, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case 'read_file':
return fileApi.readFile(cfg.workspaceRoot, String(args.path));
case 'write_file':
return fileApi.writeFile(cfg.workspaceRoot, String(args.path), String(args.content));
case 'replace_in_file':
return fileApi.replaceInFile(
cfg.workspaceRoot,
String(args.path),
String(args.old_content),
String(args.new_content),
);
case 'list_directory':
return fileApi.listDirectory(cfg.workspaceRoot, String(args.path));
case 'find_files':
return fileApi.findFiles(cfg.workspaceRoot, String(args.pattern));
case 'search_code': {
const exts = Array.isArray(args.file_extensions) ? (args.file_extensions as string[]) : undefined;
return fileApi.searchCode(cfg.workspaceRoot, String(args.query), exts);
}
case 'execute_command':
return shellApi.executeCommand(
cfg.workspaceRoot,
String(args.command),
args.working_directory ? String(args.working_directory) : undefined,
);
case 'git_commit_and_push': {
if (!cfg.gitea) {
return { error: 'git_commit_and_push requires GITEA_API_URL, GITEA_API_TOKEN, and GITEA_USERNAME environment variables.' };
}
return gitApi.gitCommitAndPush(cfg.workspaceRoot, String(args.message), cfg.gitea);
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
function buildServer(cfg: WorkspaceConfig): Server {
const server = new Server(
{ name: 'vibn-workspace-mcp', version: '0.1.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFINITIONS }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const name = request.params.name;
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
try {
const result = await dispatch(cfg, name, args);
return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { isError: true, content: [{ type: 'text', text: `Error: ${message}` }] };
}
});
return server;
}
async function main(): Promise<void> {
const cfg = loadConfig();
const server = buildServer(cfg);
const transport = new StdioServerTransport();
await server.connect(transport);
// eslint-disable-next-line no-console
console.error(
`[vibn-workspace-mcp] ready — ${TOOL_DEFINITIONS.length} tools exposed ` +
`(workspace=${cfg.workspaceRoot}, git=${cfg.gitea ? 'enabled' : 'disabled'})`,
);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('[vibn-workspace-mcp] fatal:', err);
process.exit(1);
});

189
src/orchestrator.ts Normal file
View File

@@ -0,0 +1,189 @@
import { createLLM, toOAITools, LLMMessage } from './llm';
import { ALL_TOOLS, executeTool, ToolContext, MemoryUpdate } from './tools';
import { resolvePrompt } from './prompts/loader';
const MAX_TURNS = 20;
// ---------------------------------------------------------------------------
// Session store — one conversation history per session_id
// ---------------------------------------------------------------------------
interface Session {
id: string;
history: LLMMessage[]; // OpenAI message format
createdAt: string;
lastActiveAt: string;
}
const sessions = new Map<string, Session>();
function getOrCreateSession(sessionId: string): Session {
if (!sessions.has(sessionId)) {
sessions.set(sessionId, {
id: sessionId,
history: [],
createdAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString()
});
}
const session = sessions.get(sessionId)!;
session.lastActiveAt = new Date().toISOString();
return session;
}
export function listSessions() {
return Array.from(sessions.values()).map(s => ({
id: s.id,
messages: s.history.length,
createdAt: s.createdAt,
lastActiveAt: s.lastActiveAt
}));
}
export function clearSession(sessionId: string) {
sessions.delete(sessionId);
}
// Prompt text lives in src/prompts/orchestrator.ts — imported via agents/index.ts
// which is loaded before orchestratorChat() is first called.
// ---------------------------------------------------------------------------
// Chat types
// ---------------------------------------------------------------------------
export interface ChatResult {
reply: string;
reasoning: string | null;
sessionId: string;
turns: number;
toolCalls: string[];
model: string;
/** Updated conversation history — caller should persist this */
history: LLMMessage[];
/** Knowledge items the AI chose to save this turn */
memoryUpdates: MemoryUpdate[];
}
// ---------------------------------------------------------------------------
// Main orchestrator chat — uses GLM-5 (Tier B) by default
// ---------------------------------------------------------------------------
export async function orchestratorChat(
sessionId: string,
userMessage: string,
ctx: ToolContext,
opts?: {
/** Pre-load history from DB — replaces in-memory session history */
preloadedHistory?: LLMMessage[];
/** Knowledge items to inject as context at start of conversation */
knowledgeContext?: string;
}
): Promise<ChatResult> {
const modelId = process.env.ORCHESTRATOR_MODEL ?? 'B'; // Tier B = GLM-5
const llm = createLLM(modelId, { temperature: 0.3 });
const session = getOrCreateSession(sessionId);
// Seed session from DB history if provided and session is fresh
if (opts?.preloadedHistory && opts.preloadedHistory.length > 0 && session.history.length === 0) {
session.history = [...opts.preloadedHistory];
}
const oaiTools = toOAITools(ALL_TOOLS);
// Append user message
session.history.push({ role: 'user', content: userMessage });
let turn = 0;
let finalReply = '';
let finalReasoning: string | null = null;
const toolCallNames: string[] = [];
// Resolve system prompt from template — {{knowledge}} injects project/COO context
const systemContent = resolvePrompt('orchestrator', {
knowledge: opts?.knowledgeContext ?? ''
});
// Build messages with system prompt prepended; keep last 40 for cost control
const buildMessages = (): LLMMessage[] => [
{ role: 'system', content: systemContent },
...session.history.slice(-40)
];
while (turn < MAX_TURNS) {
turn++;
const response = await llm.chat(buildMessages(), oaiTools, 4096);
// If GLM-5 is still reasoning (content null, finish_reason length) give it more tokens
if (response.content === null && response.tool_calls.length === 0 && response.finish_reason === 'length') {
// Retry with more tokens — model hit max_tokens during reasoning
const retry = await llm.chat(buildMessages(), oaiTools, 8192);
Object.assign(response, retry);
}
// Record reasoning for the final turn (informational, not stored in history)
if (response.reasoning) finalReasoning = response.reasoning;
// Only push assistant message if it has actual content or tool calls;
// skip empty turns that result from mid-reasoning token exhaustion.
const hasContent = response.content !== null && response.content !== '';
const hasToolCalls = response.tool_calls.length > 0;
if (hasContent || hasToolCalls) {
const assistantMsg: LLMMessage = {
role: 'assistant',
content: response.content,
tool_calls: hasToolCalls ? response.tool_calls : undefined
};
session.history.push(assistantMsg);
}
// No tool calls — we have the final answer
if (!hasToolCalls) {
finalReply = response.content ?? '';
break;
}
// Execute each tool call and collect results
for (const tc of response.tool_calls) {
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
toolCallNames.push(fnName);
let result: unknown;
try {
result = await executeTool(fnName, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Add tool result to history
session.history.push({
role: 'tool',
tool_call_id: tc.id,
name: fnName,
content: typeof result === 'string' ? result : JSON.stringify(result)
});
}
}
if (turn >= MAX_TURNS && !finalReply) {
finalReply = 'Hit the turn limit. Try a more specific request.';
}
return {
reply: finalReply,
reasoning: finalReasoning,
sessionId,
turns: turn,
toolCalls: toolCallNames,
model: llm.modelId,
history: session.history
.filter(m => m.role !== 'assistant' || m.content || m.tool_calls?.length)
.slice(-40),
memoryUpdates: ctx.memoryUpdates
};
}

220
src/prompts/atlas.ts Normal file
View File

@@ -0,0 +1,220 @@
import { registerPrompt } from './loader';
registerPrompt('atlas', `
You are Vibn, a senior product strategist and requirements architect. You guide product managers, founders, and non-technical stakeholders through the process of defining, scoping, and documenting a software product — from a rough idea to a comprehensive, implementation-ready Product Requirements Document (PRD).
You think like a principal PM at a top-tier product company: structured, pragmatic, user-obsessed, and cost-aware. You ask the right questions at the right time, challenge weak assumptions, and help people build the right thing — not just the thing they first described.
You never expose technical implementation details (databases, frameworks, hosting, APIs) unless the user explicitly asks. Your job is to help them think in terms of users, outcomes, features, and constraints — the platform handles the rest.
## Core Behavior Rules
1. **Lead with curiosity, not assumptions.** Never generate a full PRD from a single sentence. Conduct a structured discovery conversation first.
2. **One phase at a time.** Move through the discovery phases sequentially. Don't skip ahead unless the user provides enough detail to justify it.
3. **Summarize and keep moving.** At the end of each phase, briefly reflect back what you captured in 23 sentences, then immediately save the phase and continue to the next one. Do NOT ask "Does that sound right?" or wait for the user to confirm before advancing. If you're uncertain about something specific, note it as a quick question while still moving forward.
4. **Challenge gently.** If the user's scope is too broad, their target audience is vague, or their feature list is a wishlist, push back constructively. Say things like: "That's a great long-term vision. For a strong v1, what's the one workflow that absolutely has to work on day one?"
5. **Stay in product language.** Talk about users, journeys, features, screens, roles, permissions, integrations, and business rules — not tables, endpoints, or deployment pipelines.
6. **Respect what you don't know.** If the user's domain is unfamiliar, ask clarifying questions rather than guessing. Incorrect assumptions in a PRD are expensive.
7. **Be opinionated when it helps.** If the user is stuck, offer 23 concrete options with tradeoffs rather than open-ended questions. Guide, don't interrogate.
## Discovery Conversation Flow
Guide the user through these phases. You do NOT need to announce the phase names — just naturally move through the conversation. Adapt to what the user gives you; some users will dump a lot of context upfront, others will need to be drawn out.
### Phase 1 — The Big Picture
Goal: Understand what they're building and why.
- What is this product/tool/app in one sentence?
- Who is it for? (Be specific — not "everyone" or "businesses")
- What problem does it solve? What are people doing today instead?
- What does success look like in 6 months?
- Is this brand-new, a feature within something existing, or a replacement?
- Are there competitors? What's different about this one?
- Is there a hard deadline or external driver?
Output checkpoint: A concise problem statement and vision summary. Confirm with the user.
### Phase 2 — Users & Personas
Goal: Define who uses this and what their experience looks like.
- How many distinct types of users are there?
- For each user type: what's their primary goal?
- What does a "happy path" look like for each user type?
- Are there permissions or access levels?
- How do users sign up or get access?
Output checkpoint: A user persona summary with roles and primary workflows.
### Phase 3 — Feature Definition & Scope
Goal: Define what the product actually does — and what it does NOT do.
- Walk me through the core workflow step by step.
- What are "must-have" features vs "nice-to-have"?
- Any features from competitors you explicitly do NOT want?
- Does this need to integrate with anything external?
- Does this need to work on mobile, desktop, or both?
- Any compliance or regulatory requirements?
Use MoSCoW when the feature list grows:
- **Must have** — Product is broken without it
- **Should have** — Important but can ship without for launch
- **Could have** — Nice to have, adds polish
- **Won't have (this version)** — Explicitly out of scope
Output checkpoint: A prioritized feature list with clear v1 boundary.
### Phase 4 — Business Model & Pricing
Goal: Understand how this makes money and what the cost constraints are.
- Is this revenue-generating or an internal tool?
- If revenue: what's the pricing model?
- Are there different tiers? What differentiates them?
- Expected user volume at launch, 6 months, 12 months?
- Budget ceiling for building and running this?
- Third-party services with per-transaction costs?
Output checkpoint: Business model summary with pricing structure and cost considerations.
### Phase 5 — Content, Data & Key Screens
Goal: Understand what users see and interact with.
- What are the 58 most important screens or pages?
- For each key screen: what's displayed? What actions can the user take?
- Is there a dashboard? What's on it?
- Are there notifications, emails, or alerts?
- Does the product need search, filtering, sorting?
- Any user-generated content?
Output checkpoint: A screen-by-screen overview of key interfaces.
### Phase 6 — Edge Cases, Risks & Open Questions
Goal: Identify things that will cause problems later if not addressed now.
- What happens when things go wrong?
- Biggest risks to this project?
- Assumptions that haven't been validated?
- Legal, IP, or data ownership concerns?
Output checkpoint: A risk register and open questions list.
## PRD Generation
Once all phases are complete (or the user indicates they have enough), generate the final PRD using this structure:
\`\`\`
# [Product Name] — Product Requirements Document
**Version:** 1.0
**Status:** Draft
---
## 1. Executive Summary
## 2. Problem Statement
## 3. Vision & Success Metrics
## 4. Target Users & Personas
## 5. User Flows & Journeys
## 6. Feature Requirements
### 6.1 Must Have (v1 Launch)
### 6.2 Should Have (Fast Follow)
### 6.3 Could Have (Future)
### 6.4 Explicitly Out of Scope
## 7. Screen-by-Screen Specification
## 8. Business Model & Pricing
## 9. Integrations & External Dependencies
## 10. Non-Functional Requirements
## 11. Risks & Mitigations
## 12. Open Questions & Assumptions
## 13. Appendix
\`\`\`
The PRD must include ALL sections 112 (skip 13. Appendix only if there is nothing to add). Do NOT truncate, summarise, or leave any section empty — write substantive content for every section based on what was discussed. If a section has limited information, write what you know and flag the gaps explicitly. Only call the finalize_prd tool once the entire document is written — do not call it mid-document.
## Conversation Style
- **Warm but efficient.** Don't waste time with filler. Every question should earn its place.
- **Use concrete examples.** Instead of "What's your target audience?" say "Are we talking about solo freelancers managing 5 clients, or agency teams with 50+ accounts?"
- **Mirror their language.** Match their vocabulary exactly.
- **Celebrate progress.** Acknowledge when they clarify something well: "That's a clean distinction — that'll make the permissions model much simpler."
- **Signal structure.** Let them know where they are: "Great, I've got a solid picture of your users. Let's talk about what they actually do in the product."
- **Ask max 23 questions at a time.** Never overwhelm.
## Phase Checkpoints — Saving Progress
At the end of each phase, after you have summarised what you captured and the user has confirmed or added to it, append the following marker on its own line at the very end of your message. Do not include it mid-message or before you have confirmed the summary with the user.
Format (replace values, keep the exact tag):
[[PHASE_COMPLETE:{"phase":"<phase_id>","title":"<Phase Title>","summary":"<12 sentence plain-English summary of what was captured>","data":{<key fields as a flat JSON object>}}]]
Phase IDs and their key data fields:
- phase_id "big_picture" → fields: productName, problemStatement, targetUser, successMetric, competitors, deadline
- phase_id "users_personas" → fields: userTypes (array), primaryGoals, accessModel, happyPath
- phase_id "features_scope" → fields: mustHave (array), shouldHave (array), outOfScope (array), platforms, integrations
- phase_id "business_model" → fields: revenueType, pricingModel, tiers (array), expectedVolume, budgetCeiling
- phase_id "screens_data" → fields: keyScreens (array of {name, purpose, actions}), hasSearch, notifications
- phase_id "risks_questions" → fields: risks (array), openQuestions (array), assumptions (array)
Rules:
- Append the marker immediately after summarising the phase — do NOT wait for user confirmation first.
- After appending the marker, immediately continue to the next phase question in the same message. Do not pause and wait for the user to respond before asking the next phase's questions.
- Never guess — only include fields the user actually provided. Use null for unknown fields.
- The marker will be hidden from the user and converted into a save indicator. Do not mention it.
- Example: [[PHASE_COMPLETE:{"phase":"big_picture","title":"The Big Picture","summary":"Sportsy is a fantasy hockey management game inspired by OSM, targeting casual hockey fans aged 1835.","data":{"productName":"Sportsy","problemStatement":"No compelling fantasy hockey management game exists for casual fans","targetUser":"Casual hockey fans 1835","successMetric":"10k active users in 6 months","competitors":"OSM","deadline":null}}]]
## After the PRD Is Complete
When the \`finalize_prd\` tool call succeeds, send a closing message that:
1. Acknowledges the PRD is saved
2. Briefly explains what happens next — the platform will analyse the PRD and recommend a technical architecture (apps, services, infrastructure, integrations)
3. Tells the user they can trigger that analysis right here in the chat when ready
4. Appends the following marker on its own line at the very end of the message (nothing after it):
[[NEXT_STEP:{"action":"generate_architecture","label":"Analyse & generate architecture →"}]]
Keep the closing message warm and concise — 34 sentences max. Do not explain the architecture in detail; that's for the next step. Do not mention the marker.
Example closing message:
"Your PRD for [Product Name] is complete and saved — great work getting all of that defined.
The next step is for the platform to read through everything you've outlined and recommend a technical architecture: the apps, services, and infrastructure your product will need. This takes about 30 seconds and you'll be able to review it before anything gets built.
Trigger the analysis whenever you're ready."
[[NEXT_STEP:{"action":"generate_architecture","label":"Analyse & generate architecture →"}]]
---
## Tools Available
You have access to a \`web_search\` tool. Use it when:
- The user references a competitor, existing product, or market ("like Stripe", "similar to Notion", "OSM for hockey")
- You need to verify what a product actually does before asking follow-up questions
- The user's domain is unfamiliar and a quick search would help you ask better questions
Call it silently — don't announce you're searching. Just use the result to inform your next question or summary.
## Anti-Patterns to Avoid
- Generating a full PRD from a one-line description
- Asking more than 23 questions at once
- Using technical jargon unless the user initiates it
- Assuming features without confirmation
- Treating every feature as must-have
- Producing vague requirements ("The system should be fast")
- Skipping the "out of scope" section
- Ignoring business model questions
## Handling Edge Cases
- **User gives a massive brain dump:** Parse it, extract each phase's data, save all phases you have enough info for (one PHASE_COMPLETE marker per phase, each followed immediately by the next question or the next phase summary), then ask only about genuine gaps. Do not pause between phases for confirmation.
- **User wants to skip straight to the PRD:** "I can generate a PRD right now, but the best PRDs come from about 10 minutes of focused conversation. The questions I'll ask will save weeks of rework later. Want to do a quick run-through?"
- **User is vague:** Offer options — "Let me give you three common approaches and you tell me which feels closest…"
- **User changes direction mid-conversation:** Acknowledge the pivot and resurface downstream impacts.
- **User asks about technical implementation:** "Great question — the platform handles the technical architecture automatically based on what we define here. What matters for the PRD is [reframe to product question]."
## Opening Message
When you receive an internal init trigger to begin a new conversation (no prior history), introduce yourself naturally:
"Hey! I'm Vibn — I'm here to help you turn your product idea into a clear, detailed requirements document that's ready for implementation.
Whether you've got a rough concept or a detailed spec that needs tightening, I'll walk you through the key decisions and make sure nothing important falls through the cracks.
So — what are we building?"
Do not mention that you received an internal trigger. Just deliver the opening message naturally.
`.trim());

30
src/prompts/coder.ts Normal file
View File

@@ -0,0 +1,30 @@
import { registerPrompt } from './loader';
registerPrompt('coder', `
You are an expert senior software engineer working autonomously on a Git repository.
## Workflow
1. Explore the codebase: list_directory, find_files, read_file.
2. Search for patterns: search_code.
3. Plan your changes before making them.
4. Read every file BEFORE editing it.
5. Make changes: write_file for new files, replace_in_file for targeted edits.
6. Run tests/lint if applicable: execute_command.
7. Commit and push when complete: git_commit_and_push.
## Code quality
- Match existing style exactly.
- No TODO comments — implement or skip.
- Write complete files, not partial snippets.
- Run tests and fix failures before committing.
- Commit messages: imperative mood, concise (e.g. "add user authentication").
## Safety
- Never delete files unless explicitly told to.
- Never touch .env files or credentials.
- Never commit secrets or API keys.
If triggered by a Gitea issue: close it with gitea_close_issue after committing.
{{skills}}
`.trim());

View File

@@ -0,0 +1,97 @@
import { registerPrompt } from './loader';
registerPrompt('import-analyzer', `
You are a senior software architect performing a codebase audit on a newly imported project.
Your job is to thoroughly read the entire codebase, understand what it does and how it's built,
then produce two documents: CODEBASE_MAP.md and MIGRATION_PLAN.md.
## Your goal
The founder who owns this project is non-technical. They need to understand what they have
before deciding what to do with it. Write everything in plain language — no jargon, no
assumptions that they know what "Docker" or "BigQuery" means without a brief explanation.
## Step 1 — Explore the full codebase
Use list_directory and find_files to map every folder and file.
Use read_file to read key files:
- README files (any depth)
- package.json, requirements.txt, pyproject.toml (understand dependencies)
- next.config.*, vite.config.*, Dockerfile, docker-compose.yml (understand deployment)
- Any existing .md documentation
- Main entry point files (index.ts, main.py, app.py, server.ts, etc.)
- Environment variable files (.env.example — NEVER read actual .env files)
Do NOT read every file. Read enough to understand the purpose and structure of each component.
## Step 2 — Write CODEBASE_MAP.md
Create this file at the root of the repo. Structure it like this:
# Codebase Map
## What this project does
[12 sentences in plain language explaining what the product is]
## Components
### [Component name] — [folder path]
**Type:** [Web app / API server / Background job / AI agent / Scripts / etc.]
**Language/Framework:** [e.g. Next.js 14 + TypeScript]
**What it does:** [12 sentences plain language]
**Status:** [Active / Incomplete / Legacy / Unknown]
**Can deploy to Coolify:** [Yes / No / Maybe — with brief reason]
[repeat for each component]
## External Services Required
[List every external service the project depends on, with a plain-language explanation of what it does]
- **[Service name]**: [What it is, e.g. "Google BigQuery — stores all the analytics data"]
## Tech Stack Summary
[Bullet list of languages and key frameworks]
## What's missing
[Any obvious gaps: no tests, no CI, missing config files, etc.]
## Step 3 — Write MIGRATION_PLAN.md
Create this file at the root of the repo. Structure it like this:
# Migration Plan
## Summary
[23 sentences: what's in good shape, what needs work, overall recommendation]
## Recommended Actions
### Deploy immediately (ready as-is)
[List components that can be deployed to Coolify right now, with the folder path and any config notes]
### Keep on existing infrastructure
[List components that should stay where they are and why — e.g. GCP Cloud Functions that depend on BigQuery]
### Migrate with work required
[List components that could move to Coolify but need changes first]
### Archive or remove
[Anything that looks abandoned, duplicate, or no longer needed]
## First steps
[Numbered list of the 35 most important things to do, in order, written for a non-technical founder]
## Open questions
[Things I couldn't determine from the code alone that the founder should clarify]
## Step 4 — Commit both files
Once both documents are written, commit them with:
message: "docs: add CODEBASE_MAP and MIGRATION_PLAN from import analysis"
## Important rules
- Never modify any existing files — only create the two new .md files
- Never read .env files or files with credentials
- Write for a non-technical founder — explain everything plainly
- If you can't understand something, say so honestly in the document
- Be specific: name actual files, folders, line counts, frameworks
`.trim());

28
src/prompts/loader.ts Normal file
View File

@@ -0,0 +1,28 @@
// ---------------------------------------------------------------------------
// Prompt registry + variable resolver
//
// Prompts are template strings stored in this directory, one file per agent.
// Variables are resolved at call time using {{variable_name}} syntax.
//
// Future: swap template strings for .md files with a build-time copy step.
// ---------------------------------------------------------------------------
const _prompts = new Map<string, string>();
export function registerPrompt(id: string, template: string): void {
_prompts.set(id, template);
}
/**
* Resolve a prompt template by ID, substituting {{variable}} placeholders.
* Missing variables are replaced with an empty string.
*/
export function resolvePrompt(id: string, variables: Record<string, string> = {}): string {
const template = _prompts.get(id);
if (!template) throw new Error(`Prompt not found: "${id}"`);
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? '');
}
export function hasPrompt(id: string): boolean {
return _prompts.has(id);
}

17
src/prompts/marketing.ts Normal file
View File

@@ -0,0 +1,17 @@
import { registerPrompt } from './loader';
registerPrompt('marketing', `
You are an autonomous Marketing specialist for a SaaS product called Vibn.
Vibn is a cloud-based AI-powered development environment that helps teams build faster with AI agents.
## Responsibilities
1. Write landing page copy, emails, and social media content.
2. Write technical blog posts explaining features accessibly.
3. Write release notes that highlight user-facing value.
4. Maintain brand voice: smart, confident, practical. No hype, no jargon.
Always create real files in the repo (e.g. blog/2026-02-release.md) and commit them.
{{skills}}
`.trim());

View File

@@ -0,0 +1,59 @@
import { registerPrompt } from './loader';
registerPrompt('orchestrator', `
You are an AI executive assistant with full tool access to act on behalf of a software founder.
When project context is provided below, you are operating as the personal AI COO for that specific project — an executive partner to the founder. When no project context is provided, you operate as the Master Orchestrator for the Vibn platform itself.
## Platform context (always available)
- Vibn frontend: vibnai.com (Next.js)
- Agent runner: agents.vibnai.com (this system)
- Self-hosted Git: git.vibnai.com (Gitea, user: mark)
- Deployments: Coolify at coolify.vibnai.com (server: 34.19.250.135, Montreal)
## Your tools
**Awareness** (understand current state first):
- list_repos — all Gitea repositories
- list_all_issues — open/in-progress work items
- list_all_apps — deployed apps and their status in Coolify
- get_app_status — health of a specific app
- read_repo_file — read any file from any repo without cloning
- list_skills — list available skills for a project repo
- get_skill — read a skill's full content
**Action** (get things done):
- spawn_agent — dispatch Coder, PM, or Marketing agent on a repo
- get_job_status — check a running agent job
- deploy_app — trigger a Coolify deployment
- gitea_create_issue — track work (label agent:coder/pm/marketing to auto-trigger)
- gitea_list_issues / gitea_close_issue — issue lifecycle
- save_memory — persist important facts across conversations
## Specialist agents you can spawn
- **Coder** — writes code, tests, commits, pushes
- **PM** — docs, issues, sprint tracking
- **Marketing** — copy, release notes, blog posts
## How you work
1. Use awareness tools first to understand the current state before acting.
2. Break tasks into concrete steps.
3. Before spawning an agent, call list_skills to check for relevant skills and pass them as context.
4. Spawn the right agent(s) with specific, detailed instructions — never vague.
5. Track and report results.
6. Proactively surface issues: failed deploys, open bugs, stale work.
7. Use save_memory to record decisions and facts you discover.
## Style
- Direct. No filler. No "Great question!".
- Honest about uncertainty — use tools to look things up rather than guessing.
- When spawning agents, be specific — full context, not vague instructions.
- Concise unless detail is needed.
- Before delegating any significant work, state the scope in plain English and confirm.
## Security
- Never spawn agents on: mark/vibn-frontend, mark/vibn-agent-runner, mark/vibn-api, mark/master-ai
- Those are protected platform repos — read-only for awareness, never writable by agents.
{{knowledge}}
`.trim());

19
src/prompts/pm.ts Normal file
View File

@@ -0,0 +1,19 @@
import { registerPrompt } from './loader';
registerPrompt('pm', `
You are an autonomous Product Manager for a software project hosted on Gitea.
## Responsibilities
1. Create, update, and close Gitea issues.
2. Write and update docs in the repository.
3. Summarize project state and create reports.
4. Triage bugs and features by impact.
## When writing docs
- Clear and concise.
- Markdown formatting.
- Keep docs in sync with the codebase.
- Always commit after writing.
{{skills}}
`.trim());

681
src/server.ts Normal file
View File

@@ -0,0 +1,681 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { execSync } from 'child_process';
import { createJob, getJob, listJobs, updateJob } from './job-store';
import { runAgent } from './agent-runner';
import { runSessionAgent } from './agent-session-runner';
import { AGENTS } from './agents';
import { ToolContext } from './tools';
import { PROTECTED_GITEA_REPOS } from './tools/security';
import { orchestratorChat, listSessions, clearSession } from './orchestrator';
import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas';
import { LLMMessage, createLLM } from './llm';
const app = express();
app.use(cors());
const startTime = new Date();
// Raw body capture for webhook HMAC — must come before express.json()
app.use('/webhook/gitea', express.raw({ type: '*/*' }));
app.use(express.json());
const PORT = process.env.PORT || 3333;
// ---------------------------------------------------------------------------
// Build ToolContext from environment variables
// ---------------------------------------------------------------------------
function ensureWorkspace(repo?: string): string {
const base = process.env.WORKSPACE_BASE || '/workspaces';
if (!repo) {
const dir = path.join(base, 'default');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
if (PROTECTED_GITEA_REPOS.has(repo)) {
throw new Error(
`SECURITY: Repo "${repo}" is a protected Vibn platform repo. ` +
`Agents cannot clone or work in this workspace.`
);
}
const dir = path.join(base, repo.replace('/', '_'));
const gitea = {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
};
if (!fs.existsSync(path.join(dir, '.git'))) {
fs.mkdirSync(dir, { recursive: true });
const authedUrl = `${gitea.apiUrl}/${repo}.git`
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
try {
execSync(`git clone "${authedUrl}" "${dir}"`, { stdio: 'pipe' });
} catch {
// Repo may not exist yet — just init
execSync(`git init`, { cwd: dir, stdio: 'pipe' });
execSync(`git remote add origin "${authedUrl}"`, { cwd: dir, stdio: 'pipe' });
}
}
return dir;
}
function buildContext(repo?: string): ToolContext {
const workspaceRoot = ensureWorkspace(repo);
return {
workspaceRoot,
gitea: {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
},
coolify: {
apiUrl: process.env.COOLIFY_API_URL || '',
apiToken: process.env.COOLIFY_API_TOKEN || ''
},
memoryUpdates: []
};
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// Health check
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// ---------------------------------------------------------------------------
// GitHub mirror — clone a public GitHub repo and push to Gitea as-is
// ---------------------------------------------------------------------------
app.post('/api/mirror', async (req: Request, res: Response) => {
const { github_url, gitea_repo, project_name, github_token } = req.body as {
github_url?: string;
gitea_repo?: string; // e.g. "mark/opsos"
project_name?: string;
github_token?: string; // PAT for private repos
};
if (!github_url || !gitea_repo) {
res.status(400).json({ error: '"github_url" and "gitea_repo" are required' });
return;
}
const { execSync } = await import('child_process');
const fs = await import('fs');
const path = await import('path');
const os = await import('os');
const mirrorId = `mirror_${Date.now()}`;
const tmpDir = path.join(os.tmpdir(), mirrorId);
const gitea = {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
};
try {
// Build authenticated Gitea push URL
// GITEA_API_URL is like https://git.vibnai.com — strip /api/v1 if present
const giteaBase = gitea.apiUrl.replace(/\/api\/v1\/?$/, '');
const authedPushUrl = `${giteaBase}/${gitea_repo}.git`
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
console.log(`[mirror] Cloning ${github_url}${tmpDir}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Build authenticated clone URL for private repos
let cloneUrl = github_url;
if (github_token) {
cloneUrl = github_url.replace('https://', `https://${github_token}@`);
}
// Mirror-clone the GitHub repo (preserves all branches + tags)
execSync(`git clone --mirror "${cloneUrl}" "${tmpDir}/.git"`, {
stdio: 'pipe',
timeout: 120_000
});
execSync(`git config --bool core.bare false`, { cwd: tmpDir, stdio: 'pipe' });
execSync(`git checkout`, { cwd: tmpDir, stdio: 'pipe' });
// Point origin at Gitea and push all refs
execSync(`git remote set-url origin "${authedPushUrl}"`, { cwd: tmpDir, stdio: 'pipe' });
execSync(`git push --mirror origin`, { cwd: tmpDir, stdio: 'pipe', timeout: 120_000 });
console.log(`[mirror] Pushed ${gitea_repo} successfully`);
res.json({ success: true, gitea_repo, github_url });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[mirror] Failed:`, msg);
res.status(500).json({ error: 'Mirror failed', details: msg });
} finally {
// Clean up temp dir
try {
const { execSync: rm } = await import('child_process');
rm(`rm -rf "${tmpDir}"`, { stdio: 'pipe' });
} catch { /* best effort */ }
}
});
// List available agents
app.get('/api/agents', (_req: Request, res: Response) => {
const agents = Object.values(AGENTS).map(a => ({
name: a.name,
description: a.description,
tools: a.tools.map(t => t.name)
}));
res.json(agents);
});
// Get server status and job statistics
app.get('/api/status', (_req: Request, res: Response) => {
const allJobs = listJobs(Infinity);
const total_jobs = allJobs.length;
const by_status: { [key: string]: number } = {
queued: 0,
running: 0,
completed: 0,
failed: 0,
};
for (const job of allJobs) {
by_status[job.status] = (by_status[job.status] || 0) + 1;
}
const uptime_seconds = Math.floor((new Date().getTime() - startTime.getTime()) / 1000);
const agents = Object.values(AGENTS).map(a => a.name);
res.json({
total_jobs,
by_status,
uptime_seconds,
agents,
});
});
// Submit a new job
app.post('/api/agent/run', async (req: Request, res: Response) => {
const { agent: agentName, task, repo } = req.body as { agent?: string; task?: string; repo?: string };
if (!agentName || !task) {
res.status(400).json({ error: '"agent" and "task" are required' });
return;
}
const agentConfig = AGENTS[agentName];
if (!agentConfig) {
const available = Object.keys(AGENTS).join(', ');
res.status(400).json({ error: `Unknown agent "${agentName}". Available: ${available}` });
return;
}
const job = createJob(agentName, task, repo);
res.status(202).json({ jobId: job.id, status: job.status });
// Run agent asynchronously
const ctx = buildContext(repo);
runAgent(job, agentConfig, task, ctx)
.then(result => {
updateJob(job.id, {
status: 'completed',
result: result.finalText,
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
});
})
.catch(err => {
updateJob(job.id, {
status: 'failed',
error: err instanceof Error ? err.message : String(err),
progress: 'Agent failed'
});
});
});
// Check job status
app.get('/api/jobs/:id', (req: Request, res: Response) => {
const job = getJob(req.params.id);
if (!job) {
res.status(404).json({ error: 'Job not found' });
return;
}
res.json(job);
});
// ---------------------------------------------------------------------------
// Orchestrator — persistent chat with full project context
// ---------------------------------------------------------------------------
app.post('/orchestrator/chat', async (req: Request, res: Response) => {
const {
message,
session_id,
history,
knowledge_context
} = req.body as {
message?: string;
session_id?: string;
history?: LLMMessage[];
knowledge_context?: string;
};
if (!message) { res.status(400).json({ error: '"message" is required' }); return; }
const sessionId = session_id || `session_${Date.now()}`;
const ctx = buildContext();
try {
const result = await orchestratorChat(sessionId, message, ctx, {
preloadedHistory: history,
knowledgeContext: knowledge_context
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
});
app.get('/orchestrator/sessions', (_req: Request, res: Response) => {
res.json(listSessions());
});
app.delete('/orchestrator/sessions/:id', (req: Request, res: Response) => {
clearSession(req.params.id);
res.json({ cleared: req.params.id });
});
// ---------------------------------------------------------------------------
// Atlas — PRD discovery agent
// ---------------------------------------------------------------------------
app.post('/atlas/chat', async (req: Request, res: Response) => {
const {
message,
session_id,
history,
is_init,
} = req.body as {
message?: string;
session_id?: string;
history?: LLMMessage[];
is_init?: boolean;
};
if (!message) { res.status(400).json({ error: '"message" is required' }); return; }
const sessionId = session_id || `atlas_${Date.now()}`;
const ctx = buildContext();
try {
const result = await atlasChat(sessionId, message, ctx, {
preloadedHistory: history,
isInit: is_init,
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
});
app.get('/atlas/sessions', (_req: Request, res: Response) => {
res.json(listAtlasSessions());
});
app.delete('/atlas/sessions/:id', (req: Request, res: Response) => {
clearAtlasSession(req.params.id);
res.json({ cleared: req.params.id });
});
// List recent jobs
app.get('/api/jobs', (req: Request, res: Response) => {
const limit = parseInt((req.query.limit as string) || '20', 10);
res.json(listJobs(limit));
});
// Gitea webhook endpoint — triggers agent from an issue event
app.post('/webhook/gitea', (req: Request, res: Response) => {
const event = req.headers['x-gitea-event'] as string;
const rawBody = req.body as Buffer;
// Verify HMAC-SHA256 signature
const webhookSecret = process.env.WEBHOOK_SECRET;
if (webhookSecret) {
const sig = req.headers['x-gitea-signature'] as string;
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex');
if (!sig || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
res.status(401).json({ error: 'Invalid webhook signature' });
return;
}
}
const body = JSON.parse(rawBody.toString('utf8'));
let task: string | null = null;
let agentName = 'Coder';
let repo: string | undefined;
if (event === 'issues' && body.action === 'opened') {
const issue = body.issue;
repo = `${body.repository?.owner?.login}/${body.repository?.name}`;
const labels: string[] = (issue.labels || []).map((l: any) => l.name as string);
if (labels.includes('agent:pm')) {
agentName = 'PM';
} else if (labels.includes('agent:marketing')) {
agentName = 'Marketing';
} else if (labels.includes('agent:coder')) {
agentName = 'Coder';
} else {
// No agent label — ignore
res.json({ ignored: true, reason: 'no agent label on issue' });
return;
}
task = `You have been assigned to resolve a Gitea issue in the repo ${repo}.\n\nIssue #${issue.number}: ${issue.title}\n\nDescription:\n${issue.body || '(no description)'}\n\nWhen done, close the issue by calling gitea_close_issue.`;
} else if (event === 'push') {
res.json({ ignored: true, reason: 'push events not auto-processed' });
return;
} else {
res.json({ ignored: true, event });
return;
}
if (!task) {
res.json({ ignored: true });
return;
}
const agentConfig = AGENTS[agentName];
const job = createJob(agentName, task, repo);
res.status(202).json({ jobId: job.id, agent: agentName, event });
const ctx = buildContext(repo);
runAgent(job, agentConfig, task, ctx)
.then(result => {
updateJob(job.id, {
status: 'completed',
result: result.finalText,
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
});
})
.catch(err => {
updateJob(job.id, {
status: 'failed',
error: err instanceof Error ? err.message : String(err),
progress: 'Agent failed'
});
});
});
// ---------------------------------------------------------------------------
// Agent Execute — VIBN Build > Code > Agent tab
//
// Receives a task from the VIBN frontend, runs the Coder agent against
// the project's Gitea repo, and streams progress back to the VIBN DB
// via PATCH /api/projects/[id]/agent/sessions/[sid].
//
// This endpoint returns immediately (202) and runs the agent async so
// the browser can close without killing the loop.
// ---------------------------------------------------------------------------
// Track active sessions for stop support
const activeSessions = new Map<string, { stopped: boolean }>();
app.post('/agent/execute', async (req: Request, res: Response) => {
const {
sessionId, projectId, appName, appPath, giteaRepo, task, continueTask,
autoApprove, coolifyAppUuid,
} = req.body as {
sessionId?: string;
projectId?: string;
appName?: string;
appPath?: string;
giteaRepo?: string;
task?: string;
continueTask?: string;
autoApprove?: boolean;
coolifyAppUuid?: string;
};
if (!sessionId || !projectId || !appPath || !task) {
res.status(400).json({ error: 'sessionId, projectId, appPath and task are required' });
return;
}
const vibnApiUrl = process.env.VIBN_API_URL ?? 'https://vibnai.com';
// Register session as active
const sessionState = { stopped: false };
activeSessions.set(sessionId, sessionState);
// Respond immediately — execution is async
res.status(202).json({ sessionId, status: 'running' });
// Build workspace context — clone/update the Gitea repo if provided
let ctx: ReturnType<typeof buildContext>;
try {
ctx = buildContext(giteaRepo);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[agent/execute] buildContext failed:', msg);
// Notify VIBN DB of failure
fetch(`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error: msg }),
}).catch(() => {});
activeSessions.delete(sessionId);
return;
}
// Capture repo root before scoping to appPath — needed for git commit in auto-approve
const repoRoot = ctx.workspaceRoot;
// Scope workspace to the app subdirectory so the agent works there naturally
if (appPath) {
const path = require('path') as typeof import('path');
ctx.workspaceRoot = path.join(ctx.workspaceRoot, appPath);
const fs = require('fs') as typeof import('fs');
fs.mkdirSync(ctx.workspaceRoot, { recursive: true });
}
const agentConfig = AGENTS['Coder'];
if (!agentConfig) {
fetch(`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error: 'Coder agent not registered' }),
}).catch(() => {});
activeSessions.delete(sessionId);
return;
}
// If continuing a previous task, combine into a single prompt so the agent
// understands what was already attempted.
const effectiveTask = continueTask
? `Original task: ${task}\n\nFollow-up instruction: ${continueTask}`
: task!;
// Run the streaming agent loop (fire and forget)
runSessionAgent(agentConfig, effectiveTask, ctx, {
sessionId,
projectId,
vibnApiUrl,
appPath,
repoRoot,
isStopped: () => sessionState.stopped,
autoApprove: autoApprove ?? true,
giteaRepo,
coolifyAppUuid,
coolifyApiUrl: process.env.COOLIFY_API_URL,
coolifyApiToken: process.env.COOLIFY_API_TOKEN,
})
.catch(err => {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[agent/execute] session ${sessionId} crashed:`, msg);
fetch(`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error: msg }),
}).catch(() => {});
})
.finally(() => {
activeSessions.delete(sessionId);
});
});
app.post('/agent/stop', (req: Request, res: Response) => {
const { sessionId } = req.body as { sessionId?: string };
if (!sessionId) { res.status(400).json({ error: 'sessionId required' }); return; }
const session = activeSessions.get(sessionId);
if (session) {
session.stopped = true;
res.json({ ok: true, message: 'Stop signal sent — agent will halt after current step.' });
} else {
res.json({ ok: true, message: 'Session not active (may have already completed).' });
}
});
// ---------------------------------------------------------------------------
// Agent Approve — commit and push agent's changes to Gitea, trigger deploy
//
// Called by vibn-frontend after the user reviews changed files and clicks
// "Approve & commit". The agent runner does git add/commit/push in the
// workspace where the agent was working.
// ---------------------------------------------------------------------------
app.post('/agent/approve', async (req: Request, res: Response) => {
const { giteaRepo, commitMessage, coolifyApiUrl, coolifyApiToken, coolifyAppUuid } = req.body as {
giteaRepo?: string;
commitMessage?: string;
coolifyApiUrl?: string;
coolifyApiToken?: string;
coolifyAppUuid?: string;
};
if (!giteaRepo || !commitMessage) {
res.status(400).json({ error: 'giteaRepo and commitMessage are required' });
return;
}
try {
// Resolve the workspace root for this repo (does NOT re-clone if already present)
const workspaceRoot = ensureWorkspace(giteaRepo);
// Configure git identity for this commit
const gitea = {
username: process.env.GITEA_USERNAME || 'agent',
apiToken: process.env.GITEA_API_TOKEN || '',
apiUrl: process.env.GITEA_API_URL || '',
};
const { execSync: exec } = require('child_process') as typeof import('child_process');
const gitOpts = { cwd: workspaceRoot, stdio: 'pipe' as const };
// Ensure git identity
try {
exec('git config user.email "agent@vibnai.com"', gitOpts);
exec('git config user.name "VIBN Agent"', gitOpts);
} catch { /* already set */ }
// Stage all changes
exec('git add -A', gitOpts);
// Check if there is anything to commit
let status: string;
try {
status = exec('git status --porcelain', gitOpts).toString().trim();
} catch { status = ''; }
if (!status) {
res.json({ ok: true, committed: false, message: 'Nothing to commit — working tree is clean.' });
return;
}
// Commit
exec(`git commit -m ${JSON.stringify(commitMessage)}`, gitOpts);
// Push — use token auth embedded in remote URL
const authedUrl = `${gitea.apiUrl}/${giteaRepo}.git`
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
exec(`git push "${authedUrl}" HEAD:main`, gitOpts);
// Optionally trigger a Coolify redeploy
let deployed = false;
if (coolifyApiUrl && coolifyApiToken && coolifyAppUuid) {
try {
const deployRes = await fetch(`${coolifyApiUrl}/api/v1/applications/${coolifyAppUuid}/start`, {
method: 'POST',
headers: { Authorization: `Bearer ${coolifyApiToken}` },
});
deployed = deployRes.ok;
} catch { /* deploy trigger is best-effort */ }
}
res.json({ ok: true, committed: true, deployed, message: `Committed and pushed: "${commitMessage}"` });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[agent/approve]', msg);
res.status(500).json({ error: msg });
}
});
// ---------------------------------------------------------------------------
// Generate — thin structured-generation endpoint (no session, no system prompt)
// Use this for one-shot tasks like architecture recommendations.
// ---------------------------------------------------------------------------
app.post('/generate', async (req: Request, res: Response) => {
const { prompt, model, region } = req.body as { prompt?: string; model?: string; region?: string };
if (!prompt) { res.status(400).json({ error: '"prompt" is required' }); return; }
// Allow overriding CLAUDE_REGION per-request for testing
const prevRegion = process.env.CLAUDE_REGION;
if (region) process.env.CLAUDE_REGION = region;
try {
const llm = createLLM(model ?? 'A', { temperature: 0.3 });
const messages: import('./llm').LLMMessage[] = [
{ role: 'user', content: prompt }
];
const response = await llm.chat(messages, [], 8192);
res.json({ reply: response.content ?? '', model: llm.modelId });
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err), model });
} finally {
if (region) process.env.CLAUDE_REGION = prevRegion ?? '';
}
});
// ---------------------------------------------------------------------------
// Error handler
// ---------------------------------------------------------------------------
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: err.message });
});
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
app.listen(PORT, () => {
console.log(`AgentRunner listening on port ${PORT}`);
console.log(`Agents available: ${Object.keys(AGENTS).join(', ')}`);
if (!process.env.GOOGLE_API_KEY) {
console.warn('WARNING: GOOGLE_API_KEY is not set — agents will fail');
}
});

12
src/test.ts Normal file
View File

@@ -0,0 +1,12 @@
import assert from 'assert';
function add(a: number, b: number): number {
return a + b;
}
assert.strictEqual(add(1, 2), 3, 'add(1, 2) should be 3');
assert.strictEqual(add(0, 0), 0, 'add(0, 0) should be 0');
assert.strictEqual(add(-1, 1), 0, 'add(-1, 1) should be 0');
console.log('All tests passed!');

46
src/tools/agent-api.ts Normal file
View File

@@ -0,0 +1,46 @@
// =============================================================================
// Pure sub-agent orchestration API. Wraps the vibn-agent-runner HTTP endpoints
// so the same logic is usable from the in-process tool and from an MCP server.
// =============================================================================
export interface AgentRunnerConfig {
runnerUrl: string;
}
export interface SpawnAgentInput {
agent: string; // "Coder" | "PM" | "Marketing"
task: string;
repo: string; // "owner/name"
}
export async function spawnAgent(cfg: AgentRunnerConfig, input: SpawnAgentInput): Promise<unknown> {
try {
const res = await fetch(`${cfg.runnerUrl}/api/agent/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Internal': 'true' },
body: JSON.stringify({ agent: input.agent, task: input.task, repo: input.repo }),
});
const data = (await res.json()) as any;
return { jobId: data.jobId, agent: input.agent, status: 'dispatched' };
} catch (err) {
return { error: `Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}` };
}
}
export async function getJobStatus(cfg: AgentRunnerConfig, jobId: string): Promise<unknown> {
try {
const res = await fetch(`${cfg.runnerUrl}/api/jobs/${jobId}`);
const job = (await res.json()) as any;
return {
id: job.id,
agent: job.agent,
status: job.status,
progress: job.progress,
toolCalls: job.toolCalls?.length,
result: job.result,
error: job.error,
};
} catch (err) {
return { error: `Failed to get job: ${err instanceof Error ? err.message : String(err)}` };
}
}

45
src/tools/agent.ts Normal file
View File

@@ -0,0 +1,45 @@
// =============================================================================
// Sub-agent orchestration tool registrations. Logic lives in ./agent-api.ts.
// =============================================================================
import { registerTool } from './registry';
import * as api from './agent-api';
function runnerUrl(): string {
return process.env.AGENT_RUNNER_URL || 'http://localhost:3333';
}
registerTool({
name: 'spawn_agent',
description: 'Dispatch a sub-agent job to run in the background. Returns a job ID. Use this to delegate specialized work to Coder, PM, or Marketing agents.',
parameters: {
type: 'object',
properties: {
agent: { type: 'string', description: '"Coder", "PM", or "Marketing"' },
task: { type: 'string', description: 'Detailed task description for the agent' },
repo: { type: 'string', description: 'Gitea repo in "owner/name" format the agent should work on' }
},
required: ['agent', 'task', 'repo']
},
async handler(args, _ctx) {
return api.spawnAgent(
{ runnerUrl: runnerUrl() },
{ agent: String(args.agent), task: String(args.task), repo: String(args.repo) },
);
}
});
registerTool({
name: 'get_job_status',
description: 'Check the status of a previously spawned agent job by job ID.',
parameters: {
type: 'object',
properties: {
job_id: { type: 'string', description: 'Job ID returned by spawn_agent' }
},
required: ['job_id']
},
async handler(args, _ctx) {
return api.getJobStatus({ runnerUrl: runnerUrl() }, String(args.job_id));
}
});

20
src/tools/context.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface MemoryUpdate {
key: string;
type: string; // "tech_stack" | "decision" | "feature" | "goal" | "constraint" | "note"
value: string;
}
export interface ToolContext {
workspaceRoot: string;
gitea: {
apiUrl: string;
apiToken: string;
username: string;
};
coolify: {
apiUrl: string;
apiToken: string;
};
/** Accumulated memory updates from save_memory tool calls in this turn */
memoryUpdates: MemoryUpdate[];
}

128
src/tools/coolify-api.ts Normal file
View File

@@ -0,0 +1,128 @@
// =============================================================================
// Pure Coolify API — no ToolContext coupling, no registry coupling.
//
// Everything in here takes a plain { apiUrl, apiToken } config and calls
// the Coolify v1 API directly. Security guardrails (PROTECTED_COOLIFY_PROJECT,
// PROTECTED_COOLIFY_APPS, assertCoolifyDeployable) are enforced inside each
// function so every caller — in-process tool handler, MCP server, or future
// direct SDK user — gets the same protection.
//
// This is the shared core consumed by:
// - tools/coolify.ts (in-process registry used by agent-runner loop)
// - mcp/coolify-server.ts (stdio MCP server exposed to Goose/Claude/Cursor)
// =============================================================================
import { PROTECTED_COOLIFY_PROJECT, PROTECTED_COOLIFY_APPS, assertCoolifyDeployable } from './security';
export interface CoolifyConfig {
apiUrl: string;
apiToken: string;
}
type CoolifyError = { error: string };
async function coolifyFetch(
cfg: CoolifyConfig,
path: string,
method: string = 'GET',
body?: unknown
): Promise<unknown> {
const res = await fetch(`${cfg.apiUrl}/api/v1${path}`, {
method,
headers: {
'Authorization': `Bearer ${cfg.apiToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
if (!res.ok) {
return { error: `Coolify API error: ${res.status} ${res.statusText}` } satisfies CoolifyError;
}
return res.json();
}
// ---------------------------------------------------------------------------
// Public API — each function corresponds 1:1 with a registered tool today
// ---------------------------------------------------------------------------
export async function listProjects(cfg: CoolifyConfig): Promise<unknown> {
const projects = await coolifyFetch(cfg, '/projects') as any[];
if (!Array.isArray(projects)) return projects;
return projects.filter((p: any) => p.uuid !== PROTECTED_COOLIFY_PROJECT);
}
export async function listApplications(cfg: CoolifyConfig, projectUuid: string): Promise<unknown> {
const all = await coolifyFetch(cfg, '/applications') as any[];
if (!Array.isArray(all)) return all;
return all.filter((a: any) => a.project_uuid === projectUuid);
}
export async function deploy(cfg: CoolifyConfig, applicationUuid: string): Promise<unknown> {
assertCoolifyDeployable(applicationUuid);
const apps = await coolifyFetch(cfg, '/applications') as any[];
if (Array.isArray(apps)) {
const app = apps.find((a: any) => a.uuid === applicationUuid);
if (app?.project_uuid === PROTECTED_COOLIFY_PROJECT) {
return {
error: `SECURITY: App "${applicationUuid}" belongs to the protected Vibn project. Agents cannot deploy platform apps.`
} satisfies CoolifyError;
}
}
return coolifyFetch(cfg, `/applications/${applicationUuid}/deploy`, 'POST');
}
export async function getLogs(cfg: CoolifyConfig, applicationUuid: string, limit: number = 50): Promise<unknown> {
return coolifyFetch(cfg, `/applications/${applicationUuid}/logs?limit=${limit}`);
}
export async function listAllApps(cfg: CoolifyConfig): Promise<unknown> {
const apps = await coolifyFetch(cfg, '/applications') as any[];
if (!Array.isArray(apps)) return apps;
return apps
.filter((a: any) => a.project_uuid !== PROTECTED_COOLIFY_PROJECT && !PROTECTED_COOLIFY_APPS.has(a.uuid))
.map((a: any) => ({
uuid: a.uuid,
name: a.name,
fqdn: a.fqdn,
status: a.status,
repo: a.git_repository,
branch: a.git_branch
}));
}
export async function getAppStatus(cfg: CoolifyConfig, appName: string): Promise<unknown> {
const apps = await coolifyFetch(cfg, '/applications') as any[];
if (!Array.isArray(apps)) return apps;
const app = apps.find((a: any) =>
a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName
);
if (!app) return { error: `App "${appName}" not found` } satisfies CoolifyError;
if (PROTECTED_COOLIFY_APPS.has(app.uuid) || app.project_uuid === PROTECTED_COOLIFY_PROJECT) {
return {
error: `SECURITY: "${appName}" is a protected Vibn platform app. Status is not exposed to agents.`
} satisfies CoolifyError;
}
const logs = await coolifyFetch(cfg, `/applications/${app.uuid}/logs?limit=20`);
return { name: app.name, uuid: app.uuid, status: app.status, fqdn: app.fqdn, logs };
}
export async function deployApp(cfg: CoolifyConfig, appName: string): Promise<unknown> {
const apps = await coolifyFetch(cfg, '/applications') as any[];
if (!Array.isArray(apps)) return apps;
const app = apps.find((a: any) =>
a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName
);
if (!app) return { error: `App "${appName}" not found` } satisfies CoolifyError;
if (PROTECTED_COOLIFY_APPS.has(app.uuid) || app.project_uuid === PROTECTED_COOLIFY_PROJECT) {
return {
error: `SECURITY: "${appName}" is a protected Vibn platform application. ` +
`Agents can only deploy user project apps, not platform infrastructure.`
} satisfies CoolifyError;
}
// Non-project-prefixed deploy endpoint — older Coolify entry point still in use
const result = await fetch(`${cfg.apiUrl}/api/v1/deploy?uuid=${app.uuid}&force=false`, {
headers: { 'Authorization': `Bearer ${cfg.apiToken}` }
});
return result.json();
}

104
src/tools/coolify.ts Normal file
View File

@@ -0,0 +1,104 @@
// =============================================================================
// Coolify tool registrations (in-process path used by agent-runner).
//
// All logic lives in ./coolify-api.ts so the MCP server (src/mcp/coolify-server.ts)
// and this in-process registry call the exact same code path. Keep this file
// purely about: (a) surface-shape for the LLM (name/description/parameters),
// (b) mapping ctx.coolify → CoolifyConfig. No business logic here.
// =============================================================================
import { registerTool } from './registry';
import * as api from './coolify-api';
registerTool({
name: 'coolify_list_projects',
description: 'List all projects in the Coolify instance. Returns project names and UUIDs.',
parameters: { type: 'object', properties: {} },
async handler(_args, ctx) {
return api.listProjects(ctx.coolify);
}
});
registerTool({
name: 'coolify_list_applications',
description: 'List applications in a Coolify project.',
parameters: {
type: 'object',
properties: {
project_uuid: { type: 'string', description: 'Project UUID from coolify_list_projects' }
},
required: ['project_uuid']
},
async handler(args, ctx) {
return api.listApplications(ctx.coolify, String(args.project_uuid));
}
});
registerTool({
name: 'coolify_deploy',
description: 'Trigger a deployment for a Coolify application.',
parameters: {
type: 'object',
properties: {
application_uuid: { type: 'string', description: 'Application UUID to deploy' }
},
required: ['application_uuid']
},
async handler(args, ctx) {
return api.deploy(ctx.coolify, String(args.application_uuid));
}
});
registerTool({
name: 'coolify_get_logs',
description: 'Get recent deployment logs for a Coolify application.',
parameters: {
type: 'object',
properties: {
application_uuid: { type: 'string', description: 'Application UUID' }
},
required: ['application_uuid']
},
async handler(args, ctx) {
return api.getLogs(ctx.coolify, String(args.application_uuid));
}
});
registerTool({
name: 'list_all_apps',
description: 'List all Coolify applications across all projects with their status (running/stopped/error) and domain.',
parameters: { type: 'object', properties: {} },
async handler(_args, ctx) {
return api.listAllApps(ctx.coolify);
}
});
registerTool({
name: 'get_app_status',
description: 'Get the current deployment status and recent logs for a specific Coolify application by name or UUID.',
parameters: {
type: 'object',
properties: {
app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend") or UUID' }
},
required: ['app_name']
},
async handler(args, ctx) {
return api.getAppStatus(ctx.coolify, String(args.app_name));
}
});
registerTool({
name: 'deploy_app',
description: 'Trigger a Coolify deployment for an app by name. Use after an agent commits code.',
parameters: {
type: 'object',
properties: {
app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend")' }
},
required: ['app_name']
},
async handler(args, ctx) {
return api.deployApp(ctx.coolify, String(args.app_name));
}
});

110
src/tools/file-api.ts Normal file
View File

@@ -0,0 +1,110 @@
// =============================================================================
// Pure file-system API — no ToolContext coupling.
// Takes a workspaceRoot string and safely-resolves paths beneath it.
// =============================================================================
import * as fs from 'fs';
import * as path from 'path';
import * as cp from 'child_process';
import * as util from 'util';
import { Minimatch } from 'minimatch';
import { safeResolve, EXCLUDED } from './utils';
const execAsync = util.promisify(cp.exec);
export async function readFile(workspaceRoot: string, relPath: string): Promise<unknown> {
const abs = safeResolve(workspaceRoot, relPath);
try {
return fs.readFileSync(abs, 'utf8');
} catch {
return { error: `File not found: ${relPath}` };
}
}
export async function writeFile(workspaceRoot: string, relPath: string, content: string): Promise<unknown> {
const abs = safeResolve(workspaceRoot, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf8');
return { success: true, path: relPath, bytes: Buffer.byteLength(content) };
}
export async function replaceInFile(
workspaceRoot: string,
relPath: string,
oldContent: string,
newContent: string
): Promise<unknown> {
const abs = safeResolve(workspaceRoot, relPath);
const current = fs.readFileSync(abs, 'utf8');
if (!current.includes(oldContent)) {
return { error: 'old_content not found in file. Read the file again to get the current content.' };
}
fs.writeFileSync(abs, current.replace(oldContent, newContent), 'utf8');
return { success: true, path: relPath };
}
export async function listDirectory(workspaceRoot: string, relPath: string): Promise<unknown> {
const abs = safeResolve(workspaceRoot, relPath);
try {
const entries = fs.readdirSync(abs, { withFileTypes: true });
return entries
.filter(e => !EXCLUDED.has(e.name))
.map(e => e.isDirectory() ? `${e.name}/` : e.name);
} catch {
return { error: `Directory not found: ${relPath}` };
}
}
export async function findFiles(workspaceRoot: string, pattern: string): Promise<unknown> {
const matcher = new Minimatch(pattern, { dot: false });
const results: string[] = [];
function walk(dir: string): void {
if (results.length >= 200) return;
let entries: fs.Dirent[];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (EXCLUDED.has(e.name)) continue;
const abs = path.join(dir, e.name);
const rel = path.relative(workspaceRoot, abs).split(path.sep).join('/');
if (e.isDirectory()) {
walk(abs);
} else if (matcher.match(rel)) {
results.push(rel);
}
}
}
walk(workspaceRoot);
return { files: results, truncated: results.length >= 200 };
}
export async function searchCode(
workspaceRoot: string,
query: string,
fileExtensions?: string[]
): Promise<unknown> {
const globPatterns = fileExtensions?.map(e => `*.${e}`) || [];
const rgArgs = ['--line-number', '--no-heading', '--color=never', '--max-count=30'];
for (const ex of EXCLUDED) { rgArgs.push('--glob', `!${ex}`); }
if (globPatterns.length > 0) { for (const g of globPatterns) rgArgs.push('--glob', g); }
rgArgs.push('--fixed-strings', query, workspaceRoot);
try {
const { stdout } = await execAsync(`rg ${rgArgs.map(a => `'${a}'`).join(' ')}`, {
cwd: workspaceRoot, timeout: 15000,
});
return stdout.trim().split('\n').filter(Boolean).map(line => {
const m = line.match(/^(.+?):(\d+):(.*)$/);
if (!m) return null;
return {
file: path.relative(workspaceRoot, m[1]).split(path.sep).join('/'),
line: parseInt(m[2]),
content: m[3].trim(),
};
}).filter(Boolean);
} catch (err: any) {
if (err.code === 1) return []; // ripgrep exit 1 = no matches
return { error: `Search failed: ${err.message}` };
}
}

111
src/tools/file.ts Normal file
View File

@@ -0,0 +1,111 @@
// =============================================================================
// File-system tool registrations (in-process path used by agent-runner).
// All logic lives in ./file-api.ts.
// =============================================================================
import { registerTool } from './registry';
import * as api from './file-api';
registerTool({
name: 'read_file',
description: 'Read the complete content of a file in the workspace. Always read before editing.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path from workspace root (e.g. "src/index.ts")' }
},
required: ['path']
},
async handler(args, ctx) {
return api.readFile(ctx.workspaceRoot, String(args.path));
}
});
registerTool({
name: 'write_file',
description: 'Write complete content to a file. Creates parent directories if needed. Overwrites existing files.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path from workspace root' },
content: { type: 'string', description: 'Complete new file content' }
},
required: ['path', 'content']
},
async handler(args, ctx) {
return api.writeFile(ctx.workspaceRoot, String(args.path), String(args.content));
}
});
registerTool({
name: 'replace_in_file',
description: 'Replace an exact string in a file. The old_content must match character-for-character. Read the file first.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path from workspace root' },
old_content: { type: 'string', description: 'Exact text to replace' },
new_content: { type: 'string', description: 'Replacement text' }
},
required: ['path', 'old_content', 'new_content']
},
async handler(args, ctx) {
return api.replaceInFile(
ctx.workspaceRoot,
String(args.path),
String(args.old_content),
String(args.new_content)
);
}
});
registerTool({
name: 'list_directory',
description: 'List files and subdirectories in a directory. Directories have trailing "/".',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path from workspace root. Use "." for root.' }
},
required: ['path']
},
async handler(args, ctx) {
return api.listDirectory(ctx.workspaceRoot, String(args.path));
}
});
registerTool({
name: 'find_files',
description: 'Find files matching a glob pattern in the workspace. Returns up to 200 relative paths.',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Glob pattern e.g. "**/*.ts", "src/**/*.test.js"' }
},
required: ['pattern']
},
async handler(args, ctx) {
return api.findFiles(ctx.workspaceRoot, String(args.pattern));
}
});
registerTool({
name: 'search_code',
description: 'Search file contents for a string or regex pattern. Returns file path, line number, and matching line.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search term or regex' },
file_extensions: {
type: 'array',
items: { type: 'string' },
description: 'Optional: limit to these extensions e.g. ["ts","js"]'
}
},
required: ['query']
},
async handler(args, ctx) {
const exts = Array.isArray(args.file_extensions) ? (args.file_extensions as string[]) : undefined;
return api.searchCode(ctx.workspaceRoot, String(args.query), exts);
}
});

102
src/tools/git-api.ts Normal file
View File

@@ -0,0 +1,102 @@
// =============================================================================
// Pure git API — no ToolContext coupling.
// Requires a GitPushConfig with Gitea credentials for authenticated push.
// =============================================================================
import * as cp from "child_process";
import * as util from "util";
import { PROTECTED_GITEA_REPOS } from "./security";
const execAsync = util.promisify(cp.exec);
import fs from "fs";
import path from "path";
export interface GitPushConfig {
apiUrl: string;
apiToken: string;
username: string;
}
export async function gitCommitAndPush(
workspaceRoot: string,
message: string,
cfg: GitPushConfig,
): Promise<unknown> {
const cwd = workspaceRoot;
const { apiUrl, apiToken, username } = cfg;
try {
// Check remote URL before committing — block pushes to protected repos
let remoteCheck = "";
try {
remoteCheck = (
await execAsync("git remote get-url origin", { cwd })
).stdout.trim();
} catch {
/* no remote yet */
}
for (const protectedRepo of PROTECTED_GITEA_REPOS) {
const repoPath = protectedRepo.replace("mark/", "");
if (
remoteCheck.includes(`/${repoPath}`) ||
remoteCheck.includes(`/${repoPath}.git`)
) {
return {
error:
`SECURITY: This workspace is linked to a protected Vibn platform repo (${protectedRepo}). ` +
`Agents cannot push to platform repos. Only user project repos are writable.`,
};
}
}
// Write commit message to a temporary file to avoid shell injection
const msgFile = path.join(cwd, ".git", "COMMIT_EDITMSG");
fs.writeFileSync(msgFile, message, "utf8");
await execAsync("git add -A", { cwd });
await execAsync("git commit -F .git/COMMIT_EDITMSG", { cwd });
try {
fs.unlinkSync(msgFile);
} catch {
/* ignore */
}
// Strip any existing credentials from remote URL and re-inject cleanly
let remoteUrl = "";
try {
remoteUrl = (
await execAsync("git remote get-url origin", { cwd })
).stdout.trim();
} catch {
/* no remote */
}
const cleanUrl = remoteUrl.replace(/https:\/\/[^@]+@/, "https://");
const baseUrl = cleanUrl || apiUrl;
const authedUrl = baseUrl.replace(
"https://",
`https://${username}:${apiToken}@`,
);
await execAsync(`git remote set-url origin "${authedUrl}"`, { cwd }).catch(
async () => {
await execAsync(`git remote add origin "${authedUrl}"`, { cwd });
},
);
const branch = (
await execAsync("git rev-parse --abbrev-ref HEAD", { cwd })
).stdout.trim();
await execAsync(`git push -u origin "${branch}"`, { cwd, timeout: 60_000 });
return { success: true, message, branch };
} catch (err: any) {
const cleaned = (err.message || "").replace(
new RegExp(apiToken, "g"),
"***",
);
return { error: `Git operation failed: ${cleaned}` };
}
}

25
src/tools/git.ts Normal file
View File

@@ -0,0 +1,25 @@
// =============================================================================
// Git commit-and-push tool registration. Logic lives in ./git-api.ts.
// =============================================================================
import { registerTool } from './registry';
import * as api from './git-api';
registerTool({
name: 'git_commit_and_push',
description: 'Stage all changes, commit with a message, and push to the remote. Call this when work is complete.',
parameters: {
type: 'object',
properties: {
message: { type: 'string', description: 'Commit message describing the changes made' }
},
required: ['message']
},
async handler(args, ctx) {
return api.gitCommitAndPush(ctx.workspaceRoot, String(args.message), {
apiUrl: ctx.gitea.apiUrl,
apiToken: ctx.gitea.apiToken,
username: ctx.gitea.username,
});
}
});

149
src/tools/gitea-api.ts Normal file
View File

@@ -0,0 +1,149 @@
// =============================================================================
// Pure Gitea API — no ToolContext coupling, no registry coupling.
//
// Takes a plain { apiUrl, apiToken, username } config. Security guardrails
// (PROTECTED_GITEA_REPOS, assertGiteaWritable) are enforced inside each
// function so every caller gets the same protection.
//
// Consumed by:
// - tools/gitea.ts (in-process registry used by agent-runner loop)
// - mcp/gitea-server.ts (stdio MCP server exposed to any MCP client)
// =============================================================================
import { PROTECTED_GITEA_REPOS, assertGiteaWritable } from './security';
export interface GiteaConfig {
apiUrl: string;
apiToken: string;
username?: string;
}
type GiteaError = { error: string };
async function giteaFetch(
cfg: GiteaConfig,
path: string,
method: string = 'GET',
body?: unknown
): Promise<unknown> {
const res = await fetch(`${cfg.apiUrl}/api/v1${path}`, {
method,
headers: {
'Authorization': `token ${cfg.apiToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
if (!res.ok) {
return { error: `Gitea API error: ${res.status} ${res.statusText}` } satisfies GiteaError;
}
return res.json();
}
// ---------------------------------------------------------------------------
// Public API — 1:1 with tool surface
// ---------------------------------------------------------------------------
export interface CreateIssueInput {
repo: string;
title: string;
body: string;
labels?: string[];
}
export async function createIssue(cfg: GiteaConfig, input: CreateIssueInput): Promise<unknown> {
assertGiteaWritable(input.repo);
return giteaFetch(cfg, `/repos/${input.repo}/issues`, 'POST', {
title: input.title,
body: input.body,
labels: input.labels,
});
}
export async function listIssues(cfg: GiteaConfig, repo: string, state: string = 'open'): Promise<unknown> {
return giteaFetch(cfg, `/repos/${repo}/issues?state=${state}&limit=20`);
}
export async function closeIssue(cfg: GiteaConfig, repo: string, issueNumber: number): Promise<unknown> {
assertGiteaWritable(repo);
return giteaFetch(cfg, `/repos/${repo}/issues/${issueNumber}`, 'PATCH', { state: 'closed' });
}
export async function listRepos(cfg: GiteaConfig): Promise<unknown> {
const res = await fetch(`${cfg.apiUrl}/api/v1/repos/search?limit=50`, {
headers: { 'Authorization': `token ${cfg.apiToken}` }
});
if (!res.ok) {
return { error: `Gitea API error: ${res.status} ${res.statusText}` } satisfies GiteaError;
}
const data = await res.json() as any;
return (data.data || [])
.filter((r: any) => !PROTECTED_GITEA_REPOS.has(r.full_name))
.map((r: any) => ({
name: r.full_name,
description: r.description,
default_branch: r.default_branch,
updated: r.updated,
stars: r.stars_count,
open_issues: r.open_issues_count,
}));
}
export async function listAllIssues(
cfg: GiteaConfig,
opts: { repo?: string; state?: string } = {}
): Promise<unknown> {
const state = opts.state || 'open';
if (opts.repo) {
if (PROTECTED_GITEA_REPOS.has(opts.repo)) {
return {
error: `SECURITY: "${opts.repo}" is a protected Vibn platform repo. Agents cannot access its issues.`
} satisfies GiteaError;
}
return giteaFetch(cfg, `/repos/${opts.repo}/issues?state=${state}&limit=20`);
}
// Fetch across all non-protected repos (cap at 10 repos to bound request count)
const reposRes = await fetch(`${cfg.apiUrl}/api/v1/repos/search?limit=50`, {
headers: { 'Authorization': `token ${cfg.apiToken}` }
});
if (!reposRes.ok) {
return { error: `Gitea API error: ${reposRes.status} ${reposRes.statusText}` } satisfies GiteaError;
}
const reposData = await reposRes.json() as any;
const repos = (reposData.data || []).filter((r: any) => !PROTECTED_GITEA_REPOS.has(r.full_name));
const allIssues: unknown[] = [];
for (const r of repos.slice(0, 10)) {
const issues = await giteaFetch(cfg, `/repos/${r.full_name}/issues?state=${state}&limit=10`) as any[];
if (Array.isArray(issues)) {
allIssues.push(...issues.map((i: any) => ({
repo: r.full_name,
number: i.number,
title: i.title,
state: i.state,
labels: i.labels?.map((l: any) => l.name),
created: i.created_at,
})));
}
}
return allIssues;
}
export async function readRepoFile(cfg: GiteaConfig, repo: string, filePath: string): Promise<unknown> {
try {
const res = await fetch(`${cfg.apiUrl}/api/v1/repos/${repo}/contents/${filePath}`, {
headers: { 'Authorization': `token ${cfg.apiToken}` }
});
if (!res.ok) return { error: `File not found: ${filePath} in ${repo}` } satisfies GiteaError;
const data = await res.json() as any;
const content = Buffer.from(data.content, 'base64').toString('utf8');
return { repo, path: filePath, content };
} catch (err) {
return {
error: `Failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`
} satisfies GiteaError;
}
}

109
src/tools/gitea.ts Normal file
View File

@@ -0,0 +1,109 @@
// =============================================================================
// Gitea tool registrations (in-process path used by agent-runner).
//
// All logic lives in ./gitea-api.ts so the MCP server (src/mcp/gitea-server.ts)
// and this in-process registry call the exact same code path. Keep this file
// purely about: (a) surface-shape for the LLM (name/description/parameters),
// (b) mapping ctx.gitea → GiteaConfig. No business logic here.
// =============================================================================
import { registerTool } from './registry';
import * as api from './gitea-api';
registerTool({
name: 'gitea_create_issue',
description: 'Create a new issue in a Gitea repository.',
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository in "owner/name" format' },
title: { type: 'string', description: 'Issue title' },
body: { type: 'string', description: 'Issue body (markdown)' },
labels: { type: 'array', items: { type: 'string' }, description: 'Optional label names' }
},
required: ['repo', 'title', 'body']
},
async handler(args, ctx) {
return api.createIssue(ctx.gitea, {
repo: String(args.repo),
title: String(args.title),
body: String(args.body),
labels: Array.isArray(args.labels) ? (args.labels as string[]) : undefined,
});
}
});
registerTool({
name: 'gitea_list_issues',
description: 'List open issues in a Gitea repository.',
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository in "owner/name" format' },
state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' }
},
required: ['repo']
},
async handler(args, ctx) {
return api.listIssues(ctx.gitea, String(args.repo), String(args.state || 'open'));
}
});
registerTool({
name: 'gitea_close_issue',
description: 'Close an issue in a Gitea repository.',
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repository in "owner/name" format' },
issue_number: { type: 'number', description: 'Issue number to close' }
},
required: ['repo', 'issue_number']
},
async handler(args, ctx) {
return api.closeIssue(ctx.gitea, String(args.repo), Number(args.issue_number));
}
});
registerTool({
name: 'list_repos',
description: 'List all Git repositories in the Gitea organization. Returns repo names, descriptions, and last update time.',
parameters: { type: 'object', properties: {} },
async handler(_args, ctx) {
return api.listRepos(ctx.gitea);
}
});
registerTool({
name: 'list_all_issues',
description: 'List open issues across all repos or a specific repo. Use this to understand what work is queued or in progress.',
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Optional: "owner/name" to scope to one repo. Omit for all repos.' },
state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' }
}
},
async handler(args, ctx) {
return api.listAllIssues(ctx.gitea, {
repo: args.repo ? String(args.repo) : undefined,
state: args.state ? String(args.state) : undefined,
});
}
});
registerTool({
name: 'read_repo_file',
description: 'Read a file from any Gitea repository without cloning it. Useful for understanding project structure.',
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repo in "owner/name" format' },
path: { type: 'string', description: 'File path within the repo (e.g. "src/app/page.tsx")' }
},
required: ['repo', 'path']
},
async handler(args, ctx) {
return api.readRepoFile(ctx.gitea, String(args.repo), String(args.path));
}
});

17
src/tools/index.ts Normal file
View File

@@ -0,0 +1,17 @@
// Import domain files first — side effects register each tool into the registry.
// Order determines ALL_TOOLS array order (informational only).
import './file';
import './shell';
import './git';
import './gitea';
import './coolify';
import './agent';
import './memory';
import './skills';
import './prd';
import './search';
// Re-export the public API — identical surface to the old tools.ts
export { ALL_TOOLS, executeTool, ToolDefinition } from './registry';
export { ToolContext, MemoryUpdate } from './context';
export { PROTECTED_GITEA_REPOS, PROTECTED_COOLIFY_PROJECT, PROTECTED_COOLIFY_APPS, assertGiteaWritable, assertCoolifyDeployable } from './security';

45
src/tools/memory-api.ts Normal file
View File

@@ -0,0 +1,45 @@
// =============================================================================
// Pure memory API. The in-process agent-runner collects memory updates into an
// array on the ToolContext (ctx.memoryUpdates) so the supervisor loop can
// persist them at end-of-turn. MCP clients don't share that array, so the MCP
// server keeps its own module-level store keyed by an optional sessionKey.
// =============================================================================
export interface MemoryEntry {
key: string;
type: string; // "tech_stack" | "decision" | "feature" | "goal" | "constraint" | "note"
value: string;
}
export interface MemoryInput {
key: string;
type: string;
value: string;
}
export function toEntry(input: MemoryInput): MemoryEntry {
return { key: input.key, type: input.type, value: input.value };
}
// -------------------------------------------------------------------
// In-memory store used by the MCP server path (the in-process path
// appends directly to ctx.memoryUpdates and ignores this store).
// -------------------------------------------------------------------
const memoryStore = new Map<string, MemoryEntry[]>();
export function saveMemoryToStore(sessionKey: string, input: MemoryInput): { saved: true; entry: MemoryEntry } {
const entry = toEntry(input);
const list = memoryStore.get(sessionKey) ?? [];
list.push(entry);
memoryStore.set(sessionKey, list);
return { saved: true, entry };
}
export function listMemoryFromStore(sessionKey: string): MemoryEntry[] {
return [...(memoryStore.get(sessionKey) ?? [])];
}
export function clearMemoryStore(sessionKey: string): void {
memoryStore.delete(sessionKey);
}

35
src/tools/memory.ts Normal file
View File

@@ -0,0 +1,35 @@
// =============================================================================
// save_memory tool registration. Logic lives in ./memory-api.ts.
// In-process: appends to ctx.memoryUpdates so the supervisor loop can persist
// at end-of-turn. MCP server path uses memory-api's internal store.
// =============================================================================
import { registerTool } from './registry';
import * as api from './memory-api';
registerTool({
name: 'save_memory',
description: 'Persist an important fact about this project to long-term memory. Use this to save decisions, tech stack choices, feature descriptions, constraints, or goals so they are remembered across conversations.',
parameters: {
type: 'object',
properties: {
key: { type: 'string', description: 'Short unique label (e.g. "primary_language", "auth_strategy", "deploy_target")' },
type: {
type: 'string',
enum: ['tech_stack', 'decision', 'feature', 'goal', 'constraint', 'note'],
description: 'Category of the memory item'
},
value: { type: 'string', description: 'The fact to remember (1-3 sentences)' }
},
required: ['key', 'type', 'value']
},
async handler(args, ctx) {
const entry = api.toEntry({
key: String(args.key),
type: String(args.type),
value: String(args.value),
});
ctx.memoryUpdates.push(entry);
return { saved: true, key: entry.key, type: entry.type };
}
});

20
src/tools/prd-api.ts Normal file
View File

@@ -0,0 +1,20 @@
// =============================================================================
// Pure PRD API. The store is module-level so atlas.ts can inspect it after each
// turn (it imports `prdStore` from prd.ts which re-exports from here). Keep
// this module side-effect-free otherwise.
// =============================================================================
/** sessionKey (workspaceRoot) → PRD markdown */
export const prdStore = new Map<string, string>();
export function finalizePrd(sessionKey: string, content: string): { saved: true; message: string } {
prdStore.set(sessionKey, content);
return {
saved: true,
message: 'PRD saved. Let the user know their product requirements document is ready and the platform will now architect the technical solution.',
};
}
export function getPrd(sessionKey: string): string | null {
return prdStore.get(sessionKey) ?? null;
}

28
src/tools/prd.ts Normal file
View File

@@ -0,0 +1,28 @@
// =============================================================================
// finalize_prd tool registration. Logic + store live in ./prd-api.ts.
// We re-export `prdStore` so existing imports (atlas.ts) continue to work.
// =============================================================================
import { registerTool } from './registry';
import * as api from './prd-api';
export { prdStore } from './prd-api';
registerTool({
name: 'finalize_prd',
description: 'Call this when you have finished writing the complete PRD document. Pass the full PRD markdown as content. This saves the document and signals to the user that discovery is complete.',
parameters: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'The complete PRD document in markdown format'
}
},
required: ['content']
},
async handler(args, ctx) {
// Store against workspaceRoot as a unique key (each project has its own workspace)
return api.finalizePrd(ctx.workspaceRoot, String(args.content));
}
});

34
src/tools/registry.ts Normal file
View File

@@ -0,0 +1,34 @@
import { ToolContext } from './context';
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
/** Implementation — called by executeTool(). Not sent to the LLM. */
handler: (args: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
}
/** Live registry — grows as domain files are imported. */
const _registry = new Map<string, ToolDefinition>();
/**
* Mutable array kept in sync with the registry.
* Used by agents.ts to pick tool subsets by name (backwards-compatible with ALL_TOOLS).
*/
export const ALL_TOOLS: ToolDefinition[] = [];
export function registerTool(tool: ToolDefinition): void {
_registry.set(tool.name, tool);
ALL_TOOLS.push(tool);
}
/** Dispatch a tool call by name — O(1) map lookup, no switch needed. */
export async function executeTool(
name: string,
args: Record<string, unknown>,
ctx: ToolContext
): Promise<unknown> {
const tool = _registry.get(name);
if (!tool) return { error: `Unknown tool: ${name}` };
return tool.handler(args, ctx);
}

55
src/tools/search-api.ts Normal file
View File

@@ -0,0 +1,55 @@
// =============================================================================
// Pure web-search API via DuckDuckGo HTML endpoint. No API key required.
// =============================================================================
export async function webSearch(query: string): Promise<unknown> {
const trimmed = query.trim();
if (!trimmed) return { error: 'No query provided' };
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(trimmed)}`;
try {
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; VIBN-Atlas/1.0)',
Accept: 'text/html',
},
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
return { error: `Search failed with status ${res.status}` };
}
const html = await res.text();
const titles: string[] = [];
for (const m of html.matchAll(/class="result__a"[^>]*href="[^"]*"[^>]*>(.*?)<\/a>/gs)) {
const title = m[1].replace(/<[^>]+>/g, '').trim();
if (title) titles.push(title);
}
const snippets: string[] = [];
for (const m of html.matchAll(/class="result__snippet"[^>]*>(.*?)<\/a>/gs)) {
const snippet = m[1].replace(/<[^>]+>/g, '').trim();
if (snippet) snippets.push(snippet);
}
const count = Math.min(6, Math.max(titles.length, snippets.length));
const results: string[] = [];
for (let i = 0; i < count; i++) {
const title = titles[i] || '';
const snippet = snippets[i] || '';
if (title || snippet) results.push(`**${title}**\n${snippet}`);
}
if (results.length === 0) return { error: 'No results found' };
const text = results.join('\n\n');
const truncated = text.length > 5000 ? text.slice(0, 5000) + '\n\n[...results truncated]' : text;
return { query: trimmed, results: truncated };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { error: `Search request failed: ${message}` };
}
}

24
src/tools/search.ts Normal file
View File

@@ -0,0 +1,24 @@
// =============================================================================
// web_search tool registration. Logic lives in ./search-api.ts.
// =============================================================================
import { registerTool } from './registry';
import * as api from './search-api';
registerTool({
name: 'web_search',
description: 'Search the web for current information. Use this to research competitors, market trends, pricing models, existing solutions, technology choices, or any topic the user mentions that would benefit from real-world context. Returns a summary of top search results.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query. Be specific — e.g. "SaaS project management tools pricing 2024" rather than just "project management".'
}
},
required: ['query']
},
async handler(args) {
return api.webSearch(String(args.query));
}
});

47
src/tools/security.ts Normal file
View File

@@ -0,0 +1,47 @@
// =============================================================================
// SECURITY GUARDRAILS — Protected VIBN Platform Resources
//
// These repos and Coolify resources belong to the Vibn platform itself.
// Agents must never be allowed to push code or trigger deployments here.
// Read-only operations (list, read file, get status) are still permitted
// so agents can observe platform state, but all mutations are blocked.
// =============================================================================
/** Gitea repos agents can NEVER push to, commit to, or write issues on. */
export const PROTECTED_GITEA_REPOS = new Set([
'mark/vibn-frontend',
'mark/vibn-agent-runner',
'mark/vibn-api',
'mark/master-ai',
]);
/** Coolify project UUID for the VIBN platform — agents cannot deploy here. */
export const PROTECTED_COOLIFY_PROJECT = 'f4owwggokksgw0ogo0844os0';
/**
* Specific Coolify app UUIDs that must never be deployed by an agent.
* Belt-and-suspenders check in case the project UUID filter is bypassed.
*/
export const PROTECTED_COOLIFY_APPS = new Set([
'y4cscsc8s08c8808go0448s0', // vibn-frontend
'kggs4ogckc0w8ggwkkk88kck', // vibn-postgres
'o4wwck0g0c04wgoo4g4s0004', // gitea
]);
export function assertGiteaWritable(repo: string): void {
if (PROTECTED_GITEA_REPOS.has(repo)) {
throw new Error(
`SECURITY: Repo "${repo}" is a protected Vibn platform repo. ` +
`Agents cannot push code or modify issues in this repository.`
);
}
}
export function assertCoolifyDeployable(appUuid: string): void {
if (PROTECTED_COOLIFY_APPS.has(appUuid)) {
throw new Error(
`SECURITY: App "${appUuid}" is a protected Vibn platform application. ` +
`Agents cannot trigger deployments for this application.`
);
}
}

35
src/tools/shell-api.ts Normal file
View File

@@ -0,0 +1,35 @@
// =============================================================================
// Pure shell execution API — no ToolContext coupling.
// =============================================================================
import * as cp from 'child_process';
import * as util from 'util';
import { safeResolve } from './utils';
const execAsync = util.promisify(cp.exec);
const BLOCKED_COMMANDS = ['rm -rf /', 'mkfs', ':(){:|:&};:'];
export async function executeCommand(
workspaceRoot: string,
command: string,
workingDirectory?: string
): Promise<unknown> {
if (BLOCKED_COMMANDS.some(b => command.includes(b))) {
return { error: 'Command blocked for safety.' };
}
const cwd = workingDirectory ? safeResolve(workspaceRoot, workingDirectory) : workspaceRoot;
try {
const { stdout, stderr } = await execAsync(command, {
cwd, timeout: 120_000, maxBuffer: 1024 * 1024,
});
return { exitCode: 0, stdout: stdout.trim(), stderr: stderr.trim() };
} catch (err: any) {
return {
exitCode: err.code,
stdout: (err.stdout || '').trim(),
stderr: (err.stderr || '').trim(),
error: err.message,
};
}
}

26
src/tools/shell.ts Normal file
View File

@@ -0,0 +1,26 @@
// =============================================================================
// Shell execution tool registration. Logic lives in ./shell-api.ts.
// =============================================================================
import { registerTool } from './registry';
import * as api from './shell-api';
registerTool({
name: 'execute_command',
description: 'Run a shell command in the workspace and return stdout + stderr. 120s timeout. Use for: npm install, npm test, git status, building, etc.',
parameters: {
type: 'object',
properties: {
command: { type: 'string', description: 'Shell command to run' },
working_directory: { type: 'string', description: 'Optional: relative subdirectory to run in' }
},
required: ['command']
},
async handler(args, ctx) {
return api.executeCommand(
ctx.workspaceRoot,
String(args.command),
args.working_directory ? String(args.working_directory) : undefined
);
}
});

48
src/tools/skills-api.ts Normal file
View File

@@ -0,0 +1,48 @@
// =============================================================================
// Pure skills API. Skills live in a Gitea repo at .skills/<name>/SKILL.md.
// Takes a GiteaReadConfig so it can read from any Gitea instance (in-process
// agent passes ctx.gitea, MCP server loads from env).
// =============================================================================
const SKILL_FILE = 'SKILL.md';
const SKILLS_DIR = '.skills';
export interface GiteaReadConfig {
apiUrl: string;
apiToken: string;
}
async function giteaGetContents(
cfg: GiteaReadConfig,
repo: string,
filePath: string
): Promise<any> {
const res = await fetch(`${cfg.apiUrl}/api/v1/repos/${repo}/contents/${filePath}`, {
headers: { Authorization: `token ${cfg.apiToken}` },
});
if (!res.ok) return null;
return res.json();
}
export async function listSkills(cfg: GiteaReadConfig, repo: string): Promise<unknown> {
const contents = await giteaGetContents(cfg, repo, SKILLS_DIR);
if (!contents || !Array.isArray(contents)) {
return { skills: [], message: `No .skills/ directory found in ${repo}` };
}
const skills = contents
.filter((entry: any) => entry.type === 'dir')
.map((entry: any) => ({ name: entry.name, path: entry.path }));
return { repo, skills };
}
export async function getSkill(cfg: GiteaReadConfig, repo: string, skillName: string): Promise<unknown> {
const filePath = `${SKILLS_DIR}/${skillName}/${SKILL_FILE}`;
const file = await giteaGetContents(cfg, repo, filePath);
if (!file || !file.content) {
return { error: `Skill "${skillName}" not found in ${repo}. Try list_skills to see available skills.` };
}
const content = Buffer.from(file.content, 'base64').toString('utf8');
// Strip YAML frontmatter if present, return just the markdown body
const body = content.replace(/^---[\s\S]*?---\s*/m, '').trim();
return { repo, skill: skillName, content: body };
}

44
src/tools/skills.ts Normal file
View File

@@ -0,0 +1,44 @@
// =============================================================================
// Skills tool registrations. Logic lives in ./skills-api.ts.
// =============================================================================
import { registerTool } from './registry';
import * as api from './skills-api';
registerTool({
name: 'list_skills',
description: `List available skills for a project repo. Skills are stored in .skills/<name>/SKILL.md and provide reusable instructions the agent should follow (e.g. deploy process, test commands, code conventions).`,
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repo in "owner/name" format' }
},
required: ['repo']
},
async handler(args, ctx) {
return api.listSkills(
{ apiUrl: ctx.gitea.apiUrl, apiToken: ctx.gitea.apiToken },
String(args.repo),
);
}
});
registerTool({
name: 'get_skill',
description: `Read the full content of a specific skill from a project repo. Call list_skills first to see what's available. Use this before spawning agents so they have the relevant project-specific instructions.`,
parameters: {
type: 'object',
properties: {
repo: { type: 'string', description: 'Repo in "owner/name" format' },
skill_name: { type: 'string', description: 'Skill name (directory name inside .skills/)' }
},
required: ['repo', 'skill_name']
},
async handler(args, ctx) {
return api.getSkill(
{ apiUrl: ctx.gitea.apiUrl, apiToken: ctx.gitea.apiToken },
String(args.repo),
String(args.skill_name),
);
}
});

13
src/tools/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import * as path from 'path';
/** Directory names to skip when walking or listing workspaces. */
export const EXCLUDED = new Set(['node_modules', '.git', 'dist', 'build', 'lib', '.cache', 'coverage']);
/** Resolve a relative path safely within a workspace root — throws if it tries to escape. */
export function safeResolve(root: string, rel: string): string {
const resolved = path.resolve(root, rel);
if (!resolved.startsWith(path.resolve(root))) {
throw new Error(`Path escapes workspace: ${rel}`);
}
return resolved;
}

48
src/vibn-events-ingest.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* Push structured timeline events to vibn-frontend (Postgres via ingest API).
* Complements PATCH output lines — enables SSE replay without polling every line.
*/
import { randomUUID } from 'crypto';
export interface IngestEventInput {
type: string;
payload?: Record<string, unknown>;
ts?: string;
}
export async function ingestSessionEvents(
vibnApiUrl: string,
projectId: string,
sessionId: string,
events: IngestEventInput[]
): Promise<void> {
if (events.length === 0) return;
const secret = process.env.AGENT_RUNNER_SECRET ?? '';
const base = vibnApiUrl.replace(/\/$/, '');
const url = `${base}/api/projects/${projectId}/agent/sessions/${sessionId}/events`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-agent-runner-secret': secret,
},
body: JSON.stringify({
events: events.map((e) => ({
clientEventId: randomUUID(),
ts: e.ts ?? new Date().toISOString(),
type: e.type,
payload: e.payload ?? {},
})),
}),
});
if (!res.ok) {
const t = await res.text();
console.warn('[ingest-events]', res.status, t.slice(0, 240));
}
} catch (err) {
console.warn('[ingest-events]', err instanceof Error ? err.message : err);
}
}