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:
2026-02-27 18:55:33 -08:00
parent 5cb1e82169
commit 837b6e8b8d
3 changed files with 276 additions and 121 deletions

View File

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