feat: persistent conversation + save_memory tool
- ToolContext gets memoryUpdates[] — accumulated by save_memory calls - orchestratorChat accepts preloadedHistory and knowledgeContext opts - History trimmed to last 40 messages per turn (cost control) - Knowledge items injected into system prompt as ## Project Memory - ChatResult returns history[] and memoryUpdates[] for frontend persistence - server.ts accepts history/knowledge_context from POST body - save_memory tool: lets AI persist facts (key, type, value) to long-term memory Made-with: Cursor
This commit is contained in:
161
src/tools.ts
161
src/tools.ts
@@ -6,10 +6,65 @@ import { Minimatch } from 'minimatch';
|
||||
|
||||
const execAsync = util.promisify(cp.exec);
|
||||
|
||||
// =============================================================================
|
||||
// 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 the platform state, but all mutations are blocked.
|
||||
// =============================================================================
|
||||
|
||||
/** Gitea repos that agents can NEVER push to, commit to, or write issues on. */
|
||||
const PROTECTED_GITEA_REPOS = new Set([
|
||||
'mark/vibn-frontend',
|
||||
'mark/theia-code-os',
|
||||
'mark/vibn-agent-runner',
|
||||
'mark/vibn-api',
|
||||
'mark/master-ai',
|
||||
]);
|
||||
|
||||
/** Coolify project UUID for the VIBN platform — agents cannot deploy here. */
|
||||
const PROTECTED_COOLIFY_PROJECT = 'f4owwggokksgw0ogo0844os0';
|
||||
|
||||
/**
|
||||
* Specific Coolify app UUIDs that must never be deployed by an agent.
|
||||
* This is a belt-and-suspenders check in case the project UUID filter is bypassed.
|
||||
*/
|
||||
const PROTECTED_COOLIFY_APPS = new Set([
|
||||
'y4cscsc8s08c8808go0448s0', // vibn-frontend
|
||||
'kggs4ogckc0w8ggwkkk88kck', // vibn-postgres
|
||||
'o4wwck0g0c04wgoo4g4s0004', // gitea
|
||||
]);
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context passed to every tool call — workspace root + credentials
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MemoryUpdate {
|
||||
key: string;
|
||||
type: string; // e.g. "tech_stack" | "decision" | "feature" | "goal" | "constraint" | "note"
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
workspaceRoot: string;
|
||||
gitea: {
|
||||
@@ -21,6 +76,8 @@ export interface ToolContext {
|
||||
apiUrl: string;
|
||||
apiToken: string;
|
||||
};
|
||||
/** Accumulated memory updates from save_memory tool calls in this turn */
|
||||
memoryUpdates: MemoryUpdate[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -289,6 +346,23 @@ export const ALL_TOOLS: ToolDefinition[] = [
|
||||
},
|
||||
required: ['app_name']
|
||||
}
|
||||
},
|
||||
{
|
||||
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']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -447,6 +521,19 @@ async function gitCommitAndPush(message: string, ctx: ToolContext): Promise<unkn
|
||||
const { apiUrl, apiToken, username } = ctx.gitea;
|
||||
|
||||
try {
|
||||
// Check the remote URL before committing — block pushes to protected repos
|
||||
let remoteCheck = '';
|
||||
try { remoteCheck = (await execAsync('git remote get-url origin', { cwd })).stdout.trim(); } catch { /* ok */ }
|
||||
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.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await execAsync('git add -A', { cwd });
|
||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd });
|
||||
|
||||
@@ -493,7 +580,10 @@ async function coolifyFetch(path: string, ctx: ToolContext, method = 'GET', body
|
||||
}
|
||||
|
||||
async function coolifyListProjects(ctx: ToolContext): Promise<unknown> {
|
||||
return coolifyFetch('/projects', ctx);
|
||||
const projects = await coolifyFetch('/projects', ctx) as any[];
|
||||
if (!Array.isArray(projects)) return projects;
|
||||
// Filter out the protected VIBN project entirely — agents don't need to see it
|
||||
return projects.filter((p: any) => p.uuid !== PROTECTED_COOLIFY_PROJECT);
|
||||
}
|
||||
|
||||
async function coolifyListApplications(projectUuid: string, ctx: ToolContext): Promise<unknown> {
|
||||
@@ -503,6 +593,15 @@ async function coolifyListApplications(projectUuid: string, ctx: ToolContext): P
|
||||
}
|
||||
|
||||
async function coolifyDeploy(appUuid: string, ctx: ToolContext): Promise<unknown> {
|
||||
assertCoolifyDeployable(appUuid);
|
||||
// Also check the app belongs to the right project
|
||||
const apps = await coolifyFetch('/applications', ctx) as any[];
|
||||
if (Array.isArray(apps)) {
|
||||
const app = apps.find((a: any) => a.uuid === appUuid);
|
||||
if (app?.project_uuid === PROTECTED_COOLIFY_PROJECT) {
|
||||
return { error: `SECURITY: App "${appUuid}" belongs to the protected Vibn project. Agents cannot deploy platform apps.` };
|
||||
}
|
||||
}
|
||||
return coolifyFetch(`/applications/${appUuid}/deploy`, ctx, 'POST');
|
||||
}
|
||||
|
||||
@@ -529,6 +628,7 @@ async function giteaFetch(path: string, ctx: ToolContext, method = 'GET', body?:
|
||||
}
|
||||
|
||||
async function giteaCreateIssue(repo: string, title: string, body: string, labels: string[] | undefined, ctx: ToolContext): Promise<unknown> {
|
||||
assertGiteaWritable(repo);
|
||||
return giteaFetch(`/repos/${repo}/issues`, ctx, 'POST', { title, body, labels });
|
||||
}
|
||||
|
||||
@@ -537,6 +637,7 @@ async function giteaListIssues(repo: string, state: string, ctx: ToolContext): P
|
||||
}
|
||||
|
||||
async function giteaCloseIssue(repo: string, issueNumber: number, ctx: ToolContext): Promise<unknown> {
|
||||
assertGiteaWritable(repo);
|
||||
return giteaFetch(`/repos/${repo}/issues/${issueNumber}`, ctx, 'PATCH', { state: 'closed' });
|
||||
}
|
||||
|
||||
@@ -569,21 +670,27 @@ async function listRepos(ctx: ToolContext): Promise<unknown> {
|
||||
headers: { 'Authorization': `token ${ctx.gitea.apiToken}` }
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
return (data.data || []).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
|
||||
}));
|
||||
return (data.data || [])
|
||||
// Hide protected platform repos from agent's view entirely
|
||||
.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
|
||||
}));
|
||||
}
|
||||
|
||||
async function listAllIssues(repo: string | undefined, state: string, ctx: ToolContext): Promise<unknown> {
|
||||
if (repo) {
|
||||
if (PROTECTED_GITEA_REPOS.has(repo)) {
|
||||
return { error: `SECURITY: "${repo}" is a protected Vibn platform repo. Agents cannot access its issues.` };
|
||||
}
|
||||
return giteaFetch(`/repos/${repo}/issues?state=${state}&limit=20`, ctx);
|
||||
}
|
||||
// Fetch across all repos
|
||||
// Fetch across all non-protected repos
|
||||
const repos = await listRepos(ctx) as any[];
|
||||
const allIssues: unknown[] = [];
|
||||
for (const r of repos.slice(0, 10)) {
|
||||
@@ -605,14 +712,17 @@ async function listAllIssues(repo: string | undefined, state: string, ctx: ToolC
|
||||
async function listAllApps(ctx: ToolContext): Promise<unknown> {
|
||||
const apps = await coolifyFetch('/applications', ctx) as any[];
|
||||
if (!Array.isArray(apps)) return apps;
|
||||
return apps.map((a: any) => ({
|
||||
uuid: a.uuid,
|
||||
name: a.name,
|
||||
fqdn: a.fqdn,
|
||||
status: a.status,
|
||||
repo: a.git_repository,
|
||||
branch: a.git_branch
|
||||
}));
|
||||
return apps
|
||||
// Filter out apps that belong to the protected VIBN project
|
||||
.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
|
||||
}));
|
||||
}
|
||||
|
||||
async function getAppStatus(appName: string, ctx: ToolContext): Promise<unknown> {
|
||||
@@ -622,6 +732,9 @@ async function getAppStatus(appName: string, ctx: ToolContext): Promise<unknown>
|
||||
a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName
|
||||
);
|
||||
if (!app) return { error: `App "${appName}" not found` };
|
||||
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.` };
|
||||
}
|
||||
const logs = await coolifyFetch(`/applications/${app.uuid}/logs?limit=20`, ctx);
|
||||
return { name: app.name, uuid: app.uuid, status: app.status, fqdn: app.fqdn, logs };
|
||||
}
|
||||
@@ -659,6 +772,11 @@ async function getJobStatus(jobId: string): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function saveMemory(key: string, type: string, value: string, ctx: ToolContext): unknown {
|
||||
ctx.memoryUpdates.push({ key, type, value });
|
||||
return { saved: true, key, type };
|
||||
}
|
||||
|
||||
async function deployApp(appName: string, ctx: ToolContext): Promise<unknown> {
|
||||
const apps = await coolifyFetch('/applications', ctx) as any[];
|
||||
if (!Array.isArray(apps)) return apps;
|
||||
@@ -666,6 +784,13 @@ async function deployApp(appName: string, ctx: ToolContext): Promise<unknown> {
|
||||
a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName
|
||||
);
|
||||
if (!app) return { error: `App "${appName}" not found` };
|
||||
// Block deployment to protected VIBN platform apps
|
||||
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.`
|
||||
};
|
||||
}
|
||||
const result = await fetch(`${ctx.coolify.apiUrl}/api/v1/deploy?uuid=${app.uuid}&force=false`, {
|
||||
headers: { 'Authorization': `Bearer ${ctx.coolify.apiToken}` }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user