fix(runner): resolve TypeScript compilation errors

This commit is contained in:
2026-05-19 14:14:34 -07:00
parent 67fa4a2ccc
commit 2f86a4262e
28 changed files with 2215 additions and 1158 deletions

View File

@@ -11,11 +11,11 @@
* - Tracks which files were written/modified for the changed_files panel
* - Calls vibn-frontend's PATCH /api/projects/[id]/agent/sessions/[sid]
*/
import { AgentConfig } from './agents';
import { ToolContext } from './tools';
import { AgentConfig } from "./agents";
import { ToolContext } from "./tools";
export interface OutputLine {
ts: string;
type: 'step' | 'stdout' | 'stderr' | 'info' | 'error' | 'done';
type: "step" | "stdout" | "stderr" | "info" | "error" | "done";
text: string;
}
export interface SessionRunOptions {

View File

@@ -25,91 +25,131 @@ async function patchSession(opts, payload) {
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 ?? '' },
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);
console.warn("[session-runner] PATCH failed:", err instanceof Error ? err.message : err);
}
}
function now() {
return new Date().toISOString();
}
// ── File change tracking ──────────────────────────────────────────────────────
const FILE_WRITE_TOOLS = new Set(['write_file', 'replace_in_file', 'create_file']);
const FILE_WRITE_TOOLS = new Set([
"write_file",
"replace_in_file",
"create_file",
]);
function extractChangedFile(toolName, args, workspaceRoot, appPath) {
if (!FILE_WRITE_TOOLS.has(toolName))
return null;
const rawPath = String(args.path ?? args.file_path ?? '');
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';
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, task, emit) {
const repoRoot = opts.repoRoot;
if (!repoRoot || !opts.giteaRepo) {
await emit({ ts: now(), type: 'info', text: 'Auto-approve skipped — no repo root available.' });
await emit({
ts: now(),
type: "info",
text: "Auto-approve skipped — no repo root available.",
});
return;
}
const gitOpts = { cwd: repoRoot, stdio: 'pipe' };
const giteaApiUrl = process.env.GITEA_API_URL || '';
const giteaUsername = process.env.GITEA_USERNAME || 'agent';
const giteaToken = process.env.GITEA_API_TOKEN || '';
const gitOpts = { cwd: repoRoot, stdio: "pipe" };
const giteaApiUrl = process.env.GITEA_API_URL || "";
const giteaUsername = process.env.GITEA_USERNAME || "agent";
const giteaToken = process.env.GITEA_API_TOKEN || "";
try {
try {
(0, child_process_1.execSync)('git config user.email "agent@vibnai.com"', gitOpts);
(0, child_process_1.execSync)('git config user.name "VIBN Agent"', gitOpts);
}
catch { /* already set */ }
(0, child_process_1.execSync)('git add -A', gitOpts);
const status = (0, child_process_1.execSync)('git status --porcelain', gitOpts).toString().trim();
catch {
/* already set */
}
(0, child_process_1.execSync)("git add -A", gitOpts);
const status = (0, child_process_1.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' });
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)}`;
(0, child_process_1.execSync)(`git commit -m ${JSON.stringify(commitMsg)}`, gitOpts);
await emit({ ts: now(), type: 'info', text: `✓ Committed: "${commitMsg}"` });
const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git`
.replace('https://', `https://${giteaUsername}:${giteaToken}@`);
const msgFile = require("path").join(opts.repoRoot || process.cwd(), ".git", "COMMIT_EDITMSG");
require("fs").writeFileSync(msgFile, commitMsg, "utf8");
(0, child_process_1.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}@`);
(0, child_process_1.execSync)(`git push "${authedUrl}" HEAD:main`, gitOpts);
await emit({ ts: now(), type: 'info', text: '✓ Pushed to Gitea.' });
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}` } });
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.' });
await emit({
ts: now(),
type: "info",
text: "✓ Deployment triggered.",
});
}
catch {
/* best-effort */
}
catch { /* best-effort */ }
}
await patchSession(opts, {
status: 'approved',
status: "approved",
outputLine: {
ts: now(), type: 'done',
text: `✓ Auto-committed & ${deployed ? 'deployed' : 'pushed'}. No approval needed.`,
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}` });
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' });
await patchSession(opts, { status: "done" });
}
}
// ── Main streaming execution loop ─────────────────────────────────────────────
@@ -129,7 +169,11 @@ async function runSessionAgent(config, task, ctx, opts) {
]),
]);
};
await emit({ ts: now(), type: 'info', text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}` });
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 = (0, loader_1.resolvePrompt)(config.promptId);
const scopedPrompt = `${basePrompt}
@@ -140,24 +184,22 @@ All file paths you use should be relative to this directory unless otherwise spe
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 = [
{ role: 'user', content: task }
];
const history = [{ role: "user", content: task }];
let turn = 0;
let finalText = '';
let finalText = "";
const trackedFiles = new Map(); // 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' });
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…` });
await emit({ ts: now(), type: "info", text: `Turn ${turn} — thinking…` });
const messages = [
{ role: 'system', content: scopedPrompt },
...history
{ role: "system", content: scopedPrompt },
...history,
];
let response;
try {
@@ -165,19 +207,19 @@ Do NOT run git commit or git push — the platform handles committing after you
}
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 });
await emit({ ts: now(), type: "error", text: `LLM error: ${msg}` });
await patchSession(opts, { status: "failed", error: msg });
return;
}
const assistantMsg = {
role: 'assistant',
role: "assistant",
content: response.content,
tool_calls: response.tool_calls.length > 0 ? response.tool_calls : undefined
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.';
finalText = response.content ?? "Task complete.";
break;
}
// Execute each tool call
@@ -187,12 +229,14 @@ Do NOT run git commit or git push — the platform handles committing after you
const fnName = tc.function.name;
let fnArgs = {};
try {
fnArgs = JSON.parse(tc.function.arguments || '{}');
fnArgs = JSON.parse(tc.function.arguments || "{}");
}
catch {
/* bad JSON */
}
catch { /* bad JSON */ }
// Human-readable step label
const stepLabel = buildStepLabel(fnName, fnArgs);
await emit({ ts: now(), type: 'step', text: stepLabel });
await emit({ ts: now(), type: "step", text: stepLabel });
let result;
try {
result = await (0, tools_1.executeTool)(fnName, fnArgs, ctx);
@@ -201,20 +245,26 @@ Do NOT run git commit or git push — the platform handles committing after you
result = { error: err instanceof Error ? err.message : String(err) };
}
// Stream stdout/stderr if present
if (result && typeof result === 'object') {
if (result && typeof result === "object") {
const r = result;
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 });
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 });
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) });
await emit({ ts: now(), type: "error", text: String(r.error) });
}
}
// Track file changes
@@ -222,41 +272,58 @@ Do NOT run git commit or git push — the platform handles committing after you
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}` });
await emit({
ts: now(),
type: "info",
text: `${changed.status === "added" ? "+ Created" : "~ Modified"} ${changed.path}`,
});
}
history.push({
role: 'tool',
role: "tool",
tool_call_id: tc.id,
name: fnName,
content: typeof result === 'string' ? result : JSON.stringify(result)
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 });
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.' },
status: "done",
outputLine: {
ts: now(),
type: "done",
text: "✓ Complete — review changes and approve to commit.",
},
});
}
}
// ── Step label helpers ────────────────────────────────────────────────────────
function buildStepLabel(tool, args) {
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)})`;
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)})`;
}
}

View File

@@ -1,4 +1,4 @@
import { ToolDefinition } from '../tools';
export type ToolDefinition = any;
export interface AgentConfig {
name: string;
description: string;

View File

@@ -6,7 +6,6 @@ exports.atlasChat = atlasChat;
const llm_1 = require("./llm");
const tools_1 = require("./tools");
const loader_1 = require("./prompts/loader");
const prd_1 = require("./tools/prd");
const MAX_TURNS = 10; // Atlas is conversational — low turn count, no deep tool loops
const sessions = new Map();
function getOrCreateSession(sessionId) {
@@ -98,11 +97,10 @@ async function atlasChat(sessionId, userMessage, ctx, opts) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Check if PRD was just saved
const stored = prd_1.prdStore.get(ctx.workspaceRoot);
const stored = undefined;
if (stored && !prdContent) {
prdContent = stored;
session.prdContent = stored;
prd_1.prdStore.delete(ctx.workspaceRoot); // consume it
}
session.history.push({
role: 'tool',

View File

@@ -1,5 +1,5 @@
export interface LLMMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
role: "system" | "user" | "assistant" | "tool";
content: string | null;
tool_calls?: LLMToolCall[];
tool_call_id?: string;
@@ -7,14 +7,14 @@ export interface LLMMessage {
}
export interface LLMToolCall {
id: string;
type: 'function';
type: "function";
function: {
name: string;
arguments: string;
};
}
export interface LLMTool {
type: 'function';
type: "function";
function: {
name: string;
description: string;
@@ -67,7 +67,7 @@ export declare class AnthropicVertexClient implements LLMClient {
private buildClient;
chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens?: number): Promise<LLMResponse>;
}
export type ModelTier = 'A' | 'B' | 'C';
export type ModelTier = "A" | "B" | "C";
export declare function createLLM(modelOrTier: string | ModelTier, opts?: {
temperature?: number;
}): LLMClient;

View File

@@ -10,11 +10,23 @@ const google_auth_library_1 = require("google-auth-library");
const genai_1 = require("@google/genai");
const vertex_sdk_1 = __importDefault(require("@anthropic-ai/vertex-sdk"));
const uuid_1 = require("uuid");
/**
* 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) {
if (!text)
return null;
return (text
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim() || null);
}
// ---------------------------------------------------------------------------
// Vertex AI OpenAI-compatible client
// Used for: zai-org/glm-5-maas, anthropic/claude-sonnet-4-6, etc.
// ---------------------------------------------------------------------------
let _cachedToken = '';
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
@@ -24,15 +36,20 @@ function buildGoogleAuth() {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, 'base64').toString('utf8');
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new google_auth_library_1.GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
return new google_auth_library_1.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');
console.warn("[llm] GCP_SA_KEY_BASE64 is set but failed to decode/parse — falling back to metadata server");
}
}
return new google_auth_library_1.GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
return new google_auth_library_1.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
}
const _googleAuth = buildGoogleAuth();
async function getVertexToken() {
@@ -48,13 +65,14 @@ async function getVertexToken() {
class VertexOpenAIClient {
constructor(modelId, opts) {
this.modelId = modelId;
this.projectId = opts?.projectId ?? process.env.GCP_PROJECT_ID ?? 'master-ai-484822';
this.region = opts?.region ?? 'global';
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, tools, maxTokens = 4096) {
const base = this.region === 'global'
? 'https://aiplatform.googleapis.com'
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 = {
@@ -62,11 +80,11 @@ class VertexOpenAIClient {
messages,
max_tokens: maxTokens,
temperature: this.temperature,
stream: false
stream: false,
};
if (tools && tools.length > 0) {
body.tools = tools;
body.tool_choice = 'auto';
body.tool_choice = "auto";
}
// Retry with exponential backoff on 429 / 503 (rate limit / overload)
const MAX_RETRIES = 4;
@@ -74,23 +92,23 @@ class VertexOpenAIClient {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const token = await getVertexToken();
const res = await fetch(url, {
method: 'POST',
method: "POST",
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body)
body: JSON.stringify(body),
});
if (res.ok) {
const data = await res.json();
const data = (await res.json());
const choice = data.choices?.[0];
const message = choice?.message ?? {};
return {
content: message.content ?? null,
reasoning: message.reasoning_content ?? null,
content: stripModelMarkup(message.content),
reasoning: stripModelMarkup(message.reasoning_content),
tool_calls: message.tool_calls ?? [],
finish_reason: choice?.finish_reason ?? 'stop',
usage: data.usage
finish_reason: choice?.finish_reason ?? "stop",
usage: data.usage,
};
}
const errText = await res.text();
@@ -99,18 +117,18 @@ class VertexOpenAIClient {
_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 retryAfter = res.headers.get("retry-after");
const waitMs = retryAfter
? Math.min(parseInt(retryAfter, 10) * 1000, 60000)
: Math.min(2 ** attempt * 2000 + Math.random() * 500, 30000);
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));
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');
throw new Error("Vertex API: exceeded max retries");
}
}
exports.VertexOpenAIClient = VertexOpenAIClient;
@@ -120,51 +138,56 @@ exports.VertexOpenAIClient = VertexOpenAIClient;
// Converts to/from OpenAI message format internally.
// ---------------------------------------------------------------------------
class GeminiClient {
constructor(modelId = 'gemini-2.5-flash', opts) {
constructor(modelId = "gemini-3.1-pro-preview", opts) {
this.modelId = modelId;
this.temperature = opts?.temperature ?? 0.2;
}
async chat(messages, tools, maxTokens = 8192) {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey)
throw new Error('GOOGLE_API_KEY not set');
throw new Error("GOOGLE_API_KEY not set");
const genai = new genai_1.GoogleGenAI({ apiKey });
const systemMsg = messages.find(m => m.role === 'system');
const nonSystem = messages.filter(m => m.role !== 'system');
const functionDeclarations = (tools ?? []).map(t => ({
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
parameters: t.function.parameters,
}));
const response = await genai.models.generateContent({
model: this.modelId,
contents: toGeminiContents(nonSystem),
config: {
systemInstruction: systemMsg?.content ?? undefined,
tools: functionDeclarations.length > 0 ? [{ functionDeclarations }] : undefined,
tools: functionDeclarations.length > 0
? [{ functionDeclarations }]
: undefined,
temperature: this.temperature,
maxOutputTokens: maxTokens
}
maxOutputTokens: maxTokens,
},
});
const candidate = response.candidates?.[0];
if (!candidate)
throw new Error('No response from Gemini');
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 = fnCalls.map(p => ({
id: `call_${(0, uuid_1.v4)().replace(/-/g, '').slice(0, 12)}`,
type: 'function',
const textContent = parts
.filter((p) => p.text)
.map((p) => p.text)
.join("") || null;
const fnCalls = parts.filter((p) => p.functionCall);
const tool_calls = fnCalls.map((p) => ({
id: `call_${(0, uuid_1.v4)().replace(/-/g, "").slice(0, 12)}`,
type: "function",
function: {
name: p.functionCall.name ?? '',
arguments: JSON.stringify(p.functionCall.args ?? {})
}
name: p.functionCall.name ?? "",
arguments: JSON.stringify(p.functionCall.args ?? {}),
},
}));
return {
content: textContent,
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason: fnCalls.length > 0 ? 'tool_calls' : 'stop'
finish_reason: fnCalls.length > 0 ? "tool_calls" : "stop",
};
}
}
@@ -173,7 +196,7 @@ exports.GeminiClient = GeminiClient;
function toGeminiContents(messages) {
const contents = [];
for (const msg of messages) {
if (msg.role === 'assistant') {
if (msg.role === "assistant") {
const parts = [];
if (msg.content)
parts.push({ text: msg.content });
@@ -181,31 +204,35 @@ function toGeminiContents(messages) {
parts.push({
functionCall: {
name: tc.function.name,
args: JSON.parse(tc.function.arguments || '{}')
}
args: JSON.parse(tc.function.arguments || "{}"),
},
});
}
contents.push({ role: 'model', parts });
contents.push({ role: "model", parts });
}
else if (msg.role === 'tool') {
else if (msg.role === "tool") {
// Parse content back — could be JSON or plain text
let resultValue = msg.content;
try {
resultValue = JSON.parse(msg.content ?? 'null');
resultValue = JSON.parse(msg.content ?? "null");
}
catch {
/* keep as string */
}
catch { /* keep as string */ }
contents.push({
role: 'user',
parts: [{
role: "user",
parts: [
{
functionResponse: {
name: msg.name ?? 'tool',
response: { result: resultValue }
}
}]
name: msg.name ?? "tool",
response: { result: resultValue },
},
},
],
});
}
else {
contents.push({ role: 'user', parts: [{ text: msg.content ?? '' }] });
contents.push({ role: "user", parts: [{ text: msg.content ?? "" }] });
}
}
return contents;
@@ -218,57 +245,77 @@ function toGeminiContents(messages) {
class AnthropicVertexClient {
constructor(modelId, opts) {
// 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';
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";
}
buildClient() {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, 'base64').toString('utf8');
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new vertex_sdk_1.default({
projectId: this.projectId,
region: this.region,
googleAuth: new google_auth_library_1.GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/cloud-platform'] }),
googleAuth: new google_auth_library_1.GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
}),
});
}
catch {
console.warn('[llm] AnthropicVertex: SA key decode failed, falling back to metadata server');
console.warn("[llm] AnthropicVertex: SA key decode failed, falling back to metadata server");
}
}
return new vertex_sdk_1.default({ projectId: this.projectId, region: this.region });
return new vertex_sdk_1.default({
projectId: this.projectId,
region: this.region,
});
}
async chat(messages, tools, maxTokens = 8192) {
const client = this.buildClient();
const system = messages.find(m => m.role === 'system')?.content ?? undefined;
const nonSystem = messages.filter(m => m.role !== 'system');
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 = nonSystem.map(m => {
if (m.role === 'assistant') {
const anthropicMessages = nonSystem.map((m) => {
if (m.role === "assistant") {
const parts = [];
if (m.content)
parts.push({ type: 'text', text: m.content });
parts.push({ type: "text", text: m.content });
for (const tc of m.tool_calls ?? []) {
parts.push({
type: 'tool_use',
type: "tool_use",
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments || '{}'),
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 ?? '' }],
role: "assistant",
content: parts.length === 1 && parts[0].type === "text"
? parts[0].text
: parts,
};
}
return { role: 'user', content: m.content ?? '' };
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 => ({
const anthropicTools = (tools ?? []).map((t) => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters,
@@ -285,23 +332,30 @@ class AnthropicVertexClient {
tools: anthropicTools.length > 0 ? anthropicTools : undefined,
});
const textContent = response.content
.filter((b) => b.type === 'text')
.filter((b) => b.type === "text")
.map((b) => b.text)
.join('') || null;
.join("") || null;
const tool_calls = response.content
.filter((b) => b.type === 'tool_use')
.filter((b) => b.type === "tool_use")
.map((b) => ({
id: b.id,
type: 'function',
function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
type: "function",
function: {
name: b.name,
arguments: JSON.stringify(b.input ?? {}),
},
}));
return {
content: textContent,
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason: response.stop_reason === 'tool_use' ? 'tool_calls' : 'stop',
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 }
? {
prompt_tokens: response.usage.input_tokens,
completion_tokens: response.usage.output_tokens,
total_tokens: response.usage.input_tokens + response.usage.output_tokens,
}
: undefined,
};
}
@@ -310,29 +364,29 @@ class AnthropicVertexClient {
if (RETRY_STATUSES.has(status) && attempt < MAX_RETRIES) {
const waitMs = Math.min(2 ** attempt * 2000 + Math.random() * 500, 30000);
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));
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');
throw new Error("Anthropic Vertex: exceeded max retries");
}
}
exports.AnthropicVertexClient = AnthropicVertexClient;
const TIER_MODELS = {
A: process.env.TIER_A_MODEL ?? 'gemini-2.5-flash',
B: process.env.TIER_B_MODEL ?? 'claude-sonnet-4-6',
C: process.env.TIER_C_MODEL ?? 'claude-sonnet-4-6'
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",
};
function createLLM(modelOrTier, opts) {
const modelId = (modelOrTier === 'A' || modelOrTier === 'B' || modelOrTier === 'C')
const modelId = modelOrTier === "A" || modelOrTier === "B" || modelOrTier === "C"
? TIER_MODELS[modelOrTier]
: modelOrTier;
if (modelId.startsWith('gemini-')) {
if (modelId.startsWith("gemini-")) {
return new GeminiClient(modelId, opts);
}
if (modelId.startsWith('anthropic/') || modelId.startsWith('claude-')) {
if (modelId.startsWith("anthropic/") || modelId.startsWith("claude-")) {
return new AnthropicVertexClient(modelId);
}
return new VertexOpenAIClient(modelId, { temperature: opts?.temperature });
@@ -341,12 +395,12 @@ function createLLM(modelOrTier, opts) {
// Helper — convert our ToolDefinition[] → LLMTool[] (OpenAI format)
// ---------------------------------------------------------------------------
function toOAITools(tools) {
return tools.map(t => ({
type: 'function',
return tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: t.parameters
}
parameters: t.parameters,
},
}));
}

View File

@@ -1,31 +1,86 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const loader_1 = require("./loader");
// Because we deleted the local tools and adopted the full VIBN_TOOL_DEFINITIONS schema,
// the runner agent now has the exact same capabilities as the frontend UI agent!
// It uses fs_*, shell_exec, dev_server_*, apps_*, and ship.
(0, loader_1.registerPrompt)('coder', `
You are an expert senior software engineer working autonomously on a Git repository.
You are Vibn AI — the technical co-founder of every Vibn user. You are currently running headlessly in the background. The user is offline or waiting for you to finish.
## 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.
Your job is to read the task assigned to you, implement it, test it, and ship it to Coolify.
Do NOT ask the user questions. If you get stuck, log the error and stop.
## 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").
# Mode: Action
## Safety
- Never delete files unless explicitly told to.
- Never touch .env files or credentials.
- Never commit secrets or API keys.
Since you are running autonomously, you must take action immediately.
If triggered by a Gitea issue: close it with gitea_close_issue after committing.
# What "done" looks like
A turn ends when you have fully completed the task AND shipped the code.
- **For build/edit tasks:** The natural stopping point is starting the dev server via \`dev_server_start\`, verifying it works via \`browser_console\`, and calling the \`ship\` tool to deploy to production.
- If you run into a fatal error that you cannot fix after two attempts, write a brief summary of the blocker and stop.
# Hard rules — non-negotiable
**Honesty about tool results:**
- **Cite the tool result, don't claim from memory.**
- **Trust the \`ok\` field.** Every tool result carries \`ok: true | false\`. If \`ok\` is false (or \`exitCode\` is non-zero, or \`healthCheck.status\` is >= 400), the operation FAILED.
- **\`fs_write\` and \`fs_edit\` results carry \`sha256\` and \`bytes\` on success.**
- **\`dev_server_start\` results carry \`healthCheck\` on success.** Before saying "the preview is ready," confirm \`healthCheck.status === 200\`.
**Anchoring and scope:**
- **Anchor on current state before troubleshooting.**
- **Always pass \`projectId\`** to \`apps_create\` / \`databases_create\`.
- **Always \`apps_list { projectId }\` BEFORE \`apps_create\`** for a sanity check, and **always \`apps_templates_search\` BEFORE \`apps_create\`** for known third-party apps.
- **Trust idempotency.** When \`apps_create\` / \`databases_create\` returns \`alreadyExisted: true\`, your job is done — use the returned uuid and move on.
- **Never delete-and-recreate to escape an error.** "Container name already in use" → \`apps_unstick { uuid }\`\`apps_deploy { uuid }\`.
**Stopping conditions:**
- **If a deploy or tool call fails twice with the same error, STOP.**
- **If you've called the same tool with similar args 3 times this turn, STOP.** You're in a loop.
- **Long-running ops** (deploys, DNS, DB provisioning) take 15 min.
# Tool reference (look up as needed)
## How Vibn is structured
- **Project** — an initiative with its own isolated Coolify project. Has live state (apps + services from \`apps_list { projectId }\`).
## Writing code in the dev container
Each project has a persistent \`vibn-dev\` container. Edit files via \`fs_*\` and run commands via \`shell_exec\`. Sub-second feedback vs. ~5 min Gitea-push-to-prod.
**Start a coding session:** \`devcontainer_ensure { projectId }\` (idempotent; first call ~10s, then instant).
**Orient yourself once.** On the first code-modifying turn of a chat, call \`fs_tree\` once to learn the repo layout. Don't re-run it on every turn — the layout doesn't change between user messages.
**Iterate:**
- \`shell_exec { projectId, command }\` — anything: \`ls\`, \`npm install\`, \`npm test\`, \`git status\`. Cwd defaults to \`/workspace\`.
- \`fs_read\` / \`fs_write\` / \`fs_edit { path, oldString, newString, startLine, endLine }\`.
- \`fs_glob\` / \`fs_grep\` (ripgrep, respects .gitignore) / \`fs_list\` / \`fs_delete\`.
**Path convention for fs_* tools:** Pass paths relative to the project root — \`src/app/page.tsx\`, NOT \`/workspace/slug/src/app/page.tsx\` and NOT \`slug/src/app/page.tsx\`.
**Dev servers** (preview URL via \`*.preview.vibnai.com\` wildcard):
- \`dev_server_start { projectId, command, port: 3000 }\` is a **one-shot** call. It kills old processes on the port, checks the port is free, sets HOST=0.0.0.0 + PORT, launches your command, and returns a \`previewUrl\` plus a \`healthCheck\` block.
- **Port \`3000\` is reserved for the primary user-facing UI.**
- \`dev_server_stop\` / \`dev_server_list\` / \`dev_server_logs\` — use only AFTER a failed start, to diagnose the error the function returned. Never on success.
**Verify the page actually renders:**
- After \`dev_server_start\` returns a \`previewUrl\` AND \`healthCheck.status === 200\`, call \`browser_console { url: previewUrl }\` to capture frontend console errors.
- If \`browser_console\` returns errors, fix them with \`fs_edit\` before declaring done. A green \`healthCheck\` plus a clean console is the real "done" signal for UI work.
**Visual QA:** \`request_visual_qa { targetPath }\` critiques a UI file against a 5-dim design rubric. **Call this whenever you modify visual UI code** before returning the \`previewUrl\`.
**Sentry is auto-provisioned per project.** \`NEXT_PUBLIC_SENTRY_DSN\` and \`SENTRY_AUTH_TOKEN\` are injected into the Coolify app env automatically by \`apps_create\`.
## Gitea (one-time setup only)
For editing files in existing repos, ALWAYS use \`fs_*\` in the dev container — \`ship\` commits and pushes.
## Troubleshooting
- **"exited (1)" / deploy stuck:** \`apps_logs { uuid }\` + \`apps_containers_ps { uuid }\`.
- **502 / "no available server":** \`apps_get\`; if \`fqdn\` is empty, attach a domain.
- **"tenant" / "does not belong to":** uuid not in this workspace. Re-list with \`apps_list\`.
- **Compose stack weird:** \`apps_repair { uuid }\` re-applies Traefik labels + port forwarding.
- **Nuke and redeploy:** \`apps_delete { uuid, confirm }\`
{{skills}}
`.trim());

