From 8c3486dd58252ce26e63511054c7a1a67fc40550 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Fri, 27 Feb 2026 18:55:41 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20persistent=20AI=20memory=20=E2=80=94=20?= =?UTF-8?q?chat=20history=20+=20knowledge=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../projects/[projectId]/agent-chat/route.ts | 136 +++++++++++++++--- .../projects/[projectId]/knowledge/route.ts | 103 +++++++++++++ components/OrchestratorChat.tsx | 1 + 3 files changed, 221 insertions(+), 19 deletions(-) create mode 100644 app/api/projects/[projectId]/knowledge/route.ts diff --git a/app/api/projects/[projectId]/agent-chat/route.ts b/app/api/projects/[projectId]/agent-chat/route.ts index 1171c02..846a1ea 100644 --- a/app/api/projects/[projectId]/agent-chat/route.ts +++ b/app/api/projects/[projectId]/agent-chat/route.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }); } diff --git a/app/api/projects/[projectId]/knowledge/route.ts b/app/api/projects/[projectId]/knowledge/route.ts new file mode 100644 index 0000000..dcc4788 --- /dev/null +++ b/app/api/projects/[projectId]/knowledge/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth/authOptions"; +import { query } from "@/lib/db-postgres"; + +async function assertOwnership(projectId: string, email: string): Promise { + const rows = await query( + `SELECT p.id FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, + [projectId, email] + ); + return rows.length > 0; +} + +// GET /api/projects/[projectId]/knowledge +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { projectId } = await params; + if (!(await assertOwnership(projectId, session.user.email))) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const rows = await query<{ id: string; data: any; updated_at: string }>( + `SELECT id, data, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY updated_at DESC`, + [projectId] + ); + + return NextResponse.json({ + items: rows.map((r) => ({ id: r.id, ...r.data, updatedAt: r.updated_at })), + }); +} + +// POST /api/projects/[projectId]/knowledge — add or update an item +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { projectId } = await params; + if (!(await assertOwnership(projectId, session.user.email))) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const { key, type, value } = await req.json(); + if (!key || !value) { + return NextResponse.json({ error: "key and value are required" }, { status: 400 }); + } + + const itemType = type ?? "note"; + const data = JSON.stringify({ key, type: itemType, value, source: "user" }); + + // Upsert by key + const existing = await query<{ id: string }>( + `SELECT id FROM fs_knowledge_items WHERE project_id = $1 AND data->>'key' = $2 LIMIT 1`, + [projectId, key] + ); + + if (existing.length > 0) { + await query( + `UPDATE fs_knowledge_items SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`, + [data, existing[0].id] + ); + return NextResponse.json({ id: existing[0].id, key, type: itemType, value, updated: true }); + } else { + const rows = await query<{ id: string }>( + `INSERT INTO fs_knowledge_items (project_id, data) VALUES ($1, $2::jsonb) RETURNING id`, + [projectId, data] + ); + return NextResponse.json({ id: rows[0].id, key, type: itemType, value, created: true }); + } +} + +// DELETE /api/projects/[projectId]/knowledge?id=xxx +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { projectId } = await params; + if (!(await assertOwnership(projectId, session.user.email))) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const id = req.nextUrl.searchParams.get("id"); + if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 }); + + await query( + `DELETE FROM fs_knowledge_items WHERE id = $1 AND project_id = $2`, + [id, projectId] + ); + + return NextResponse.json({ deleted: true }); +} diff --git a/components/OrchestratorChat.tsx b/components/OrchestratorChat.tsx index c098af8..ee426f4 100644 --- a/components/OrchestratorChat.tsx +++ b/components/OrchestratorChat.tsx @@ -54,6 +54,7 @@ const TOOL_LABELS: Record = { gitea_list_issues: "Listed issues", gitea_close_issue: "Closed issue", gitea_comment_issue: "Added comment", + save_memory: "Saved to memory", }; function friendlyToolName(raw: string): string {