diff --git a/src/index.ts b/src/index.ts index 84e504d..d00d929 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,7 @@ import { import { testConnection, close } from './db.js'; import { toolDefinitions } from './tools/index.js'; import { taskAdd, taskList, taskShow, taskClose, taskUpdate, taskInvestigate, taskMoveProject } from './tools/crud.js'; -import { taskSimilar, taskContext } from './tools/search.js'; +import { taskSimilar, taskContext, taskSessionContext } from './tools/search.js'; import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from './tools/relations.js'; import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js'; import { taskDelegations, taskDelegationQuery } from './tools/delegations.js'; @@ -71,6 +71,8 @@ import { sessionContext, buildRecord, sessionCommitLink, + sessionRecoverOrphaned, + sessionRecoverTempNotes, } from './tools/sessions.js'; import { sessionNoteAdd, @@ -175,6 +177,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { limit: a.limit, }); break; + case 'task_session_context': + result = await taskSessionContext({ + id: a.id, + }); + break; // Relations case 'task_link': @@ -529,6 +536,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { a.committed_at ); break; + case 'session_recover_orphaned': + result = await sessionRecoverOrphaned({ + project: a.project, + }); + break; + case 'session_recover_temp_notes': + result = await sessionRecoverTempNotes({ + session_id: a.session_id, + temp_file_path: a.temp_file_path, + }); + break; // Session Documentation case 'session_note_add': diff --git a/src/tools/crud.ts b/src/tools/crud.ts index 5a121cf..c014f77 100644 --- a/src/tools/crud.ts +++ b/src/tools/crud.ts @@ -6,6 +6,7 @@ import type { Task, ChecklistItem, TaskLink } from '../types.js'; import { getRecentDelegations } from './delegations.js'; import { getTaskCommits } from './commits.js'; import { taskLink } from './relations.js'; +import { sessionNoteAdd } from './session-docs.js'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -167,6 +168,25 @@ export async function taskAdd(args: TaskAddArgs): Promise { // Record activity for session tracking await recordActivity(taskId, 'created', undefined, 'open'); + // CF-572 Phase 3: Auto-capture conversation context as session note + // Ensures task context is preserved even if session exits abnormally + if (session_id) { + try { + const contextNote = description + ? `Created task: ${title}\n\nDescription:\n${description}` + : `Created task: ${title}`; + + await sessionNoteAdd({ + session_id, + note_type: 'context', + content: contextNote, + }); + } catch (err) { + // Silently fail context capture - don't block task creation + console.error('Failed to capture task context for session:', err); + } + } + // Enhanced auto-linking logic (CF-166) let autoLinkMessage = ''; try { diff --git a/src/tools/index.ts b/src/tools/index.ts index e284e45..8510982 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -123,6 +123,17 @@ export const toolDefinitions = [ required: ['description'], }, }, + { + name: 'task_session_context', + description: 'Get session context for a task - retrieves notes, decisions, and related tasks from the session where the task was created. Use this to understand the original context and requirements.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID (e.g., CF-570)' }, + }, + required: ['id'], + }, + }, // Relation Tools { @@ -852,6 +863,28 @@ export const toolDefinitions = [ required: ['session_id', 'commit_sha', 'repo'], }, }, + { + name: 'session_recover_orphaned', + description: 'Recover abandoned/orphaned sessions (CF-572). Detects sessions active for >2 hours and marks as abandoned', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Project key to filter by (optional)' }, + }, + }, + }, + { + name: 'session_recover_temp_notes', + description: 'Recover notes from temp files for a specific session (CF-572)', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID to recover notes for' }, + temp_file_path: { type: 'string', description: 'Path to .claude-session/*/notes.md file' }, + }, + required: ['session_id', 'temp_file_path'], + }, + }, // Session Documentation Tools { diff --git a/src/tools/search.ts b/src/tools/search.ts index e360843..c408e6a 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -1,9 +1,141 @@ // Semantic search operations -import { query, getProjectKey } from '../db.js'; +import { query, queryOne, getProjectKey } from '../db.js'; import { getEmbedding, formatEmbedding } from '../embeddings.js'; import type { SimilarTask } from '../types.js'; +interface SessionNote { + note_type: string; + content: string; + created_at: string; +} + +interface SessionTask { + id: string; + title: string; + status: string; + priority: string; +} + +interface SessionCommit { + commit_hash: string; + commit_message: string; +} + +interface TaskSessionContextArgs { + id: string; +} + +/** + * Get session context for a task - retrieves notes, decisions, and related tasks + * from the session where the task was created + */ +export async function taskSessionContext(args: TaskSessionContextArgs): Promise { + const { id } = args; + + // Get task with session info + const task = await queryOne<{ + id: string; + title: string; + description: string; + session_id: string; + }>( + `SELECT t.id, t.title, t.description, t.session_id + FROM tasks t + WHERE t.id = $1`, + [id] + ); + + if (!task) { + return `Task not found: ${id}`; + } + + if (!task.session_id) { + return `# Context for ${id}\n\n**Task:** ${task.title}\n\n⚠️ No session linked to this task. Task was created before session tracking was implemented or via direct database insert.\n\n${task.description ? `**Description:**\n${task.description}` : ''}`; + } + + // Get session info + const session = await queryOne<{ + session_number: number; + summary: string; + started_at: string; + }>( + `SELECT session_number, summary, to_char(started_at, 'YYYY-MM-DD HH24:MI') as started_at + FROM sessions + WHERE id = $1`, + [task.session_id] + ); + + let output = `# Context for ${id}\n\n`; + output += `**Task:** ${task.title}\n`; + + if (session) { + output += `**Created in Session:** #${session.session_number} (${session.started_at})\n`; + + if (session.summary) { + output += `\n## Session Summary\n${session.summary}\n`; + } + } else { + output += `**Session ID:** ${task.session_id} (session record not found)\n`; + } + + if (task.description) { + output += `\n## Task Description\n${task.description}\n`; + } + + // Get session notes + const notes = await query( + `SELECT note_type, content, to_char(created_at, 'HH24:MI') as created_at + FROM session_notes + WHERE session_id = $1 + ORDER BY created_at`, + [task.session_id] + ); + + if (notes.length > 0) { + output += `\n## Session Notes\n`; + for (const note of notes) { + output += `- **[${note.note_type}]** ${note.content}\n`; + } + } + + // Get related tasks from same session + const relatedTasks = await query( + `SELECT id, title, status, priority + FROM tasks + WHERE session_id = $1 AND id != $2 + ORDER BY created_at`, + [task.session_id, id] + ); + + if (relatedTasks.length > 0) { + output += `\n## Other Tasks from Same Session\n`; + for (const t of relatedTasks) { + const statusIcon = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '▶' : '○'; + output += `- ${statusIcon} [${t.priority}] ${t.id}: ${t.title}\n`; + } + } + + // Get commits from session + const commits = await query( + `SELECT DISTINCT commit_hash, commit_message + FROM task_commits + WHERE task_id IN (SELECT id FROM tasks WHERE session_id = $1) + ORDER BY committed_at DESC + LIMIT 10`, + [task.session_id] + ); + + if (commits.length > 0) { + output += `\n## Commits from Session\n`; + for (const c of commits) { + output += `- \`${c.commit_hash}\` ${c.commit_message}\n`; + } + } + + return output; +} + interface TaskSimilarArgs { query: string; project?: string; diff --git a/src/tools/sessions.ts b/src/tools/sessions.ts index 06bfb7b..51442e5 100644 --- a/src/tools/sessions.ts +++ b/src/tools/sessions.ts @@ -435,3 +435,85 @@ export async function sessionCommitLink( 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` + ); + } catch (err) { + results.push(`✗ Failed to mark session ${session.id} as abandoned: ${err}`); + } + } + + return `Recovered ${orphanedSessions.length} orphaned session(s):\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}`; + } +} diff --git a/src/utils/notes-parser.ts b/src/utils/notes-parser.ts new file mode 100644 index 0000000..5fe609e --- /dev/null +++ b/src/utils/notes-parser.ts @@ -0,0 +1,145 @@ +/** + * Notes Parser Utility + * Parses markdown-formatted notes from temp files during orphan recovery + * Supports recovery of session notes lost due to abnormal exit + */ + +import { readFileSync } from 'fs'; +import { getEmbedding, formatEmbedding } from '../embeddings.js'; +import { execute } from '../db.js'; + +export interface ParsedNote { + note_type: 'accomplishment' | 'decision' | 'gotcha' | 'next_steps' | 'context'; + content: string; + created_at?: Date; +} + +/** + * Parse markdown notes from temp file + * Expected format: + * ## accomplishment + * Note content here + * + * ## decision + * Another note + */ +export function parseNotesMarkdown(content: string): ParsedNote[] { + const notes: ParsedNote[] = []; + const validTypes = new Set(['accomplishment', 'decision', 'gotcha', 'next_steps', 'context']); + + // Split by ## headings + const sections = content.split(/^##\s+/m).slice(1); // Skip empty first element + + for (const section of sections) { + const lines = section.split('\n'); + const typeCandidate = lines[0]?.trim().toLowerCase(); + + if (!typeCandidate || !validTypes.has(typeCandidate)) { + continue; // Skip invalid sections + } + + const noteContent = lines.slice(1).join('\n').trim(); + if (!noteContent) { + continue; // Skip empty notes + } + + notes.push({ + note_type: typeCandidate as ParsedNote['note_type'], + content: noteContent, + }); + } + + return notes; +} + +/** + * Recover notes from temp file and insert to database + * @param sessionId - Session ID to recover notes for + * @param tempFilePath - Path to .claude-session/SESSION_ID/notes.md file + * @param source - Source indicator for recovery (always 'recovered' for orphan recovery) + * @returns Number of notes recovered + */ +export async function recoverNotesFromTempFile( + sessionId: string, + tempFilePath: string, + source: 'recovered' = 'recovered' +): Promise { + try { + const content = readFileSync(tempFilePath, 'utf-8'); + + if (!content.trim()) { + return 0; // No notes to recover + } + + const notes = parseNotesMarkdown(content); + + // Batch insert recovered notes + let insertedCount = 0; + + for (const note of notes) { + try { + // Get embedding for semantic search + const embedding = await getEmbedding(note.content); + const embeddingValue = embedding ? formatEmbedding(embedding) : null; + + await execute( + `INSERT INTO session_notes (session_id, note_type, content, embedding, created_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT DO NOTHING`, + [sessionId, note.note_type, note.content, embeddingValue] + ); + + insertedCount++; + } catch (err) { + console.error(`Failed to insert note of type ${note.note_type}:`, err); + // Continue with other notes on error + } + } + + return insertedCount; + } catch (err) { + console.error(`Failed to recover notes from ${tempFilePath}:`, err); + return 0; + } +} + +/** + * Find temp files for orphaned sessions + * Checks .claude-session directories for recovery candidates + */ +export async function findOrphanedSessionTempFiles(projectDir: string): Promise { + const fs = require('fs').promises; + const path = require('path'); + + const claudeSessionDir = path.join(projectDir, '.claude-session'); + const tempFiles: string[] = []; + + try { + const entries = await fs.readdir(claudeSessionDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const notesPath = path.join(claudeSessionDir, entry.name, 'notes.md'); + try { + await fs.access(notesPath); + tempFiles.push(notesPath); + } catch { + // File doesn't exist, skip + } + } + } + } catch (err) { + // Directory doesn't exist, return empty + } + + return tempFiles; +} + +/** + * Extract session ID from temp file path + * Path format: /path/to/project/.claude-session/session_YYYYMMDDHHMM_/notes.md + */ +export function extractSessionIdFromTempPath(tempFilePath: string): string | null { + const match = tempFilePath.match(/\.claude-session\/([^/]+)\/notes\.md$/); + return match ? match[1] : null; +}