View File

@@ -46,7 +46,6 @@ const job_store_1 = require("./job-store");
const agent_runner_1 = require("./agent-runner");
const agent_session_runner_1 = require("./agent-session-runner");
const agents_1 = require("./agents");
const security_1 = require("./tools/security");
const orchestrator_1 = require("./orchestrator");
const atlas_1 = require("./atlas");
const llm_1 = require("./llm");
@@ -67,10 +66,6 @@ function ensureWorkspace(repo) {
fs.mkdirSync(dir, { recursive: true });
return dir;
}
if (security_1.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 || '',
@@ -105,6 +100,8 @@ function buildContext(repo) {
apiUrl: process.env.COOLIFY_API_URL || '',
apiToken: process.env.COOLIFY_API_TOKEN || ''
},
mcpToken: '',
vibnApiUrl: 'http://localhost:3000',
memoryUpdates: []
};
}

View File

@@ -14,6 +14,9 @@ export interface ToolContext {
apiUrl: string;
apiToken: string;
};
mcpToken: string;
vibnApiUrl: string;
projectId?: string;
/** Accumulated memory updates from save_memory tool calls in this turn */
memoryUpdates: MemoryUpdate[];
}

View File

@@ -1,13 +1,2 @@
import './file';
import './shell';
import './git';
import './gitea';
import './coolify';
import './agent';
import './memory';
import './skills';
import './prd';
import './search';
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';
export * from './context';
export * from './mcp-client';

