// Session management operations for database-driven session tracking // Sessions auto-create CF Jira issues and post output on close (CF-762) import { query, queryOne, execute } from '../db.js'; import { getEmbedding, formatEmbedding, generateContentHash } from '../embeddings.js'; import { createSessionIssue, addComment, transitionToDone, updateIssueDescription } from '../services/jira.js'; interface SessionStartArgs { session_id?: string; project: string; working_directory?: string; git_branch?: string; initial_prompt?: string; jira_issue_key?: string; } interface SessionUpdateArgs { session_id: string; message_count?: number; token_count?: number; tools_used?: string[]; } interface SessionEndArgs { session_id: string; summary: string; status?: 'completed' | 'interrupted'; } interface SessionListArgs { project?: string; status?: 'active' | 'completed' | 'interrupted'; since?: string; limit?: number; } interface SessionSearchArgs { query: string; project?: string; limit?: number; } interface Session { id: string; project: string | null; session_number: number | null; started_at: string; ended_at: string | null; duration_minutes: number | null; working_directory: string | null; git_branch: string | null; initial_prompt: string | null; summary: string | null; message_count: number; token_count: number; tools_used: string[] | null; status: string; jira_issue_key: string | null; created_at: string; } /** * Start a new session with metadata tracking. * Auto-creates a CF Jira issue for session tracking. * Returns session_id, session_number, and Jira issue key. */ export async function sessionStart(args: SessionStartArgs): Promise { const { session_id, project, working_directory, git_branch, initial_prompt, jira_issue_key } = args; // Generate session ID if not provided (fallback, should come from session-memory) const id = session_id || `session_${Date.now()}_${Math.random().toString(36).substring(7)}`; await execute( `INSERT INTO sessions (id, project, started_at, working_directory, git_branch, initial_prompt, jira_issue_key, status) VALUES ($1, $2, NOW(), $3, $4, $5, $6, 'active')`, [id, project, working_directory || null, git_branch || null, initial_prompt || null, jira_issue_key || null] ); // Get the assigned session_number const result = await queryOne<{ session_number: number }>( 'SELECT session_number FROM sessions WHERE id = $1', [id] ); const session_number = result?.session_number || null; // Auto-create CF Jira issue for session tracking (non-blocking) let sessionJiraKey: string | null = jira_issue_key || null; if (!sessionJiraKey) { try { const jiraResult = await createSessionIssue({ sessionNumber: session_number, project, parentIssueKey: jira_issue_key || undefined, branch: git_branch || undefined, workingDirectory: working_directory || undefined, }); if (jiraResult) { sessionJiraKey = jiraResult.key; // Store the auto-created Jira issue key await execute( `UPDATE sessions SET jira_issue_key = $1 WHERE id = $2`, [sessionJiraKey, id] ); } } catch (err) { console.error('session-mcp: Failed to create session Jira issue:', err); } } const jiraInfo = sessionJiraKey ? ` [${sessionJiraKey}]` : ''; return `Session started: ${id} (${project} #${session_number})${jiraInfo}`; } /** * Update session metadata during execution */ export async function sessionUpdate(args: SessionUpdateArgs): Promise { const { session_id, message_count, token_count, tools_used } = args; const updates: string[] = []; const params: unknown[] = []; let paramIndex = 1; if (message_count !== undefined) { updates.push(`message_count = $${paramIndex++}`); params.push(message_count); } if (token_count !== undefined) { updates.push(`token_count = $${paramIndex++}`); params.push(token_count); } if (tools_used !== undefined) { updates.push(`tools_used = $${paramIndex++}`); params.push(tools_used); } if (updates.length === 0) { return 'No updates provided'; } updates.push(`updated_at = NOW()`); params.push(session_id); await execute( `UPDATE sessions SET ${updates.join(', ')} WHERE id = $${paramIndex}`, params ); return `Session updated: ${session_id}`; } /** * End session and generate summary with embedding. * Posts full session output as Jira comment and transitions session issue to Done. */ export async function sessionEnd(args: SessionEndArgs): Promise { const { session_id, summary, status = 'completed' } = args; // CF-1314: Store content hash alongside embedding const contentHash = generateContentHash(summary); // Generate embedding for semantic search const embedding = await getEmbedding(summary); const embeddingValue = embedding ? formatEmbedding(embedding) : null; await execute( `UPDATE sessions SET ended_at = NOW(), summary = $1, embedding = $2, status = $3, content_hash = $4, updated_at = NOW() WHERE id = $5`, [summary, embeddingValue, status, contentHash, session_id] ); // Get session details const session = await queryOne( `SELECT id, project, session_number, duration_minutes, jira_issue_key FROM sessions WHERE id = $1`, [session_id] ); if (!session) { return `Session ended: ${session_id}`; } // Post session output to Jira and close the session issue (non-blocking) let jiraStatus = ''; if (session.jira_issue_key) { try { // Collect session output for Jira comment const sessionOutput = await buildSessionOutput(session_id, session, summary); // Post as comment const commented = await addComment(session.jira_issue_key, sessionOutput); // Update issue description with final summary const descriptionUpdate = [ `## Session ${session.project} #${session.session_number}`, `**Duration:** ${session.duration_minutes || 0} minutes`, `**Status:** ${status}`, `**Session ID:** ${session_id}`, '', `## Summary`, summary, ].join('\n'); await updateIssueDescription(session.jira_issue_key, descriptionUpdate); // Transition to Done const transitioned = await transitionToDone(session.jira_issue_key); jiraStatus = commented && transitioned ? ` [${session.jira_issue_key} → Done]` : commented ? ` [${session.jira_issue_key} commented]` : ` [${session.jira_issue_key} Jira update partial]`; } catch (err) { console.error('session-mcp: Failed to update session Jira issue:', err); jiraStatus = ` [${session.jira_issue_key} Jira update failed]`; } } return `Session ended: ${session.project} #${session.session_number} (${session.duration_minutes || 0}m)${jiraStatus}`; } /** * Build full session output markdown for Jira comment. */ async function buildSessionOutput( session_id: string, session: { project: string | null; session_number: number | null; duration_minutes: number | null }, summary: string ): Promise { const lines: string[] = []; lines.push(`# Session ${session.project} #${session.session_number}`); lines.push(`Duration: ${session.duration_minutes || 0} minutes`); lines.push(''); lines.push(`## Summary`); lines.push(summary); lines.push(''); // Get session 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] ); if (notes.length > 0) { const grouped: Record = {}; for (const n of notes) { if (!grouped[n.note_type]) grouped[n.note_type] = []; grouped[n.note_type].push(n.content); } for (const [type, items] of Object.entries(grouped)) { const label = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); lines.push(`## ${label}`); for (const item of items) { lines.push(`- ${item}`); } lines.push(''); } } // Get commits const commits = await query<{ commit_sha: string; repo: string; commit_message: string | null }>( `SELECT commit_sha, repo, commit_message FROM session_commits WHERE session_id = $1 ORDER BY committed_at DESC`, [session_id] ); if (commits.length > 0) { lines.push(`## Commits (${commits.length})`); for (const c of commits) { const msg = c.commit_message ? c.commit_message.split('\n')[0] : 'No message'; lines.push(`- ${c.commit_sha.substring(0, 7)} (${c.repo}): ${msg}`); } lines.push(''); } return lines.join('\n'); } /** * List sessions with filtering and pagination */ export async function sessionList(args: SessionListArgs): Promise { const { project, status, since, limit = 20 } = args; let whereClause = 'WHERE 1=1'; const params: unknown[] = []; let paramIndex = 1; if (project) { whereClause += ` AND project = $${paramIndex++}`; params.push(project); } if (status) { whereClause += ` AND status = $${paramIndex++}`; params.push(status); } if (since) { whereClause += ` AND started_at >= $${paramIndex++}::timestamp`; params.push(since); } params.push(limit); const sessions = await query( `SELECT id, project, session_number, started_at, ended_at, duration_minutes, summary, message_count, token_count, status FROM sessions ${whereClause} ORDER BY started_at DESC LIMIT $${paramIndex}`, params ); if (sessions.length === 0) { return `No sessions found${project ? ` for project ${project}` : ''}`; } const lines: string[] = []; for (const s of sessions) { const num = s.session_number ? `#${s.session_number}` : ''; const duration = s.duration_minutes ? `${s.duration_minutes}m` : 'active'; const messages = s.message_count ? `${s.message_count} msgs` : ''; const summaryPreview = s.summary ? s.summary.slice(0, 60) + '...' : 'No summary'; lines.push(`${s.project} ${num} (${duration}, ${messages}) - ${summaryPreview}`); } return `Sessions:\n${lines.join('\n')}`; } /** * Semantic search across sessions using vector similarity */ export async function sessionSearch(args: SessionSearchArgs): Promise { const { query: searchQuery, project, limit = 5 } = args; // Generate embedding for search const embedding = await getEmbedding(searchQuery); if (!embedding) { return 'Error: Could not generate embedding for search'; } const embeddingStr = formatEmbedding(embedding); let whereClause = 'WHERE embedding IS NOT NULL'; const params: unknown[] = [embeddingStr, limit]; if (project) { whereClause += ` AND project = $3`; params.splice(1, 0, project); // Insert before limit params[2] = limit; // Adjust limit position } const sessions = await query( `SELECT id, project, session_number, started_at, duration_minutes, summary, 1 - (embedding <=> $1) as similarity FROM sessions ${whereClause} ORDER BY embedding <=> $1 LIMIT $${project ? '3' : '2'}`, params ); if (sessions.length === 0) { return 'No relevant sessions found'; } const lines = ['Similar sessions:\n']; for (const s of sessions) { const sim = Math.round(s.similarity * 100); const num = s.session_number ? `#${s.session_number}` : ''; const duration = s.duration_minutes ? `(${s.duration_minutes}m)` : ''; lines.push(`**${s.project} ${num}** ${duration} (${sim}% match)`); lines.push(` ${s.summary || 'No summary'}`); lines.push(''); } return lines.join('\n'); } /** * Get complete session context: tasks, commits, builds, memories */ export async function sessionContext(session_id: string): Promise { // Get session details const session = await queryOne( `SELECT * FROM sessions WHERE id = $1`, [session_id] ); if (!session) { return `Session not found: ${session_id}`; } const lines: string[] = []; lines.push(`**Session: ${session.project} #${session.session_number}**`); lines.push(`Started: ${session.started_at}`); if (session.ended_at) { lines.push(`Ended: ${session.ended_at} (${session.duration_minutes}m)`); } if (session.summary) { lines.push(`\nSummary: ${session.summary}`); } lines.push(''); // Get tasks touched in this session const tasks = await query<{ task_id: string; title: string; status: string; activity_count: number }>( `SELECT task_id, title, status, activity_count FROM session_tasks WHERE session_id = $1 ORDER BY first_touched`, [session_id] ); if (tasks.length > 0) { lines.push(`**Tasks (${tasks.length}):**`); for (const t of tasks) { lines.push(`• ${t.task_id}: ${t.title} [${t.status}] (${t.activity_count} activities)`); } lines.push(''); } // Get commits made in this session const commits = await query<{ commit_sha: string; repo: string; commit_message: string | null }>( `SELECT commit_sha, repo, commit_message FROM session_commits WHERE session_id = $1 ORDER BY committed_at DESC`, [session_id] ); if (commits.length > 0) { lines.push(`**Commits (${commits.length}):**`); for (const c of commits) { const msg = c.commit_message ? c.commit_message.split('\n')[0] : 'No message'; lines.push(`• ${c.commit_sha.substring(0, 7)} (${c.repo}): ${msg}`); } lines.push(''); } // Get builds linked to this session const builds = await query<{ build_number: number; status: string; version_id: string | null }>( `SELECT build_number, status, version_id FROM builds WHERE session_id = $1 ORDER BY started_at DESC`, [session_id] ); if (builds.length > 0) { lines.push(`**Builds (${builds.length}):**`); for (const b of builds) { lines.push(`• Build #${b.build_number}: ${b.status}${b.version_id ? ` (${b.version_id})` : ''}`); } lines.push(''); } // Get memories stored in this session const memories = await query<{ category: string; title: string; content: string }>( `SELECT category, title, content FROM memories WHERE session_id = $1 ORDER BY created_at`, [session_id] ); if (memories.length > 0) { lines.push(`**Memories (${memories.length}):**`); for (const m of memories) { lines.push(`• [${m.category}] ${m.title}`); lines.push(` ${m.content.slice(0, 100)}${m.content.length > 100 ? '...' : ''}`); } } // Show metrics if (session.message_count || session.token_count || session.tools_used) { lines.push('\n**Metrics:**'); if (session.message_count) lines.push(`• Messages: ${session.message_count}`); if (session.token_count) lines.push(`• Tokens: ${session.token_count}`); if (session.tools_used && session.tools_used.length > 0) { lines.push(`• Tools: ${session.tools_used.join(', ')}`); } } return lines.join('\n'); } /** * Record build information linked to session and version */ export async function buildRecord( session_id: string | null, version_id: string, build_number: number, git_commit_sha: string | null, status: string, started_at: string ): Promise { await execute( `INSERT INTO builds (session_id, version_id, build_number, git_commit_sha, status, started_at) VALUES ($1, $2, $3, $4, $5, $6)`, [session_id, version_id, build_number, git_commit_sha, status, started_at] ); return `Build recorded: #${build_number} for ${version_id} (${status})`; } /** * Link a commit to a session (automatically called when commits are made) */ export async function sessionCommitLink( session_id: string, commit_sha: string, repo: string, commit_message: string | null, committed_at: string | null ): Promise { // Check if session exists const sessionExists = await queryOne<{ exists: boolean }>( 'SELECT EXISTS(SELECT 1 FROM sessions WHERE id = $1) as exists', [session_id] ); // Auto-create session if missing (prevents FK constraint error) if (!sessionExists?.exists) { const timestamp = committed_at || new Date().toISOString(); await execute( `INSERT INTO sessions (id, started_at, status) VALUES ($1, $2, 'active') ON CONFLICT (id) DO NOTHING`, [session_id, timestamp] ); } // Now safely insert commit link await execute( `INSERT INTO session_commits (session_id, commit_sha, repo, commit_message, committed_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_id, commit_sha) DO NOTHING`, [session_id, commit_sha, repo, commit_message, committed_at] ); const prefix = sessionExists?.exists ? '' : '[auto-created session] '; return `${prefix}Linked commit ${commit_sha.substring(0, 7)} to session ${session_id}`; } /** * Recover abandoned/orphaned sessions (Part of CF-572) * Detects sessions that have been active for >2 hours without activity * Marks them as abandoned and recovers any associated temp file notes */ export async function sessionRecoverOrphaned(args: { project?: string }): Promise { const { project } = args; // Find abandoned sessions (active for >2 hours) let whereClause = "WHERE status = 'active' AND started_at < NOW() - INTERVAL '2 hours'"; const params: unknown[] = []; if (project) { whereClause += ` AND project = $${params.length + 1}`; params.push(project); } const orphanedSessions = await query<{ id: string; project: string | null; session_number: number | null; started_at: string; working_directory: string | null; }>( `SELECT id, project, session_number, started_at, working_directory FROM sessions ${whereClause} ORDER BY started_at ASC`, params ); if (orphanedSessions.length === 0) { return 'No orphaned sessions found'; } const results: string[] = []; let totalNotesRecovered = 0; for (const session of orphanedSessions) { try { // Mark session as abandoned await execute( `UPDATE sessions SET status = 'abandoned', ended_at = NOW(), updated_at = NOW() WHERE id = $1`, [session.id] ); results.push( `✓ Session ${session.project} #${session.session_number} marked as abandoned` ); // Attempt to recover transcript first (CF-580) let transcriptRecovered = false; if (session.working_directory) { // Construct projects path: ~/.claude/projects/{encoded-dir}/ // Encoding: / and . → - (Claude Code removes dots from usernames) const home = process.env.HOME || ''; const encodedDir = session.working_directory.replace(/[/\.]/g, '-'); const projectsDir = `${home}/.claude/projects/${encodedDir}`; try { const fs = await import('fs'); const path = await import('path'); if (fs.default.existsSync(projectsDir)) { // Find the most recently modified JSONL file const files = fs.default.readdirSync(projectsDir); const jsonlFiles = files .filter(f => f.endsWith('.jsonl')) .map(f => ({ name: f, path: `${projectsDir}/${f}`, mtime: fs.default.statSync(`${projectsDir}/${f}`).mtimeMs, })) .sort((a, b) => b.mtime - a.mtime); if (jsonlFiles.length > 0) { const latestFile = jsonlFiles[0]; const transcriptContent = fs.default.readFileSync(latestFile.path, 'utf-8'); const lineCount = transcriptContent.split('\n').filter(l => l.trim()).length; // Update session with transcript await execute( `UPDATE sessions SET transcript_jsonl = $1, transcript_ingested_at = NOW(), transcript_file_path = $2, updated_at = NOW() WHERE id = $3`, [transcriptContent, latestFile.path, session.id] ); results.push(` → Recovered transcript (${lineCount} lines)`); transcriptRecovered = true; } } } catch (err) { // Silently skip transcript recovery errors } } // Note: Legacy fallback to notes.md removed (CF-580 Phase 3) // All recovery now uses JSONL transcripts. If recovery failed, log warning. if (!transcriptRecovered) { results.push(` ⚠ No transcript found for recovery`); } } catch (err) { results.push(`✗ Failed to mark session ${session.id} as abandoned: ${err}`); } } return `Recovered ${orphanedSessions.length} orphaned session(s), ${totalNotesRecovered} notes:\n${results.join('\n')}`; } /** * Recover notes from temp files for a specific session * Called after orphan is detected (Part of CF-572) */ export async function sessionRecoverTempNotes(args: { session_id: string; temp_file_path: string }): Promise { const { session_id, temp_file_path } = args; try { // Import notes parser utility const { recoverNotesFromTempFile } = await import('../utils/notes-parser.js'); const recovered = await recoverNotesFromTempFile(session_id, temp_file_path, 'recovered'); if (recovered === 0) { return `No notes to recover from ${temp_file_path}`; } return `Recovered ${recovered} note(s) from temp file for session ${session_id}`; } catch (err) { return `Failed to recover temp notes: ${err}`; } }