diff --git a/packages/chat-client/src/App.jsx b/packages/chat-client/src/App.jsx index 36bd793..4586c0c 100644 --- a/packages/chat-client/src/App.jsx +++ b/packages/chat-client/src/App.jsx @@ -65,7 +65,7 @@ export default function App() { streaming, lastTokenCount, lastModel, - useChat, + summarising, } = useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions }); function navigate(nextView) { diff --git a/packages/chat-client/src/api/orchestration.js b/packages/chat-client/src/api/orchestration.js index 5cc3723..13b1c10 100644 --- a/packages/chat-client/src/api/orchestration.js +++ b/packages/chat-client/src/api/orchestration.js @@ -212,3 +212,14 @@ export async function fetchSessionSummaries(sessionId) { return res.json(); } +export async function generateProjectSummary(projectId) { + const res = await fetch(`${BASE_URL}/summaries/project/${projectId}/generate`, { method: 'POST' }); + if (!res.ok) throw new Error(`Failed to generate project summary: ${res.status}`); + return res.json(); +} + +export async function fetchProjectOverviewSummary(projectId) { + const res = await fetch(`${BASE_URL}/summaries/project/${projectId}/overview`); + if (!res.ok) throw new Error(`Failed to fetch project overview: ${res.status}`); + return res.json(); // null if none exists yet +} \ No newline at end of file diff --git a/packages/chat-client/src/components/ProjectView.jsx b/packages/chat-client/src/components/ProjectView.jsx index 062fddb..0448d4a 100644 --- a/packages/chat-client/src/components/ProjectView.jsx +++ b/packages/chat-client/src/components/ProjectView.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { fetchSessions, updateProject, deleteProject } from '../api/orchestration'; +import { fetchSessions, updateProject, deleteProject, generateProjectSummary, fetchProjectOverviewSummary } from '../api/orchestration'; import ProjectModal from './ProjectModal'; export default function ProjectView({ project, onNavigate, onBack, onSelectSession, onNewProjectChat, onProjectsChange }) { @@ -8,9 +8,27 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi const [input, setInput] = useState(''); const [menuOpen, setMenuOpen] = useState(false); const [modal, setModal] = useState(null); + const [overview, setOverview] = useState(null); + const [overviewLoading, setOverviewLoading] = useState(true); + const [generating, setGenerating] = useState(false); + const [generateError, setGenerateError] = useState(null); useEffect(() => { load(); }, [project.id]); + useEffect(() => { + async function loadOverview() { + setOverviewLoading(true); + try { + setOverview(await fetchProjectOverviewSummary(project.id)); + } catch (err) { + console.error('[ProjectView] Failed to load overview:', err.message); + } finally { + setOverviewLoading(false); + } + } + loadOverview(); + }, [project.id]); + async function load() { setLoading(true); try { @@ -70,6 +88,23 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi if (diffDays === 1) return 'Yesterday'; return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); } + + async function handleGenerateSummary() { + setGenerating(true); + setGenerateError(null); + try { + setOverview(await generateProjectSummary(project.id)); + } catch (err) { + // 422 means no session summaries exist yet — surface a friendly message + setGenerateError( + err.message.includes('422') + ? 'No conversations found in this project yet.' + : 'Failed to generate summary. Please try again.' + ); + } finally { + setGenerating(false); + } +} return (
@@ -198,34 +233,61 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi {/* ── Project Memory ── */}
-

Project Memory

-
-
- - - Project Summary - - Coming soon +
+

Project Memory

+ +
+ +
+ {overviewLoading ? ( +

Loading…

+ + ) : generateError ? ( +

+ {generateError} +

+ + ) : overview ? ( + <> +

+ {overview.content} +

+

+ Last generated {formatTimestamp(overview.created_at)} +

+ + + ) : ( + // No overview exists yet — explain what this section is for +
+
+ + + No project summary yet + +
+

+ Generate a summary to create a concise overview of this project's goals, + progress, and key decisions — built from your session summaries. +

+
+ )}
-

- Once this project has enough conversations, NexusAI will automatically - generate a rolling summary of key themes, decisions, and context — giving - the model a condensed view of the project's memory without consuming the - full context window. -

-
{/* ── Notes ── */} diff --git a/packages/chat-client/src/index.css b/packages/chat-client/src/index.css index 95fa4d9..202fa64 100644 --- a/packages/chat-client/src/index.css +++ b/packages/chat-client/src/index.css @@ -114,4 +114,14 @@ html, body, #root { .text-secondary { color: var(--text-secondary); } .text-accent { color: var(--accent); } .label-upper { font-size: 13px; font-weight: 750; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; } -.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } \ No newline at end of file +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.spinner { + width: 12px; + height: 12px; + border: 2px solid var(--border); + border-top-color: var(--text-muted); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} \ No newline at end of file diff --git a/packages/memory-service/src/episodic/index.js b/packages/memory-service/src/episodic/index.js index d18c5ec..9e0772e 100644 --- a/packages/memory-service/src/episodic/index.js +++ b/packages/memory-service/src/episodic/index.js @@ -207,6 +207,17 @@ async function getEpisodeEmbedding(userMessage, aiResponse){ return data.embedding; } +function getEpisodesByProject(projectId, limit = 200) { + const db = getDB(); + return db.prepare(` + SELECT e.* FROM episodes e + JOIN sessions s ON s.id = e.session_id + WHERE s.project_id = ? + ORDER BY e.created_at ASC + LIMIT ? + `).all(projectId, limit).map(parseRow); +} + module.exports = { createSession, getSession, @@ -221,5 +232,6 @@ module.exports = { getEpisodesBySession, getRecentEpisodes, searchEpisodes, - deleteEpisode + deleteEpisode, + getEpisodesByProject }; \ No newline at end of file diff --git a/packages/memory-service/src/index.js b/packages/memory-service/src/index.js index 8a7c3ff..ebf6351 100644 --- a/packages/memory-service/src/index.js +++ b/packages/memory-service/src/index.js @@ -252,8 +252,7 @@ app.post('/projects/:id/summarize', async (req, res) => { const summary = await generateAndStoreProjectSummary(Number(req.params.id)); res.status(201).json(summary); } catch (err) { - // Distinguish "no data" from actual errors — both are non-500 from the client's perspective - if (err.message.includes('No session summaries')) { + if (err.message.includes('No session summaries or episodes')) { return res.status(422).json({ error: err.message }); } res.status(500).json({ error: err.message }); diff --git a/packages/memory-service/src/summarization/project.js b/packages/memory-service/src/summarization/project.js index d5d55f3..09e48c6 100644 --- a/packages/memory-service/src/summarization/project.js +++ b/packages/memory-service/src/summarization/project.js @@ -6,6 +6,7 @@ const { updateSummary, } = require('../db/summaries'); + const { getEpisodesByProject } = require('../episodic'); const { getProject } = require('../db/projects'); const EXTRACTION_URL = getEnv('EXTRACTION_URL', 'http://localhost:11434'); @@ -34,6 +35,55 @@ function buildProjectSummaryPrompt(projectName, sessionSummaries) { ].join('\n'); } +function buildProjectSummaryFromEpisodesPrompt(projectName, episodes) { + // Condense episodes into a readable block, truncating if needed + let episodeBlock = episodes + .map(ep => `User: ${ep.user_message}\nAssistant: ${ep.ai_response}`) + .join('\n\n'); + + if (episodeBlock.length > MAX_SUMMARY_CHARS) { + // Keep the most recent episodes — slice from the end + episodeBlock = episodeBlock.slice(-MAX_SUMMARY_CHARS); + } + + return [ + '<|im_start|>user', + `The following are conversations from a project called "${projectName}".`, + 'Write a project overview covering: goals, progress, key decisions, and current state.', + 'Scale the length to the material — use multiple paragraphs for complex projects, a few sentences for simple ones.', + 'Be comprehensive but avoid padding. Do not repeat the same point twice.', + 'Write in third person. Output only the overview text, no headings or labels.', + '', + episodeBlock, + '<|im_end|>', + '<|im_start|>assistant', + ].join('\n'); +} + +async function generateProjectSummaryFromEpisodes(projectName, episodes) { + const prompt = buildProjectSummaryFromEpisodesPrompt(projectName, episodes); + + const res = await fetch(`${EXTRACTION_URL}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: EXTRACTION_MODEL, + prompt, + stream: false, + options: { temperature: 0.2, num_predict: 1200 }, + }), + }); + + if (!res.ok) throw new Error(`Ollama responded ${res.status}`); + const data = await res.json(); + + const raw = data.response?.trim() ?? ''; + return raw + .replace(/<\|im_start\|>.*?<\|im_end\|>/gs, '') + .replace(/<\|im_start\|>|<\|im_end\|>|<\|im_sep\|>/g, '') + .trim(); +} + async function generateProjectSummary(projectName, sessionSummaries) { const prompt = buildProjectSummaryPrompt(projectName, sessionSummaries); @@ -64,13 +114,23 @@ async function generateAndStoreProjectSummary(projectId) { const project = getProject(projectId); if (!project) throw new Error('Project not found'); + let content; const sessionSummaries = getSessionSummariesForProject(projectId); - if (!sessionSummaries.length) throw new Error('No session summaries available to summarize'); - const content = await generateProjectSummary(project.name, sessionSummaries); + if (sessionSummaries.length > 0) { + // Preferred path — summarize the summaries + content = await generateProjectSummary(project.name, sessionSummaries); + } else { + // Fallback — summarize raw episodes directly + const episodes = getEpisodesByProject(projectId); + if (!episodes.length) { + throw new Error('No session summaries or episodes found for this project'); + } + content = await generateProjectSummaryFromEpisodes(project.name, episodes); + } + if (!content) throw new Error('Model returned empty summary'); - // Upsert — replace existing overview if present, otherwise create fresh const existing = getProjectOverviewSummary(projectId); if (existing) { return updateSummary(existing.id, { content, tokenCount: null, episodeRange: null });