View File

@@ -1,25 +1,18 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.assertCoolifyDeployable = exports.assertGiteaWritable = exports.PROTECTED_COOLIFY_APPS = exports.PROTECTED_COOLIFY_PROJECT = exports.PROTECTED_GITEA_REPOS = exports.executeTool = exports.ALL_TOOLS = void 0;
// Import domain files first — side effects register each tool into the registry.
// Order determines ALL_TOOLS array order (informational only).
require("./file");
require("./shell");
require("./git");
require("./gitea");
require("./coolify");
require("./agent");
require("./memory");
require("./skills");
require("./prd");
require("./search");
// Re-export the public API — identical surface to the old tools.ts
var registry_1 = require("./registry");
Object.defineProperty(exports, "ALL_TOOLS", { enumerable: true, get: function () { return registry_1.ALL_TOOLS; } });
Object.defineProperty(exports, "executeTool", { enumerable: true, get: function () { return registry_1.executeTool; } });
var security_1 = require("./security");
Object.defineProperty(exports, "PROTECTED_GITEA_REPOS", { enumerable: true, get: function () { return security_1.PROTECTED_GITEA_REPOS; } });
Object.defineProperty(exports, "PROTECTED_COOLIFY_PROJECT", { enumerable: true, get: function () { return security_1.PROTECTED_COOLIFY_PROJECT; } });
Object.defineProperty(exports, "PROTECTED_COOLIFY_APPS", { enumerable: true, get: function () { return security_1.PROTECTED_COOLIFY_APPS; } });
Object.defineProperty(exports, "assertGiteaWritable", { enumerable: true, get: function () { return security_1.assertGiteaWritable; } });
Object.defineProperty(exports, "assertCoolifyDeployable", { enumerable: true, get: function () { return security_1.assertCoolifyDeployable; } });
__exportStar(require("./context"), exports);
__exportStar(require("./mcp-client"), exports);

