feat: persistent AI memory — chat history + knowledge store

agent-chat/route.ts:
- Loads conversation history from chat_conversations before each turn
- Passes history + knowledge context to agent runner
- Saves returned history back to chat_conversations after each turn
- Saves AI-generated memory updates to fs_knowledge_items

knowledge/route.ts (new):
- GET  /api/projects/[id]/knowledge — list all knowledge items
- POST /api/projects/[id]/knowledge — add/update item by key
- DELETE /api/projects/[id]/knowledge?id=xxx — remove item

OrchestratorChat.tsx:
- Added "Saved to memory" label for save_memory tool calls

Made-with: Cursor
This commit is contained in:
2026-02-27 18:55:41 -08:00
parent a893d95387
commit 8c3486dd58
3 changed files with 221 additions and 19 deletions

View File

@@ -5,6 +5,84 @@ import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
// ---------------------------------------------------------------------------
// Helpers — chat_conversations + fs_knowledge_items
// ---------------------------------------------------------------------------
async function loadConversation(projectId: string): Promise<any[]> {
try {
const rows = await query<{ messages: any[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[projectId]
);
return rows[0]?.messages ?? [];
} catch {
return [];
}
}
async function saveConversation(projectId: string, messages: any[]): Promise<void> {
try {
await query(
`INSERT INTO chat_conversations (project_id, messages, updated_at)
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (project_id) DO UPDATE
SET messages = $2::jsonb, updated_at = NOW()`,
[projectId, JSON.stringify(messages)]
);
} catch (e) {
console.error("[agent-chat] Failed to save conversation:", e);
}
}
async function loadKnowledge(projectId: string): Promise<string> {
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_knowledge_items WHERE project_id = $1 ORDER BY updated_at DESC LIMIT 50`,
[projectId]
);
if (rows.length === 0) return "";
return rows
.map((r) => `[${r.data.type ?? "note"}] ${r.data.key}: ${r.data.value}`)
.join("\n");
} catch {
return "";
}
}
async function saveMemoryUpdates(
projectId: string,
updates: Array<{ key: string; type: string; value: string }>
): Promise<void> {
if (!updates?.length) return;
try {
for (const u of updates) {
// Upsert by project_id + key
const existing = await query<{ id: string }>(
`SELECT id FROM fs_knowledge_items WHERE project_id = $1 AND data->>'key' = $2 LIMIT 1`,
[projectId, u.key]
);
if (existing.length > 0) {
await query(
`UPDATE fs_knowledge_items SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
[JSON.stringify({ key: u.key, type: u.type, value: u.value, source: "ai" }), existing[0].id]
);
} else {
await query(
`INSERT INTO fs_knowledge_items (project_id, data) VALUES ($1, $2::jsonb)`,
[projectId, JSON.stringify({ key: u.key, type: u.type, value: u.value, source: "ai" })]
);
}
}
} catch (e) {
console.error("[agent-chat] Failed to save memory updates:", e);
}
}
// ---------------------------------------------------------------------------
// POST — send a message
// ---------------------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
@@ -21,7 +99,7 @@ export async function POST(
return NextResponse.json({ error: '"message" is required' }, { status: 400 });
}
// Load project context to inject into the orchestrator session
// Load project context
let projectContext = "";
try {
const rows = await query<{ data: any }>(
@@ -40,24 +118,34 @@ export async function POST(
].filter(Boolean);
projectContext = lines.join("\n");
}
} catch {
// Non-fatal — orchestrator still works without extra context
}
} catch { /* non-fatal */ }
// Use projectId as the session ID so each project has its own conversation
const sessionId = `project_${projectId}`;
// First message in a new session? Prepend project context
const enrichedMessage = projectContext
? `[Project context]\n${projectContext}\n\n[User message]\n${message}`
: message;
// Load persistent conversation history and knowledge from DB
const [history, knowledgeContext] = await Promise.all([
loadConversation(projectId),
loadKnowledge(projectId),
]);
// Enrich user message with project context on the very first message
const isFirstMessage = history.length === 0;
const enrichedMessage =
isFirstMessage && projectContext
? `[Project context]\n${projectContext}\n\n[User message]\n${message}`
: message;
try {
const res = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: enrichedMessage, session_id: sessionId }),
signal: AbortSignal.timeout(120_000), // 2 min — agents can take time
body: JSON.stringify({
message: enrichedMessage,
session_id: sessionId,
history,
knowledge_context: knowledgeContext || undefined,
}),
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
@@ -69,6 +157,13 @@ export async function POST(
}
const data = await res.json();
// Persist conversation and any memory updates the AI generated
await Promise.all([
saveConversation(projectId, data.history ?? []),
saveMemoryUpdates(projectId, data.memoryUpdates ?? []),
]);
return NextResponse.json({
reply: data.reply,
reasoning: data.reasoning ?? null,
@@ -76,6 +171,7 @@ export async function POST(
turns: data.turns ?? 0,
model: data.model || null,
sessionId,
memoryUpdates: data.memoryUpdates ?? [],
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -86,7 +182,10 @@ export async function POST(
}
}
// Clear session for this project
// ---------------------------------------------------------------------------
// DELETE — clear session + conversation history
// ---------------------------------------------------------------------------
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
@@ -99,13 +198,12 @@ export async function DELETE(
const { projectId } = await params;
const sessionId = `project_${projectId}`;
try {
await fetch(`${AGENT_RUNNER_URL}/orchestrator/sessions/${sessionId}`, {
method: "DELETE",
});
} catch {
// Best-effort
}
await Promise.all([
// Clear in-memory session from agent runner
fetch(`${AGENT_RUNNER_URL}/orchestrator/sessions/${sessionId}`, { method: "DELETE" }).catch(() => {}),
// Clear persisted conversation from DB
query(`DELETE FROM chat_conversations WHERE project_id = $1`, [projectId]).catch(() => {}),
]);
return NextResponse.json({ cleared: true });
}