diff --git a/README.md b/README.md index 27bcb58..f16d0ea 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Exposes task management tools via Model Context Protocol. Uses PostgreSQL with p ## Requirements -- SSH tunnel to docker-host: `ssh -L 5432:localhost:5432 docker-host -N &` -- PostgreSQL with pgvector on docker-host (CI stack `postgres` container) -- CI postgres must be running: `ssh docker-host "cd /opt/docker/ci && docker compose up -d postgres"` +- SSH tunnel to services: `ssh -L 5432:localhost:5432 services -N &` +- PostgreSQL with pgvector on services (CI stack `postgres` container) +- CI postgres must be running: `ssh services "cd /opt/docker/ci && docker compose up -d postgres"` ## Configuration diff --git a/src/index.ts b/src/index.ts index b27cc46..51a3326 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,16 @@ import { componentGraph, } from './tools/impact.js'; import { memoryAdd, memorySearch, memoryList, memoryContext } from './tools/memories.js'; +import { + sessionStart, + sessionUpdate, + sessionEnd, + sessionList, + sessionSearch, + sessionContext, + buildRecord, + sessionCommitLink, +} from './tools/sessions.js'; // Create MCP server const server = new Server( @@ -329,6 +339,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: a.content, context: a.context, project: a.project, + session_id: a.session_id, + task_id: a.task_id, }); break; case 'memory_search': @@ -350,6 +362,69 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { result = await memoryContext(a.project, a.task_description); break; + // Sessions + case 'session_start': + result = await sessionStart({ + session_id: a.session_id, + project: a.project, + working_directory: a.working_directory, + git_branch: a.git_branch, + initial_prompt: a.initial_prompt, + }); + break; + case 'session_update': + result = await sessionUpdate({ + session_id: a.session_id, + message_count: a.message_count, + token_count: a.token_count, + tools_used: a.tools_used, + }); + break; + case 'session_end': + result = await sessionEnd({ + session_id: a.session_id, + summary: a.summary, + status: a.status, + }); + break; + case 'session_list': + result = await sessionList({ + project: a.project, + status: a.status, + since: a.since, + limit: a.limit, + }); + break; + case 'session_search': + result = await sessionSearch({ + query: a.query, + project: a.project, + limit: a.limit, + }); + break; + case 'session_context': + result = await sessionContext(a.session_id); + break; + case 'build_record': + result = await buildRecord( + a.session_id, + a.version_id, + a.build_number, + a.git_commit_sha, + a.status, + a.started_at + ); + break; + case 'session_commit_link': + result = await sessionCommitLink( + a.session_id, + a.commit_sha, + a.repo, + a.commit_message, + a.committed_at + ); + break; + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/tools/index.ts b/src/tools/index.ts index 7908856..191adaf 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -559,6 +559,8 @@ export const toolDefinitions = [ content: { type: 'string', description: 'The learning/insight to remember' }, context: { type: 'string', description: 'When/where this applies (optional)' }, project: { type: 'string', description: 'Project this relates to (optional)' }, + session_id: { type: 'string', description: 'Session ID to link memory to (optional)' }, + task_id: { type: 'string', description: 'Task ID to link memory to (optional)' }, }, required: ['category', 'title', 'content'], }, @@ -600,4 +602,120 @@ export const toolDefinitions = [ }, }, }, + + // Session Management Tools + { + name: 'session_start', + description: 'Start a new session with metadata tracking', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID (auto-generated if not provided)' }, + project: { type: 'string', description: 'Project key (e.g., CF, VPN)' }, + working_directory: { type: 'string', description: 'Current working directory' }, + git_branch: { type: 'string', description: 'Current git branch' }, + initial_prompt: { type: 'string', description: 'First user message' }, + }, + required: ['project'], + }, + }, + { + name: 'session_update', + description: 'Update session metrics (message count, tokens, tools used)', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID to update' }, + message_count: { type: 'number', description: 'Number of messages exchanged' }, + token_count: { type: 'number', description: 'Total tokens used' }, + tools_used: { + type: 'array', + items: { type: 'string' }, + description: 'Array of tool names used', + }, + }, + required: ['session_id'], + }, + }, + { + name: 'session_end', + description: 'End session and generate summary with embedding', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID to end' }, + summary: { type: 'string', description: 'Session summary text' }, + status: { type: 'string', enum: ['completed', 'interrupted'], description: 'Session completion status (default: completed)' }, + }, + required: ['session_id', 'summary'], + }, + }, + { + name: 'session_list', + description: 'List sessions with filtering and pagination', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Filter by project key' }, + status: { type: 'string', enum: ['active', 'completed', 'interrupted'], description: 'Filter by status' }, + since: { type: 'string', description: 'Show sessions since date (ISO format)' }, + limit: { type: 'number', description: 'Max results (default: 20)' }, + }, + }, + }, + { + name: 'session_search', + description: 'Find similar sessions using vector search', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + project: { type: 'string', description: 'Filter by project (optional)' }, + limit: { type: 'number', description: 'Max results (default: 5)' }, + }, + required: ['query'], + }, + }, + { + name: 'session_context', + description: 'Get complete context: tasks, commits, builds, memories', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID to get context for' }, + }, + required: ['session_id'], + }, + }, + { + name: 'build_record', + description: 'Record build information linked to session and version', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID (optional)' }, + version_id: { type: 'string', description: 'Version ID being built' }, + build_number: { type: 'number', description: 'Build number' }, + git_commit_sha: { type: 'string', description: 'Git commit SHA' }, + status: { type: 'string', description: 'Build status (pending, running, success, failed)' }, + started_at: { type: 'string', description: 'Build start timestamp (ISO format)' }, + }, + required: ['version_id', 'build_number', 'status', 'started_at'], + }, + }, + { + name: 'session_commit_link', + description: 'Link a commit to a session (automatically called when commits are made)', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID' }, + commit_sha: { type: 'string', description: 'Git commit SHA' }, + repo: { type: 'string', description: 'Repository (e.g., christian/ClaudeFramework)' }, + commit_message: { type: 'string', description: 'Commit message (optional)' }, + committed_at: { type: 'string', description: 'Commit timestamp (ISO format, optional)' }, + }, + required: ['session_id', 'commit_sha', 'repo'], + }, + }, ]; diff --git a/src/tools/memories.ts b/src/tools/memories.ts index 336c069..84930d0 100644 --- a/src/tools/memories.ts +++ b/src/tools/memories.ts @@ -12,8 +12,9 @@ interface Memory { content: string; context: string | null; project: string | null; - source_session: string | null; - times_surfaced: number; + session_id: string | null; + task_id: string | null; + access_count: number; created_at: string; } @@ -23,7 +24,8 @@ interface MemoryAddArgs { content: string; context?: string; project?: string; - source_session?: string; + session_id?: string; + task_id?: string; } interface MemorySearchArgs { @@ -40,10 +42,10 @@ interface MemoryListArgs { } /** - * Add a new memory/learning + * Add a new memory/learning (enhanced with session_id and task_id) */ export async function memoryAdd(args: MemoryAddArgs): Promise { - const { category, title, content, context, project, source_session } = args; + const { category, title, content, context, project, session_id, task_id } = args; // Generate embedding for semantic search const embedText = `${title}. ${content}`; @@ -52,15 +54,15 @@ export async function memoryAdd(args: MemoryAddArgs): Promise { if (embeddingValue) { await execute( - `INSERT INTO session_memories (category, title, content, context, project, source_session, embedding) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [category, title, content, context || null, project || null, source_session || null, embeddingValue] + `INSERT INTO memories (category, title, content, context, project, session_id, task_id, embedding) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [category, title, content, context || null, project || null, session_id || null, task_id || null, embeddingValue] ); } else { await execute( - `INSERT INTO session_memories (category, title, content, context, project, source_session) - VALUES ($1, $2, $3, $4, $5, $6)`, - [category, title, content, context || null, project || null, source_session || null] + `INSERT INTO memories (category, title, content, context, project, session_id, task_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [category, title, content, context || null, project || null, session_id || null, task_id || null] ); } @@ -96,10 +98,10 @@ export async function memorySearch(args: MemorySearchArgs): Promise { } const memories = await query( - `SELECT id, category, title, content, context, project, times_surfaced, + `SELECT id, category, title, content, context, project, access_count, to_char(created_at, 'YYYY-MM-DD') as created_at, 1 - (embedding <=> $1) as similarity - FROM session_memories + FROM memories ${whereClause} ORDER BY embedding <=> $1 LIMIT $2`, @@ -110,10 +112,10 @@ export async function memorySearch(args: MemorySearchArgs): Promise { return 'No relevant memories found'; } - // Update times_surfaced for returned memories + // Update access_count for returned memories const ids = memories.map(m => m.id); await execute( - `UPDATE session_memories SET times_surfaced = times_surfaced + 1, last_surfaced = NOW() WHERE id = ANY($1)`, + `UPDATE memories SET access_count = access_count + 1, last_accessed_at = NOW() WHERE id = ANY($1)`, [ids] ); @@ -154,9 +156,9 @@ export async function memoryList(args: MemoryListArgs): Promise { params.push(limit); const memories = await query( - `SELECT id, category, title, content, context, project, times_surfaced, + `SELECT id, category, title, content, context, project, access_count, to_char(created_at, 'YYYY-MM-DD') as created_at - FROM session_memories + FROM memories ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex}`, @@ -170,8 +172,8 @@ export async function memoryList(args: MemoryListArgs): Promise { const lines = [`Memories${project ? ` (${project})` : ''}:\n`]; for (const m of memories) { const proj = m.project ? `[${m.project}] ` : ''; - const surfaced = m.times_surfaced > 0 ? ` (shown ${m.times_surfaced}x)` : ''; - lines.push(`• [${m.category}] ${proj}${m.title}${surfaced}`); + const accessed = m.access_count > 0 ? ` (accessed ${m.access_count}x)` : ''; + lines.push(`• [${m.category}] ${proj}${m.title}${accessed}`); lines.push(` ${m.content.slice(0, 100)}${m.content.length > 100 ? '...' : ''}`); } @@ -182,7 +184,7 @@ export async function memoryList(args: MemoryListArgs): Promise { * Delete a memory by ID */ export async function memoryDelete(id: number): Promise { - const result = await execute('DELETE FROM session_memories WHERE id = $1', [id]); + const result = await execute('DELETE FROM memories WHERE id = $1', [id]); if (result === 0) { return `Memory not found: ${id}`; @@ -200,9 +202,9 @@ export async function memoryContext(project: string | null, taskDescription?: st // Get project-specific memories if (project) { const projectMemories = await query( - `SELECT category, title, content FROM session_memories + `SELECT category, title, content FROM memories WHERE project = $1 - ORDER BY times_surfaced DESC, created_at DESC + ORDER BY access_count DESC, created_at DESC LIMIT 5`, [project] ); @@ -222,7 +224,7 @@ export async function memoryContext(project: string | null, taskDescription?: st if (embedding) { const relevant = await query( `SELECT category, title, content, project - FROM session_memories + FROM memories WHERE embedding IS NOT NULL ORDER BY embedding <=> $1 LIMIT 3`, @@ -241,7 +243,7 @@ export async function memoryContext(project: string | null, taskDescription?: st // Get recent gotchas (always useful) const gotchas = await query( - `SELECT title, content FROM session_memories + `SELECT title, content FROM memories WHERE category = 'gotcha' ORDER BY created_at DESC LIMIT 3`, diff --git a/src/tools/sessions.ts b/src/tools/sessions.ts new file mode 100644 index 0000000..7b4cc14 --- /dev/null +++ b/src/tools/sessions.ts @@ -0,0 +1,418 @@ +// Session management operations for database-driven session tracking + +import { query, queryOne, execute } from '../db.js'; +import { getEmbedding, formatEmbedding } from '../embeddings.js'; + +interface SessionStartArgs { + session_id?: string; + project: string; + working_directory?: string; + git_branch?: string; + initial_prompt?: 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; + created_at: string; +} + +/** + * Start a new session with metadata tracking + * Returns session_id and session_number + */ +export async function sessionStart(args: SessionStartArgs): Promise { + const { session_id, project, working_directory, git_branch, initial_prompt } = 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, status) + VALUES ($1, $2, NOW(), $3, $4, $5, 'active')`, + [id, project, working_directory || null, git_branch || null, initial_prompt || 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; + + return `Session started: ${id} (${project} #${session_number})`; +} + +/** + * 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 + */ +export async function sessionEnd(args: SessionEndArgs): Promise { + const { session_id, summary, status = 'completed' } = args; + + // Generate embedding for semantic search + const embedding = await getEmbedding(summary); + const embeddingValue = embedding ? formatEmbedding(embedding) : null; + + if (embeddingValue) { + await execute( + `UPDATE sessions + SET ended_at = NOW(), + summary = $1, + embedding = $2, + status = $3, + updated_at = NOW() + WHERE id = $4`, + [summary, embeddingValue, status, session_id] + ); + } else { + await execute( + `UPDATE sessions + SET ended_at = NOW(), + summary = $1, + status = $2, + updated_at = NOW() + WHERE id = $3`, + [summary, status, session_id] + ); + } + + // Get session details + const session = await queryOne( + `SELECT id, project, session_number, duration_minutes + FROM sessions WHERE id = $1`, + [session_id] + ); + + if (!session) { + return `Session ended: ${session_id}`; + } + + return `Session ended: ${session.project} #${session.session_number} (${session.duration_minutes || 0}m)`; +} + +/** + * 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 { + 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] + ); + + return `Linked commit ${commit_sha.substring(0, 7)} to session ${session_id}`; +}