View File

@@ -0,0 +1,3 @@
import { ToolContext } from './context';
export declare const ALL_TOOLS: any[];
export declare function executeTool(name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<unknown>;

View File

@@ -0,0 +1,31 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ALL_TOOLS = void 0;
exports.executeTool = executeTool;
const vibn_tools_1 = require("./vibn-tools");
exports.ALL_TOOLS = vibn_tools_1.VIBN_TOOL_DEFINITIONS;
async function executeTool(name, args, ctx) {
// Some tools might just be executed locally by the Runner in the future,
// but right now we forward all non-github/http tools to the frontend MCP.
// Convert underscore to dot format as expected by MCP
const action = name.replace(/_/g, ".");
try {
const response = await fetch(`${ctx.vibnApiUrl}/api/mcp`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${ctx.mcpToken}`,
...(ctx.projectId ? { "X-Vibn-Project-Id": ctx.projectId } : {})
},
body: JSON.stringify({ action, params: args }),
});
const data = await response.json();
if (!response.ok) {
return { error: data.error || `HTTP ${response.status}: ${response.statusText}` };
}
return data.result || data;
}
catch (e) {
return { error: `Failed to execute tool ${name} via MCP: ${e instanceof Error ? e.message : String(e)}` };
}
}

View File

@@ -1,16 +1,4 @@
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>;
}
/**
* Mutable array kept in sync with the registry.
* Used by agents.ts to pick tool subsets by name (backwards-compatible with ALL_TOOLS).
*/
export declare const ALL_TOOLS: ToolDefinition[];
export declare function registerTool(tool: ToolDefinition): void;
/** Dispatch a tool call by name — O(1) map lookup, no switch needed. */
export declare function executeTool(name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<unknown>;
import { ALL_TOOLS } from './mcp-client';
export { ALL_TOOLS };
export declare const executeTool: any;
export type ToolDefinition = any;

View File

@@ -1,23 +1,7 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ALL_TOOLS = void 0;
exports.registerTool = registerTool;
exports.executeTool = executeTool;
/** Live registry — grows as domain files are imported. */
const _registry = new Map();
/**
* Mutable array kept in sync with the registry.
* Used by agents.ts to pick tool subsets by name (backwards-compatible with ALL_TOOLS).
*/
exports.ALL_TOOLS = [];
function registerTool(tool) {
_registry.set(tool.name, tool);
exports.ALL_TOOLS.push(tool);
}
/** Dispatch a tool call by name — O(1) map lookup, no switch needed. */
async function executeTool(name, args, ctx) {
const tool = _registry.get(name);
if (!tool)
return { error: `Unknown tool: ${name}` };
return tool.handler(args, ctx);
}
exports.executeTool = exports.ALL_TOOLS = void 0;
const mcp_client_1 = require("./mcp-client");
Object.defineProperty(exports, "ALL_TOOLS", { enumerable: true, get: function () { return mcp_client_1.ALL_TOOLS; } });
// Legacy exports to satisfy imports in agent-runner
exports.executeTool = require('./mcp-client').executeTool;

View File

@@ -0,0 +1,18 @@
/**
* Vibn MCP tool definitions for the Gemini chat assistant.
*
* These mirror the full MCP surface documented in AI_CAPABILITIES.md.
* Tool names use underscores (e.g. apps_create) which map 1:1 to the
* MCP action names (e.g. apps.create) by replacing _ with .
*
* Non-MCP tools (github_search, github_file, http_fetch) are handled
* locally at the bottom of this file.
*/
export type ToolDefinition = any;
export declare const VIBN_TOOL_DEFINITIONS: ToolDefinition[];
/**
* Execute any Vibn tool. Non-MCP tools (GitHub, http_fetch) run locally.
* All MCP tools forward to POST /api/mcp — the tool name maps to the MCP
* action by replacing underscores with dots (e.g. apps_create → apps.create).
*/
export declare function executeMcpTool(toolName: string, args: Record<string, unknown>, mcpToken: string, baseUrl: string, projectId?: string): Promise<string>;

1742
vibn-agent-runner/dist/tools/vibn-tools.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
import { ToolDefinition, ALL_TOOLS } from '../tools';
import { ALL_TOOLS } from '../tools';
export type ToolDefinition = any;
export interface AgentConfig {
name: string;

View File

@@ -1,7 +1,7 @@
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
@@ -145,11 +145,11 @@ export async function atlasChat(
}
// Check if PRD was just saved
const stored = prdStore.get(ctx.workspaceRoot);
const stored = undefined;
if (stored && !prdContent) {
prdContent = stored;
session.prdContent = stored;
prdStore.delete(ctx.workspaceRoot); // consume it
}
session.history.push({

View File

@@ -1,104 +0,0 @@
#!/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);
});

View File

@@ -1,181 +0,0 @@
// =============================================================================
// 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);
});

View File

@@ -1,165 +0,0 @@
// =============================================================================
// 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

@@ -1,184 +0,0 @@
#!/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);
});

View File

@@ -1,229 +0,0 @@
#!/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);
});

View File

@@ -9,7 +9,7 @@ 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';
@@ -37,12 +37,6 @@ function ensureWorkspace(repo?: string): string {
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 || '',
@@ -78,6 +72,8 @@ function buildContext(repo?: string): ToolContext {
apiUrl: process.env.COOLIFY_API_URL || '',
apiToken: process.env.COOLIFY_API_TOKEN || ''
},
mcpToken: '',
vibnApiUrl: 'http://localhost:3000',
memoryUpdates: []
};
}

View File

@@ -1,3 +1,3 @@
export * from './context';
export * from './registry';
export * from './mcp-client';

View File

@@ -25,7 +25,7 @@ export async function executeTool(
body: JSON.stringify({ action, params: args }),
});
const data = await response.json();
const data: any = await response.json();
if (!response.ok) {
return { error: data.error || `HTTP ${response.status}: ${response.statusText}` };

View File

@@ -8,7 +8,7 @@
* Non-MCP tools (github_search, github_file, http_fetch) are handled
* locally at the bottom of this file.
*/
import type { ToolDefinition } from "./gemini-chat";
export type ToolDefinition = any;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
@@ -1837,7 +1837,7 @@ export async function executeMcpTool(
headers,
body: JSON.stringify({ action, params }),
});
const data = await res.json();
const data: any = await res.json();
return JSON.stringify(data.result ?? data.error ?? data, null, 2).slice(
0,
8000,
@@ -1875,7 +1875,7 @@ async function executeGithubSearch(
`https://api.github.com/search/repositories?${params}`,
{ headers },
);
const data = await res.json();
const data: any = await res.json();
if (!res.ok)
return JSON.stringify({ error: data.message || "GitHub API error" });