Files
vibn-frontend/lib/server/chat-context.ts

430 lines
13 KiB
TypeScript

/**
* Project Context Builder for Chat
*
* Loads project state from Firestore and AlloyDB vector memory,
* building a compact context object for LLM consumption.
*/
import { query } from '@/lib/db-postgres';
import { retrieveRelevantChunks } from '@/lib/server/vector-memory';
import { embedText } from '@/lib/ai/embeddings';
import {
summarizeKnowledgeItems,
summarizeExtractions,
} from '@/lib/server/chat-mode-resolver';
import type { ChatMode } from '@/lib/ai/chat-modes';
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
import type { PhaseHandoff } from '@/lib/types/phase-handoff';
/**
* Compact project context for LLM
*/
export interface ProjectChatContext {
/** Basic project info */
project: {
id: string;
name: string;
currentPhase: string;
phaseStatus: string;
githubRepo?: string | null;
githubRepoUrl?: string | null;
extensionLinked?: boolean;
visionAnswers?: {
q1?: string;
q2?: string;
q3?: string;
updatedAt?: string;
};
/** Gitea monorepo */
giteaRepo?: string | null;
giteaRepoUrl?: string | null;
/** Turborepo apps */
apps?: Array<{
name: string;
path: string;
domain?: string | null;
coolifyServiceUuid?: string | null;
}>;
};
/** Phase-specific artifacts */
phaseData: {
canonicalProductModel?: any;
mvpPlan?: any;
marketingPlan?: any;
};
/** Phase scores and progress */
phaseScores: ProjectPhaseScores;
/** Phase handoffs for smart transitions */
phaseHandoffs: Partial<Record<'collector' | 'extraction' | 'vision' | 'mvp' | 'marketing', PhaseHandoff>>;
/** Knowledge summary (counts, types) */
knowledgeSummary: {
totalCount: number;
bySourceType: Record<string, number>;
recentTitles: string[];
};
/** Extraction summary */
extractionSummary: {
totalCount: number;
avgConfidence: number;
avgCompletion: number;
};
/** Relevant chunks from vector search */
retrievedChunks: {
content: string;
sourceType?: string | null;
importance?: string | null;
similarity: number;
}[];
/** Repository analysis (if GitHub connected) */
repositoryAnalysis?: {
repoFullName: string;
totalFiles: number;
directories: string[];
keyFiles: string[];
techStack: string[];
readme: string | null;
summary: string;
} | null;
/** Session history from linked Cursor sessions */
sessionHistory: {
totalSessions: number;
messages: Array<{
role: string;
content: string;
timestamp: string;
sessionId?: string;
}>;
};
}
/**
* Build project context for a chat interaction
*
* @param projectId - Firestore project ID
* @param mode - Current chat mode
* @param userMessage - User's message (used for vector retrieval)
* @param options - Context building options
* @returns Compact context object
*/
export async function buildProjectContextForChat(
projectId: string,
mode: ChatMode,
userMessage: string,
options: {
retrievalLimit?: number;
includeVectorSearch?: boolean;
includeGitHubAnalysis?: boolean;
} = {}
): Promise<ProjectChatContext> {
const {
retrievalLimit = 10,
includeVectorSearch = true,
includeGitHubAnalysis = true,
} = options;
try {
// Load project from Postgres
const projectRows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (projectRows.length === 0) {
throw new Error(`Project ${projectId} not found`);
}
const projectData = projectRows[0].data ?? {};
// Load summaries in parallel
const [knowledgeSummary, extractionSummary] = await Promise.all([
summarizeKnowledgeItems(projectId),
summarizeExtractions(projectId),
]);
// Vector retrieval
let retrievedChunks: ProjectChatContext['retrievedChunks'] = [];
// extraction_review_mode does NOT load documents - it reviews extraction results
// Normal vector search for modes that need it
if (includeVectorSearch && mode !== 'collector_mode' && mode !== 'extraction_review_mode' && userMessage.trim().length > 0) {
try {
const queryEmbedding = await embedText(userMessage);
const chunks = await retrieveRelevantChunks(projectId, queryEmbedding, {
limit: retrievalLimit,
minSimilarity: 0.7, // Only include reasonably relevant chunks
});
retrievedChunks = chunks.map((chunk) => ({
content: chunk.content,
sourceType: chunk.sourceType,
importance: chunk.importance,
similarity: chunk.similarity,
}));
console.log(
`[Chat Context] Retrieved ${retrievedChunks.length} chunks for project ${projectId}`
);
} catch (vectorError) {
console.error('[Chat Context] Vector retrieval failed:', vectorError);
// Continue without vector results
}
}
// GitHub repository analysis
let repositoryAnalysis = null;
if (includeGitHubAnalysis && projectData.githubRepo && projectData.userId) {
try {
const { analyzeGitHubRepository } = await import('@/lib/server/github-analyzer');
repositoryAnalysis = await analyzeGitHubRepository(
projectData.userId,
projectData.githubRepo,
projectData.githubDefaultBranch || 'main'
);
} catch (githubError) {
console.error('[Chat Context] GitHub analysis failed:', githubError);
// Continue without GitHub analysis
}
}
// Fetch linked Cursor session history
let sessionHistory = {
totalSessions: 0,
messages: [] as Array<{
role: string;
content: string;
timestamp: string;
sessionId?: string;
}>,
};
try {
// Query sessions linked to this project
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('projectId', '==', projectId)
.orderBy('startTime', 'asc')
.get();
if (!sessionsSnapshot.empty) {
sessionHistory.totalSessions = sessionsSnapshot.size;
// Extract all messages from all sessions in chronological order
const allMessages: Array<{
role: string;
content: string;
timestamp: string;
sessionId: string;
}> = [];
for (const sessionDoc of sessionsSnapshot.docs) {
const sessionData = sessionDoc.data();
const conversation = sessionData.conversation || [];
// Add messages from this session
for (const msg of conversation) {
if (msg.content && msg.content.trim()) {
allMessages.push({
role: msg.role || 'unknown',
content: msg.content,
timestamp: msg.timestamp instanceof Date
? msg.timestamp.toISOString()
: (typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString()),
sessionId: sessionDoc.id,
});
}
}
}
// Sort all messages by timestamp (chronological order)
allMessages.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
sessionHistory.messages = allMessages;
console.log(
`[Chat Context] Loaded ${sessionHistory.totalSessions} sessions with ${allMessages.length} total messages for project ${projectId}`
);
} else {
console.log(`[Chat Context] No linked sessions found for project ${projectId}`);
}
} catch (sessionError) {
console.error('[Chat Context] Session history fetch failed:', sessionError);
// Continue without session history
}
// Build context object
const context: ProjectChatContext = {
project: {
id: projectId,
name: projectData.name ?? 'Unnamed Project',
currentPhase: projectData.currentPhase ?? 'collector',
phaseStatus: projectData.phaseStatus ?? 'not_started',
githubRepo: projectData.githubRepo ?? null,
githubRepoUrl: projectData.githubRepoUrl ?? null,
extensionLinked: projectData.extensionLinked ?? false,
visionAnswers: projectData.visionAnswers ?? {},
giteaRepo: projectData.giteaRepo ?? null,
giteaRepoUrl: projectData.giteaRepoUrl ?? null,
apps: projectData.apps ?? [],
},
phaseData: {
canonicalProductModel: projectData.phaseData?.canonicalProductModel ?? null,
mvpPlan: projectData.phaseData?.mvpPlan ?? null,
marketingPlan: projectData.phaseData?.marketingPlan ?? null,
},
phaseScores: projectData.phaseScores ?? {},
phaseHandoffs: projectData.phaseData?.phaseHandoffs ?? {},
knowledgeSummary,
extractionSummary,
retrievedChunks,
repositoryAnalysis: repositoryAnalysis as any,
sessionHistory, // ✅ Include session history in context
};
return context;
} catch (error) {
console.error('[Chat Context] Failed to build context:', error);
throw new Error(
`Failed to build chat context: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Determine which artifacts were used in building the context
*
* This helps the UI show what sources the AI is drawing from.
*/
export function determineArtifactsUsed(context: ProjectChatContext): string[] {
const artifacts: string[] = [];
if (context.phaseData.canonicalProductModel) {
artifacts.push('Product Model');
}
if (context.phaseData.mvpPlan) {
artifacts.push('MVP Plan');
}
if (context.phaseData.marketingPlan) {
artifacts.push('Marketing Plan');
}
if (context.retrievedChunks.length > 0) {
artifacts.push(`${context.retrievedChunks.length} Vector Chunks`);
}
if (context.repositoryAnalysis) {
artifacts.push('GitHub Repo Analysis');
}
if (context.knowledgeSummary.totalCount > 0) {
artifacts.push(`${context.knowledgeSummary.totalCount} Knowledge Items`);
}
if (context.extractionSummary.totalCount > 0) {
artifacts.push(`${context.extractionSummary.totalCount} Extractions`);
}
if (context.sessionHistory.totalSessions > 0) {
artifacts.push(`${context.sessionHistory.totalSessions} Cursor Sessions (${context.sessionHistory.messages.length} messages)`);
}
return artifacts;
}
/**
* Format project context as a string for LLM system prompt
*
* Provides a human-readable summary of the context.
*/
export function formatContextForPrompt(context: ProjectChatContext): string {
const sections: string[] = [];
// Project info
sections.push(`Project: ${context.project.name} (ID: ${context.project.id})`);
sections.push(
`Phase: ${context.project.currentPhase} (${context.project.phaseStatus})`
);
// Monorepo info
if (context.project.giteaRepo) {
sections.push(`\n## Monorepo (Turborepo)`);
sections.push(`Repository: ${context.project.giteaRepoUrl ?? context.project.giteaRepo}`);
if (context.project.apps && context.project.apps.length > 0) {
sections.push(`Apps:`);
for (const app of context.project.apps) {
const domain = app.domain ? ` → https://${app.domain}` : '';
sections.push(`${app.name} (${app.path})${domain}`);
}
}
}
// Knowledge summary
if (context.knowledgeSummary.totalCount > 0) {
sections.push(`\nKnowledge Items: ${context.knowledgeSummary.totalCount} total`);
if (Object.keys(context.knowledgeSummary.bySourceType).length > 0) {
sections.push(
` By type: ${JSON.stringify(context.knowledgeSummary.bySourceType)}`
);
}
}
// Extraction summary
if (context.extractionSummary.totalCount > 0) {
sections.push(
`\nExtractions: ${context.extractionSummary.totalCount} analyzed (avg confidence: ${(context.extractionSummary.avgConfidence * 100).toFixed(1)}%)`
);
}
// Retrieved chunks
if (context.retrievedChunks.length > 0) {
sections.push(`\nRelevant Context (vector search):`);
context.retrievedChunks.slice(0, 3).forEach((chunk, i) => {
sections.push(
` ${i + 1}. [${chunk.sourceType ?? 'unknown'}] (similarity: ${(chunk.similarity * 100).toFixed(1)}%)`
);
sections.push(` ${chunk.content.substring(0, 150)}...`);
});
}
// GitHub repo
if (context.repositoryAnalysis) {
sections.push(`\nGitHub Repository: ${context.repositoryAnalysis.repoFullName}`);
sections.push(` Files: ${context.repositoryAnalysis.totalFiles}`);
sections.push(` Tech: ${context.repositoryAnalysis.techStack.join(', ')}`);
}
// Phase handoffs
const handoffs = Object.keys(context.phaseHandoffs);
if (handoffs.length > 0) {
sections.push(`\nPhase Handoffs: ${handoffs.join(', ')}`);
}
// Session history
if (context.sessionHistory.totalSessions > 0) {
sections.push(`\n## Cursor Session History (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages)`);
sections.push(`This is your complete conversation history with the user from Cursor IDE, in chronological order.`);
sections.push(`Use this to understand what has been built, discussed, and decided so far.\n`);
// Include all messages chronologically
context.sessionHistory.messages.forEach((msg, i) => {
const timestamp = new Date(msg.timestamp).toLocaleString();
sections.push(`[${timestamp}] ${msg.role}:`);
sections.push(msg.content);
sections.push(''); // Empty line between messages
});
}
return sections.join('\n');
}