diff --git a/README.md b/README.md index 08a7fb2..27bcb58 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ Exposes task management tools via Model Context Protocol. Uses PostgreSQL with p ## Requirements -- SSH tunnel to docker-host: `ssh -L 5435:localhost:5435 docker-host -N &` -- PostgreSQL with pgvector on docker-host (litellm-pgvector container) +- 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"` ## Configuration -Add to `~/.claude/mcp_servers.json`: +Add to `~/.claude/settings.json` under `mcpServers`: ```json { @@ -18,10 +19,10 @@ Add to `~/.claude/mcp_servers.json`: "args": ["/path/to/task-mcp/dist/index.js"], "env": { "DB_HOST": "localhost", - "DB_PORT": "5435", - "DB_NAME": "litellm", - "DB_USER": "litellm", - "DB_PASSWORD": "litellm", + "DB_PORT": "5432", + "DB_NAME": "agiliton", + "DB_USER": "agiliton", + "DB_PASSWORD": "", "LLM_API_URL": "https://llm.agiliton.cloud", "LLM_API_KEY": "sk-master-..." } diff --git a/src/db.ts b/src/db.ts index 888f81a..d65f0fc 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,10 +1,10 @@ import pg from 'pg'; const { Pool } = pg; -// Configuration from environment variables +// Configuration - FORCE port 5432 (not 5435 which is litellm) const config = { host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432'), + port: 5432, // Hardcoded - env was being overridden to 5435 by unknown source database: process.env.DB_NAME || 'agiliton', user: process.env.DB_USER || 'agiliton', password: process.env.DB_PASSWORD || 'QtqiwCOAUpQNF6pjzOMAREzUny2bY8V1', diff --git a/src/index.ts b/src/index.ts index e9732d9..75d8277 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ import { impactLearn, componentGraph, } from './tools/impact.js'; +import { memoryAdd, memorySearch, memoryList, memoryContext } from './tools/memories.js'; // Create MCP server const server = new Server( @@ -320,6 +321,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { result = JSON.stringify(await componentGraph(a.component_id), null, 2); break; + // Memories + case 'memory_add': + result = await memoryAdd({ + category: a.category, + title: a.title, + content: a.content, + context: a.context, + project: a.project, + }); + break; + case 'memory_search': + result = await memorySearch({ + query: a.query, + project: a.project, + category: a.category, + limit: a.limit, + }); + break; + case 'memory_list': + result = await memoryList({ + project: a.project, + category: a.category, + limit: a.limit, + }); + break; + case 'memory_context': + result = await memoryContext(a.project, a.task_description); + break; + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/tools/index.ts b/src/tools/index.ts index 8009b85..7908856 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -546,4 +546,58 @@ export const toolDefinitions = [ }, }, }, + + // Memory Tools + { + name: 'memory_add', + description: 'Store a learning/memory for future sessions. Use at session end to persist insights.', + inputSchema: { + type: 'object', + properties: { + category: { type: 'string', enum: ['pattern', 'fix', 'preference', 'gotcha', 'architecture'], description: 'Memory category' }, + title: { type: 'string', description: 'Short title for the memory' }, + 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)' }, + }, + required: ['category', 'title', 'content'], + }, + }, + { + name: 'memory_search', + description: 'Search memories semantically. Returns relevant learnings for current context.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + project: { type: 'string', description: 'Filter by project (optional)' }, + category: { type: 'string', enum: ['pattern', 'fix', 'preference', 'gotcha', 'architecture'], description: 'Filter by category (optional)' }, + limit: { type: 'number', description: 'Max results (default: 5)' }, + }, + required: ['query'], + }, + }, + { + name: 'memory_list', + description: 'List stored memories (non-semantic)', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Filter by project (optional)' }, + category: { type: 'string', enum: ['pattern', 'fix', 'preference', 'gotcha', 'architecture'], description: 'Filter by category (optional)' }, + limit: { type: 'number', description: 'Max results (default: 20)' }, + }, + }, + }, + { + name: 'memory_context', + description: 'Get memories relevant to current session context. Use at session start.', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Current project' }, + task_description: { type: 'string', description: 'Description of planned work (for semantic matching)' }, + }, + }, + }, ]; diff --git a/src/tools/memories.ts b/src/tools/memories.ts new file mode 100644 index 0000000..336c069 --- /dev/null +++ b/src/tools/memories.ts @@ -0,0 +1,259 @@ +// Session memory operations for persistent learnings + +import { query, queryOne, execute } from '../db.js'; +import { getEmbedding, formatEmbedding } from '../embeddings.js'; + +type MemoryCategory = 'pattern' | 'fix' | 'preference' | 'gotcha' | 'architecture'; + +interface Memory { + id: number; + category: MemoryCategory; + title: string; + content: string; + context: string | null; + project: string | null; + source_session: string | null; + times_surfaced: number; + created_at: string; +} + +interface MemoryAddArgs { + category: MemoryCategory; + title: string; + content: string; + context?: string; + project?: string; + source_session?: string; +} + +interface MemorySearchArgs { + query: string; + project?: string; + category?: MemoryCategory; + limit?: number; +} + +interface MemoryListArgs { + project?: string; + category?: MemoryCategory; + limit?: number; +} + +/** + * Add a new memory/learning + */ +export async function memoryAdd(args: MemoryAddArgs): Promise { + const { category, title, content, context, project, source_session } = args; + + // Generate embedding for semantic search + const embedText = `${title}. ${content}`; + const embedding = await getEmbedding(embedText); + const embeddingValue = embedding ? formatEmbedding(embedding) : null; + + 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] + ); + } 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] + ); + } + + return `Stored memory: [${category}] ${title}`; +} + +/** + * Search memories semantically + */ +export async function memorySearch(args: MemorySearchArgs): Promise { + const { query: searchQuery, project, category, 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]; + let paramIndex = 3; + + if (project) { + whereClause += ` AND (project = $${paramIndex++} OR project IS NULL)`; + params.splice(params.length - 1, 0, project); + } + if (category) { + whereClause += ` AND category = $${paramIndex++}`; + params.splice(params.length - 1, 0, category); + } + + const memories = await query( + `SELECT id, category, title, content, context, project, times_surfaced, + to_char(created_at, 'YYYY-MM-DD') as created_at, + 1 - (embedding <=> $1) as similarity + FROM session_memories + ${whereClause} + ORDER BY embedding <=> $1 + LIMIT $2`, + params + ); + + if (memories.length === 0) { + return 'No relevant memories found'; + } + + // Update times_surfaced 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)`, + [ids] + ); + + const lines = ['Relevant memories:\n']; + for (const m of memories) { + const sim = Math.round(m.similarity * 100); + const proj = m.project ? ` [${m.project}]` : ''; + lines.push(`**[${m.category}]${proj}** ${m.title} (${sim}% match)`); + lines.push(` ${m.content}`); + if (m.context) { + lines.push(` _Context: ${m.context}_`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * List memories (non-semantic) + */ +export async function memoryList(args: MemoryListArgs): Promise { + const { project, category, limit = 20 } = args; + + let whereClause = 'WHERE 1=1'; + const params: unknown[] = []; + let paramIndex = 1; + + if (project) { + whereClause += ` AND (project = $${paramIndex++} OR project IS NULL)`; + params.push(project); + } + if (category) { + whereClause += ` AND category = $${paramIndex++}`; + params.push(category); + } + + params.push(limit); + + const memories = await query( + `SELECT id, category, title, content, context, project, times_surfaced, + to_char(created_at, 'YYYY-MM-DD') as created_at + FROM session_memories + ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex}`, + params + ); + + if (memories.length === 0) { + return `No memories found${project ? ` for project ${project}` : ''}`; + } + + 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}`); + lines.push(` ${m.content.slice(0, 100)}${m.content.length > 100 ? '...' : ''}`); + } + + return lines.join('\n'); +} + +/** + * Delete a memory by ID + */ +export async function memoryDelete(id: number): Promise { + const result = await execute('DELETE FROM session_memories WHERE id = $1', [id]); + + if (result === 0) { + return `Memory not found: ${id}`; + } + + return `Deleted memory: ${id}`; +} + +/** + * Get memories relevant to current context (for session start) + */ +export async function memoryContext(project: string | null, taskDescription?: string): Promise { + const lines: string[] = []; + + // Get project-specific memories + if (project) { + const projectMemories = await query( + `SELECT category, title, content FROM session_memories + WHERE project = $1 + ORDER BY times_surfaced DESC, created_at DESC + LIMIT 5`, + [project] + ); + + if (projectMemories.length > 0) { + lines.push(`**${project} Memories:**`); + for (const m of projectMemories) { + lines.push(`• [${m.category}] ${m.title}: ${m.content}`); + } + lines.push(''); + } + } + + // If task description provided, do semantic search + if (taskDescription) { + const embedding = await getEmbedding(taskDescription); + if (embedding) { + const relevant = await query( + `SELECT category, title, content, project + FROM session_memories + WHERE embedding IS NOT NULL + ORDER BY embedding <=> $1 + LIMIT 3`, + [formatEmbedding(embedding)] + ); + + if (relevant.length > 0) { + lines.push('**Relevant memories for this task:**'); + for (const m of relevant) { + const proj = m.project ? `[${m.project}] ` : ''; + lines.push(`• ${proj}${m.title}: ${m.content}`); + } + } + } + } + + // Get recent gotchas (always useful) + const gotchas = await query( + `SELECT title, content FROM session_memories + WHERE category = 'gotcha' + ORDER BY created_at DESC + LIMIT 3`, + [] + ); + + if (gotchas.length > 0) { + lines.push('\n**Recent gotchas:**'); + for (const g of gotchas) { + lines.push(`⚠️ ${g.title}: ${g.content}`); + } + } + + return lines.length > 0 ? lines.join('\n') : 'No memories to surface'; +}