// Session documentation operations - notes, plans, and project documentation // Replaces file-based CLAUDE.md and plan files with database storage import { query, queryOne, execute } from '../db.js'; import { getEmbedding, formatEmbedding, generateContentHash, rrfMerge } from '../embeddings.js'; import { getSessionId } from './session-id.js'; // ============================================================================ // SESSION NOTES // ============================================================================ interface SessionNoteAddArgs { session_id: string; note_type: 'accomplishment' | 'decision' | 'gotcha' | 'next_steps' | 'context'; content: string; } interface SessionNotesListArgs { session_id: string; note_type?: 'accomplishment' | 'decision' | 'gotcha' | 'next_steps' | 'context'; } interface SessionNote { id: number; session_id: string; note_type: string; content: string; created_at: string; } /** * Add a note to current session * Auto-generates embedding for semantic search */ export async function sessionNoteAdd(args: SessionNoteAddArgs): Promise { const { session_id: providedSessionId, note_type, content } = args; const session_id = providedSessionId || getSessionId(); // CF-1314: Hash content for dedup before embedding API call const contentHash = generateContentHash(content); const existing = await queryOne<{ id: number }>( 'SELECT id FROM session_notes WHERE content_hash = $1 AND session_id = $2 LIMIT 1', [contentHash, session_id] ); if (existing) { return `Note already exists (id: ${existing.id}) in session ${session_id}`; } // Generate embedding for semantic search const embedding = await getEmbedding(content); const embeddingFormatted = embedding ? formatEmbedding(embedding) : null; await execute( `INSERT INTO session_notes (session_id, note_type, content, embedding, content_hash) VALUES ($1, $2, $3, $4, $5)`, [session_id, note_type, content, embeddingFormatted, contentHash] ); return `Note added to session ${session_id} (type: ${note_type})`; } /** * List all notes for a session, optionally filtered by type */ export async function sessionNotesList(args: SessionNotesListArgs): Promise { const { session_id, note_type } = args; let sql = ` SELECT id, session_id, note_type, content, created_at FROM session_notes WHERE session_id = $1 `; const params: unknown[] = [session_id]; if (note_type) { sql += ` AND note_type = $2`; params.push(note_type); } sql += ` ORDER BY created_at ASC`; const notes = await query(sql, params); return notes; } // ============================================================================ // SESSION PLANS // ============================================================================ interface SessionPlanSaveArgs { session_id: string; plan_content: string; plan_file_name?: string; status?: 'draft' | 'approved' | 'executed' | 'abandoned'; } interface SessionPlanUpdateStatusArgs { plan_id: number; status: 'draft' | 'approved' | 'executed' | 'abandoned'; } interface SessionPlanListArgs { session_id: string; status?: 'draft' | 'approved' | 'executed' | 'abandoned'; } interface SessionPlan { id: number; session_id: string; plan_file_name: string | null; plan_content: string; status: string; created_at: string; approved_at: string | null; completed_at: string | null; } /** * Save a plan to database * Extracts embedding from plan content for semantic search */ export async function sessionPlanSave(args: SessionPlanSaveArgs): Promise { const { session_id, plan_content, plan_file_name, status = 'draft' } = args; // CF-1314: Hash content for dedup before embedding API call const contentHash = generateContentHash(plan_content); const existing = await queryOne<{ id: number }>( 'SELECT id FROM session_plans WHERE content_hash = $1 AND session_id = $2 LIMIT 1', [contentHash, session_id] ); if (existing) { return `Plan already exists (id: ${existing.id}) in session ${session_id}`; } // Generate embedding for semantic search const embedding = await getEmbedding(plan_content); const embeddingFormatted = embedding ? formatEmbedding(embedding) : null; const result = await queryOne<{ id: number }>( `INSERT INTO session_plans (session_id, plan_file_name, plan_content, status, embedding, content_hash) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, [session_id, plan_file_name || null, plan_content, status, embeddingFormatted, contentHash] ); const planId = result?.id || 0; return `Plan saved to session ${session_id} (plan_id: ${planId}, status: ${status})`; } /** * Update plan status (draft → approved → executed) */ export async function sessionPlanUpdateStatus(args: SessionPlanUpdateStatusArgs): Promise { const { plan_id, status } = args; // Set timestamps based on status let sql = 'UPDATE session_plans SET status = $1'; const params: unknown[] = [status, plan_id]; if (status === 'approved') { sql += ', approved_at = NOW()'; } else if (status === 'executed' || status === 'abandoned') { sql += ', completed_at = NOW()'; } sql += ' WHERE id = $2'; await execute(sql, params); return `Plan ${plan_id} status updated to: ${status}`; } /** * List plans for a session */ export async function sessionPlanList(args: SessionPlanListArgs): Promise { const { session_id, status } = args; let sql = ` SELECT id, session_id, plan_file_name, plan_content, status, created_at, approved_at, completed_at FROM session_plans WHERE session_id = $1 `; const params: unknown[] = [session_id]; if (status) { sql += ` AND status = $2`; params.push(status); } sql += ` ORDER BY created_at DESC`; const plans = await query(sql, params); return plans; } // ============================================================================ // PROJECT DOCUMENTATION // ============================================================================ interface ProjectDocUpsertArgs { project: string; doc_type: 'overview' | 'architecture' | 'guidelines' | 'history'; title: string; content: string; session_id?: string; } interface ProjectDocGetArgs { project: string; doc_type: 'overview' | 'architecture' | 'guidelines' | 'history'; } interface ProjectDocListArgs { project: string; } interface ProjectDoc { id: number; project: string; doc_type: string; title: string; content: string; last_updated_session: string | null; created_at: string; updated_at: string; } /** * Create or update project documentation * Links to current session */ export async function projectDocUpsert(args: ProjectDocUpsertArgs): Promise { const { project, doc_type, title, content, session_id } = args; await execute( `INSERT INTO project_documentation (project, doc_type, title, content, last_updated_session, updated_at) VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT (project, doc_type) DO UPDATE SET title = EXCLUDED.title, content = EXCLUDED.content, last_updated_session = EXCLUDED.last_updated_session, updated_at = NOW()`, [project, doc_type, title, content, session_id || null] ); return `Project documentation updated: ${project} (${doc_type})`; } /** * Get specific project documentation by type */ export async function projectDocGet(args: ProjectDocGetArgs): Promise { const { project, doc_type } = args; const doc = await queryOne( `SELECT id, project, doc_type, title, content, last_updated_session, created_at, updated_at FROM project_documentation WHERE project = $1 AND doc_type = $2`, [project, doc_type] ); return doc; } /** * List all documentation for a project */ export async function projectDocList(args: ProjectDocListArgs): Promise { const { project } = args; const docs = await query( `SELECT id, project, doc_type, title, content, last_updated_session, created_at, updated_at FROM project_documentation WHERE project = $1 ORDER BY doc_type`, [project] ); return docs; } // ============================================================================ // SESSION DOCUMENTATION GENERATION // ============================================================================ interface SessionDocumentationGenerateArgs { session_id: string; } /** * Auto-generate full markdown documentation for a session * Aggregates: tasks, commits, notes, decisions, plans * Stores in sessions.documentation field */ export async function sessionDocumentationGenerate(args: SessionDocumentationGenerateArgs): Promise { const { session_id } = args; // Fetch session details const session = await queryOne<{ id: string; project: string; session_number: number; started_at: string; ended_at: string; duration_minutes: number; summary: string; git_branch: string; }>( `SELECT id, project, session_number, started_at, ended_at, duration_minutes, summary, git_branch FROM sessions WHERE id = $1`, [session_id] ); if (!session) { throw new Error(`Session not found: ${session_id}`); } // Fetch tasks worked on const tasks = await query<{ task_id: string; title: string; status: string }>( `SELECT DISTINCT t.id as task_id, t.title, t.status FROM task_activity ta JOIN tasks t ON ta.task_id = t.id WHERE ta.session_id = $1 ORDER BY t.id`, [session_id] ); // Fetch commits made const commits = await query<{ commit_sha: string; commit_message: string }>( `SELECT commit_sha, commit_message FROM session_commits WHERE session_id = $1 ORDER BY committed_at`, [session_id] ); // Fetch notes const notes = await query<{ note_type: string; content: string }>( `SELECT note_type, content FROM session_notes WHERE session_id = $1 ORDER BY created_at`, [session_id] ); // Fetch plans const plans = await query<{ plan_content: string; status: string }>( `SELECT plan_content, status FROM session_plans WHERE session_id = $1 ORDER BY created_at DESC LIMIT 1`, [session_id] ); // Build markdown documentation let doc = `# Session ${session.session_number} - ${session.project}\n\n`; doc += `**Date:** ${new Date(session.started_at).toLocaleDateString()}\n`; doc += `**Duration:** ${session.duration_minutes || 0} minutes\n`; doc += `**Status:** ${session.summary ? '✅ Complete' : '⚠️ In Progress'}\n`; if (session.git_branch) { doc += `**Branch:** ${session.git_branch}\n`; } doc += `\n---\n\n`; // Summary if (session.summary) { doc += `## Summary\n\n${session.summary}\n\n`; } // Tasks if (tasks.length > 0) { doc += `## Tasks Completed\n\n`; tasks.forEach(task => { doc += `- **${task.task_id}**: ${task.title} (${task.status})\n`; }); doc += `\n`; } // Commits if (commits.length > 0) { doc += `## Commits\n\n`; commits.forEach(commit => { const shortSha = commit.commit_sha.substring(0, 7); doc += `- \`${shortSha}\` - ${commit.commit_message || 'No message'}\n`; }); doc += `\n`; } // Notes by type const notesByType: Record = {}; notes.forEach(note => { if (!notesByType[note.note_type]) { notesByType[note.note_type] = []; } notesByType[note.note_type].push(note.content); }); if (notesByType.accomplishment && notesByType.accomplishment.length > 0) { doc += `## Accomplishments\n\n`; notesByType.accomplishment.forEach(content => { doc += `${content}\n\n`; }); } if (notesByType.decision && notesByType.decision.length > 0) { doc += `## Key Decisions\n\n`; notesByType.decision.forEach(content => { doc += `${content}\n\n`; }); } if (notesByType.gotcha && notesByType.gotcha.length > 0) { doc += `## Gotchas & Learnings\n\n`; notesByType.gotcha.forEach(content => { doc += `${content}\n\n`; }); } if (notesByType.next_steps && notesByType.next_steps.length > 0) { doc += `## Next Steps\n\n`; notesByType.next_steps.forEach(content => { doc += `${content}\n\n`; }); } // Plan (if exists) if (plans.length > 0 && plans[0].status === 'executed') { doc += `## Plan\n\n${plans[0].plan_content}\n\n`; } // Store documentation in sessions table await execute( `UPDATE sessions SET documentation = $1 WHERE id = $2`, [doc, session_id] ); return `Documentation generated for session ${session_id} (${doc.length} characters)`; } // ============================================================================ // SEMANTIC SEARCH & ANALYTICS // ============================================================================ type SearchMode = 'hybrid' | 'vector' | 'keyword'; interface SessionSemanticSearchArgs { query: string; project?: string; limit?: number; search_mode?: SearchMode; } interface SessionSearchResult { session_id: string; session_number: number; project: string; summary: string | null; started_at: string; similarity: number; } /** * Semantic search across all session documentation with hybrid/vector/keyword modes (CF-1315) */ export async function sessionSemanticSearch(args: SessionSemanticSearchArgs): Promise { const { query: searchQuery, project, limit = 10, search_mode = 'hybrid' } = args; // Build shared filter clause const buildFilter = (startIdx: number) => { let where = ''; const params: unknown[] = []; let idx = startIdx; if (project) { where += ` AND s.project = $${idx++}`; params.push(project); } return { where, params, nextIdx: idx }; }; // Vector search let vectorIds: string[] = []; let vectorRows: Map = new Map(); let embeddingFailed = false; if (search_mode !== 'keyword') { const queryEmbedding = await getEmbedding(searchQuery); if (queryEmbedding) { const embeddingFormatted = formatEmbedding(queryEmbedding); const filter = buildFilter(3); const params: unknown[] = [embeddingFormatted, limit, ...filter.params]; const rows = await query( `SELECT s.id as session_id, s.session_number, s.project, s.summary, s.started_at, 1 - (s.embedding <=> $1) as similarity FROM sessions s WHERE s.embedding IS NOT NULL AND s.status = 'completed'${filter.where} ORDER BY s.embedding <=> $1 LIMIT $2`, params ); vectorIds = rows.map(r => r.session_id); for (const r of rows) vectorRows.set(r.session_id, r); } else { embeddingFailed = true; if (search_mode === 'vector') { return []; } } } // Keyword search let keywordIds: string[] = []; let keywordRows: Map = new Map(); if (search_mode !== 'vector') { const filter = buildFilter(3); const params: unknown[] = [searchQuery, limit, ...filter.params]; const rows = await query( `SELECT s.id as session_id, s.session_number, s.project, s.summary, s.started_at, ts_rank(s.search_vector, plainto_tsquery('english', $1)) as similarity FROM sessions s WHERE s.search_vector @@ plainto_tsquery('english', $1) AND s.status = 'completed'${filter.where} ORDER BY similarity DESC LIMIT $2`, params ); keywordIds = rows.map(r => r.session_id); for (const r of rows) keywordRows.set(r.session_id, r); } // Merge results let finalIds: string[]; if (search_mode === 'hybrid' && vectorIds.length > 0 && keywordIds.length > 0) { const merged = rrfMerge(vectorIds, keywordIds); finalIds = merged.slice(0, limit).map(m => m.id as string); } else if (vectorIds.length > 0) { finalIds = vectorIds; } else if (keywordIds.length > 0) { finalIds = keywordIds; } else { return []; } // Build final results preserving original similarity scores const results: SessionSearchResult[] = []; for (const id of finalIds) { const r = vectorRows.get(id) || keywordRows.get(id); if (r) results.push(r); } return results; } interface SessionAnalyticsArgs { project?: string; time_period?: 'week' | 'month' | 'quarter'; } interface SessionAnalytics { total_sessions: number; avg_duration_minutes: number; avg_tasks_per_session: number; avg_commits_per_session: number; avg_notes_per_session: number; total_tokens_used: number; } /** * Get productivity analytics for sessions */ export async function sessionProductivityAnalytics(args: SessionAnalyticsArgs): Promise { const { project, time_period = 'month' } = args; // Map time period to interval const intervalMap = { week: '7 days', month: '30 days', quarter: '90 days', }; const interval = intervalMap[time_period]; const sql = ` WITH session_stats AS ( SELECT s.id, s.duration_minutes, s.token_count, COUNT(DISTINCT ta.task_id) as tasks_touched, COUNT(DISTINCT sc.commit_sha) as commits_made, COUNT(sn.id) as notes_created FROM sessions s LEFT JOIN task_activity ta ON s.id = ta.session_id LEFT JOIN session_commits sc ON s.id = sc.session_id LEFT JOIN session_notes sn ON s.id = sn.session_id WHERE s.status = 'completed' AND s.started_at >= NOW() - INTERVAL '${interval}' ${project ? 'AND s.project = $1' : ''} GROUP BY s.id, s.duration_minutes, s.token_count ) SELECT COUNT(*)::int as total_sessions, COALESCE(AVG(duration_minutes), 0)::int as avg_duration_minutes, COALESCE(AVG(tasks_touched), 0)::numeric(10,1) as avg_tasks_per_session, COALESCE(AVG(commits_made), 0)::numeric(10,1) as avg_commits_per_session, COALESCE(AVG(notes_created), 0)::numeric(10,1) as avg_notes_per_session, COALESCE(SUM(token_count), 0)::bigint as total_tokens_used FROM session_stats; `; const result = await queryOne(sql, project ? [project] : []); return result || { total_sessions: 0, avg_duration_minutes: 0, avg_tasks_per_session: 0, avg_commits_per_session: 0, avg_notes_per_session: 0, total_tokens_used: 0, }; } interface SessionPatternArgs { project?: string; pattern_type?: 'tool_usage' | 'task_types' | 'error_frequency'; } interface Pattern { pattern: string; frequency: number; avg_session_duration: number; sessions_count: number; } /** * Detect patterns across sessions (tool usage, task types, etc.) */ export async function sessionPatternDetection(args: SessionPatternArgs): Promise { const { project, pattern_type = 'tool_usage' } = args; if (pattern_type === 'tool_usage') { // Analyze tool usage patterns const sql = ` SELECT unnest(s.tools_used) as pattern, COUNT(*) as frequency, AVG(s.duration_minutes)::int as avg_session_duration, COUNT(DISTINCT s.id) as sessions_count FROM sessions s WHERE s.status = 'completed' AND s.tools_used IS NOT NULL ${project ? 'AND s.project = $1' : ''} GROUP BY pattern HAVING COUNT(*) > 3 ORDER BY frequency DESC LIMIT 20; `; const results = await query(sql, project ? [project] : []); return results; } else if (pattern_type === 'task_types') { // Analyze task type patterns const sql = ` SELECT t.type as pattern, COUNT(*) as frequency, AVG(s.duration_minutes)::int as avg_session_duration, COUNT(DISTINCT s.id) as sessions_count FROM sessions s JOIN task_activity ta ON s.id = ta.session_id JOIN tasks t ON ta.task_id = t.id WHERE s.status = 'completed' ${project ? 'AND s.project = $1' : ''} GROUP BY t.type HAVING COUNT(*) > 3 ORDER BY frequency DESC; `; const results = await query(sql, project ? [project] : []); return results; } return []; }