commit a03e9e065a763c51f08cb66d80fa3adf15898670 Author: Christian Gick Date: Thu Jan 8 20:58:14 2026 +0200 feat: Add task-mcp server for task management via MCP Implements 10 MCP tools for task management: - CRUD: task_add, task_list, task_show, task_close, task_update - Search: task_similar (pgvector), task_context - Relations: task_link, task_checklist_add, task_checklist_toggle Uses PostgreSQL with pgvector for semantic search via LiteLLM embeddings. Connects via SSH tunnel to docker-host:5435. Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..08a7fb2 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Task MCP Server + +Exposes task management tools via Model Context Protocol. Uses PostgreSQL with pgvector for semantic search. + +## Requirements + +- SSH tunnel to docker-host: `ssh -L 5435:localhost:5435 docker-host -N &` +- PostgreSQL with pgvector on docker-host (litellm-pgvector container) + +## Configuration + +Add to `~/.claude/mcp_servers.json`: + +```json +{ + "task-mcp": { + "command": "node", + "args": ["/path/to/task-mcp/dist/index.js"], + "env": { + "DB_HOST": "localhost", + "DB_PORT": "5435", + "DB_NAME": "litellm", + "DB_USER": "litellm", + "DB_PASSWORD": "litellm", + "LLM_API_URL": "https://llm.agiliton.cloud", + "LLM_API_KEY": "sk-master-..." + } + } +} +``` + +## Tools + +| Tool | Description | +|------|-------------| +| `task_add` | Create task with auto-generated ID and embedding | +| `task_list` | List tasks with filters (project, status, type, priority) | +| `task_show` | Show task details including checklist and dependencies | +| `task_close` | Mark task as completed | +| `task_update` | Update task fields | +| `task_similar` | Find semantically similar tasks using pgvector | +| `task_context` | Get related tasks for current work context | +| `task_link` | Create dependency between tasks | +| `task_checklist_add` | Add checklist item to task | +| `task_checklist_toggle` | Toggle checklist item | + +## Build + +```bash +npm install +npm run build +``` + +## Development + +```bash +npm run dev # Run with tsx (no build) +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..6b45c96 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "task-mcp", + "version": "1.0.0", + "description": "MCP server for task management with PostgreSQL/pgvector backend", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "pg": "^8.11.3" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/pg": "^8.10.9", + "typescript": "^5.3.3", + "tsx": "^4.7.0" + } +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..8674bdc --- /dev/null +++ b/src/db.ts @@ -0,0 +1,120 @@ +import pg from 'pg'; +const { Pool } = pg; + +// Configuration from environment variables +const config = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME || 'litellm', + user: process.env.DB_USER || 'litellm', + password: process.env.DB_PASSWORD || 'litellm', + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}; + +// Create connection pool +const pool = new Pool(config); + +// Log connection errors +pool.on('error', (err) => { + console.error('Unexpected database error:', err); +}); + +/** + * Execute a query and return all rows + */ +export async function query>( + text: string, + params?: unknown[] +): Promise { + const result = await pool.query(text, params); + return result.rows as T[]; +} + +/** + * Execute a query and return the first row + */ +export async function queryOne>( + text: string, + params?: unknown[] +): Promise { + const result = await pool.query(text, params); + return (result.rows[0] as T) || null; +} + +/** + * Execute a query without returning results (INSERT, UPDATE, DELETE) + */ +export async function execute( + text: string, + params?: unknown[] +): Promise { + const result = await pool.query(text, params); + return result.rowCount || 0; +} + +/** + * Get the next task ID for a project + */ +export async function getNextTaskId(projectKey: string): Promise { + const result = await queryOne<{ next_id: number }>( + `INSERT INTO task_sequences (project, next_id) VALUES ($1, 1) + ON CONFLICT (project) DO UPDATE SET next_id = task_sequences.next_id + 1 + RETURNING next_id`, + [projectKey] + ); + return `${projectKey}-${result?.next_id || 1}`; +} + +/** + * Get project key from name, or generate one + */ +export async function getProjectKey(projectName: string): Promise { + // First check if already registered + const existing = await queryOne<{ key: string }>( + `SELECT key FROM projects WHERE name = $1 LIMIT 1`, + [projectName] + ); + + if (existing) { + return existing.key; + } + + // Generate a key from the name (uppercase first letters) + let generated = projectName.replace(/[a-z]/g, '').slice(0, 4); + if (!generated) { + generated = projectName.slice(0, 3).toUpperCase(); + } + + // Register the new project + await execute( + `INSERT INTO projects (key, name) VALUES ($1, $2) + ON CONFLICT (key) DO NOTHING`, + [generated, projectName] + ); + + return generated; +} + +/** + * Test database connection + */ +export async function testConnection(): Promise { + try { + await query('SELECT 1'); + return true; + } catch (error) { + console.error('Database connection failed:', error); + return false; + } +} + +/** + * Close the connection pool + */ +export async function close(): Promise { + await pool.end(); +} + +export default pool; diff --git a/src/embeddings.ts b/src/embeddings.ts new file mode 100644 index 0000000..bb0d3ec --- /dev/null +++ b/src/embeddings.ts @@ -0,0 +1,58 @@ +// Embeddings via LiteLLM API + +const LLM_API_URL = process.env.LLM_API_URL || 'https://llm.agiliton.cloud'; +const LLM_API_KEY = process.env.LLM_API_KEY || ''; + +interface EmbeddingResponse { + data: Array<{ + embedding: number[]; + index: number; + }>; + model: string; + usage: { + prompt_tokens: number; + total_tokens: number; + }; +} + +/** + * Generate embedding for text using LiteLLM API + */ +export async function getEmbedding(text: string): Promise { + if (!LLM_API_KEY) { + console.error('LLM_API_KEY not set, skipping embedding'); + return null; + } + + try { + const response = await fetch(`${LLM_API_URL}/v1/embeddings`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${LLM_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'text-embedding-ada-002', + input: text, + }), + }); + + if (!response.ok) { + console.error('Embedding API error:', response.status, await response.text()); + return null; + } + + const data = await response.json() as EmbeddingResponse; + return data.data?.[0]?.embedding || null; + } catch (error) { + console.error('Embedding generation failed:', error); + return null; + } +} + +/** + * Format embedding array for PostgreSQL vector type + */ +export function formatEmbedding(embedding: number[]): string { + return `[${embedding.join(',')}]`; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ed7e1a0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * Task MCP Server + * + * Exposes task management tools via Model Context Protocol. + * Uses PostgreSQL with pgvector for semantic search. + * + * Requires SSH tunnel to docker-host: + * ssh -L 5432:172.27.0.3:5432 docker-host -N & + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import { testConnection, close } from './db.js'; +import { toolDefinitions } from './tools/index.js'; +import { taskAdd, taskList, taskShow, taskClose, taskUpdate } from './tools/crud.js'; +import { taskSimilar, taskContext } from './tools/search.js'; +import { taskLink, checklistAdd, checklistToggle } from './tools/relations.js'; + +// Create MCP server +const server = new Server( + { name: 'task-mcp', version: '1.0.0' }, + { capabilities: { tools: {} } } +); + +// Register tool list handler +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: toolDefinitions, +})); + +// Register tool call handler +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + let result: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const a = args as any; + + switch (name) { + // CRUD + case 'task_add': + result = await taskAdd({ + title: a.title, + project: a.project, + type: a.type, + priority: a.priority, + description: a.description, + }); + break; + case 'task_list': + result = await taskList({ + project: a.project, + status: a.status, + type: a.type, + priority: a.priority, + limit: a.limit, + }); + break; + case 'task_show': + result = await taskShow(a.id); + break; + case 'task_close': + result = await taskClose(a.id); + break; + case 'task_update': + result = await taskUpdate({ + id: a.id, + status: a.status, + priority: a.priority, + type: a.type, + title: a.title, + }); + break; + + // Search + case 'task_similar': + result = await taskSimilar({ + query: a.query, + project: a.project, + limit: a.limit, + }); + break; + case 'task_context': + result = await taskContext({ + description: a.description, + project: a.project, + limit: a.limit, + }); + break; + + // Relations + case 'task_link': + result = await taskLink({ + from_id: a.from_id, + to_id: a.to_id, + link_type: a.link_type, + }); + break; + case 'task_checklist_add': + result = await checklistAdd({ + task_id: a.task_id, + item: a.item, + }); + break; + case 'task_checklist_toggle': + result = await checklistToggle({ + item_id: a.item_id, + checked: a.checked, + }); + break; + + default: + throw new Error(`Unknown tool: ${name}`); + } + + return { + content: [{ type: 'text', text: result }], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true, + }; + } +}); + +// Main entry point +async function main() { + // Test database connection + const connected = await testConnection(); + if (!connected) { + console.error('Failed to connect to database. Ensure SSH tunnel is active:'); + console.error(' ssh -L 5432:172.27.0.3:5432 docker-host -N &'); + process.exit(1); + } + + console.error('task-mcp: Connected to database'); + + // Set up cleanup + process.on('SIGINT', async () => { + await close(); + process.exit(0); + }); + + process.on('SIGTERM', async () => { + await close(); + process.exit(0); + }); + + // Start server + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('task-mcp: Server started'); +} + +main().catch((error) => { + console.error('task-mcp: Fatal error:', error); + process.exit(1); +}); diff --git a/src/tools/crud.ts b/src/tools/crud.ts new file mode 100644 index 0000000..39d1057 --- /dev/null +++ b/src/tools/crud.ts @@ -0,0 +1,266 @@ +// CRUD operations for tasks + +import { query, queryOne, execute, getNextTaskId, getProjectKey } from '../db.js'; +import { getEmbedding, formatEmbedding } from '../embeddings.js'; +import type { Task, ChecklistItem, TaskLink } from '../types.js'; + +interface TaskAddArgs { + title: string; + project?: string; + type?: string; + priority?: string; + description?: string; +} + +interface TaskListArgs { + project?: string; + status?: string; + type?: string; + priority?: string; + limit?: number; +} + +interface TaskUpdateArgs { + id: string; + status?: string; + priority?: string; + type?: string; + title?: string; +} + +/** + * Create a new task + */ +export async function taskAdd(args: TaskAddArgs): Promise { + const { title, project = 'Unknown', type = 'task', priority = 'P2', description = '' } = args; + + // Get project key + const projectKey = await getProjectKey(project); + + // Get next task ID + const taskId = await getNextTaskId(projectKey); + + // Generate embedding + const embedText = description ? `${title}. ${description}` : title; + const embedding = await getEmbedding(embedText); + const embeddingValue = embedding ? formatEmbedding(embedding) : null; + + // Insert task + if (embeddingValue) { + await execute( + `INSERT INTO tasks (id, project, title, description, type, status, priority, embedding) + VALUES ($1, $2, $3, $4, $5, 'open', $6, $7)`, + [taskId, projectKey, title, description, type, priority, embeddingValue] + ); + } else { + await execute( + `INSERT INTO tasks (id, project, title, description, type, status, priority) + VALUES ($1, $2, $3, $4, $5, 'open', $6)`, + [taskId, projectKey, title, description, type, priority] + ); + } + + return `Created: ${taskId}\n Title: ${title}\n Type: ${type}\n Priority: ${priority}\n Project: ${projectKey}${embedding ? '\n (embedded for semantic search)' : ''}`; +} + +/** + * List tasks with filters + */ +export async function taskList(args: TaskListArgs): Promise { + const { project, status, type, priority, limit = 20 } = args; + + let whereClause = 'WHERE 1=1'; + const params: unknown[] = []; + let paramIndex = 1; + + if (project) { + const projectKey = await getProjectKey(project); + whereClause += ` AND project = $${paramIndex++}`; + params.push(projectKey); + } + if (status) { + whereClause += ` AND status = $${paramIndex++}`; + params.push(status); + } + if (type) { + whereClause += ` AND type = $${paramIndex++}`; + params.push(type); + } + if (priority) { + whereClause += ` AND priority = $${paramIndex++}`; + params.push(priority); + } + + params.push(limit); + + const tasks = await query( + `SELECT id, title, type, status, priority, project + FROM tasks + ${whereClause} + ORDER BY + CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END, + created_at DESC + LIMIT $${paramIndex}`, + params + ); + + if (tasks.length === 0) { + return `No tasks found${project ? ` for project ${project}` : ''}`; + } + + const lines = tasks.map(t => { + const statusIcon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[>]' : t.status === 'blocked' ? '[!]' : '[ ]'; + const typeLabel = t.type !== 'task' ? ` [${t.type}]` : ''; + return `${statusIcon} ${t.priority} ${t.id}: ${t.title}${typeLabel}`; + }); + + return `Tasks${project ? ` (${project})` : ''}:\n\n${lines.join('\n')}`; +} + +/** + * Show task details + */ +export async function taskShow(id: string): Promise { + const task = await queryOne( + `SELECT id, project, title, description, type, status, priority, + to_char(created_at, 'YYYY-MM-DD HH24:MI') as created, + to_char(updated_at, 'YYYY-MM-DD HH24:MI') as updated, + to_char(completed_at, 'YYYY-MM-DD HH24:MI') as completed + FROM tasks WHERE id = $1`, + [id] + ); + + if (!task) { + return `Task not found: ${id}`; + } + + let output = `# ${task.id}\n\n`; + output += `**Title:** ${task.title}\n`; + output += `**Project:** ${task.project}\n`; + output += `**Type:** ${task.type}\n`; + output += `**Status:** ${task.status}\n`; + output += `**Priority:** ${task.priority}\n`; + output += `**Created:** ${(task as unknown as { created: string }).created}\n`; + output += `**Updated:** ${(task as unknown as { updated: string }).updated}\n`; + + if ((task as unknown as { completed: string }).completed) { + output += `**Completed:** ${(task as unknown as { completed: string }).completed}\n`; + } + + if (task.description) { + output += `\n**Description:**\n${task.description}\n`; + } + + // Get checklist + const checklist = await query( + `SELECT id, item, checked FROM task_checklist + WHERE task_id = $1 ORDER BY position, id`, + [id] + ); + + if (checklist.length > 0) { + const done = checklist.filter(c => c.checked).length; + output += `\n**Checklist:** (${done}/${checklist.length})\n`; + for (const item of checklist) { + output += ` ${item.checked ? '[x]' : '[ ]'} ${item.item} (#${item.id})\n`; + } + } + + // Get dependencies + const blockedBy = await query<{ id: string; title: string }>( + `SELECT t.id, t.title FROM task_links l + JOIN tasks t ON t.id = l.from_task_id + WHERE l.to_task_id = $1 AND l.link_type = 'blocks'`, + [id] + ); + + const blocks = await query<{ id: string; title: string }>( + `SELECT t.id, t.title FROM task_links l + JOIN tasks t ON t.id = l.to_task_id + WHERE l.from_task_id = $1 AND l.link_type = 'blocks'`, + [id] + ); + + if (blockedBy.length > 0) { + output += `\n**Blocked by:**\n`; + for (const t of blockedBy) { + output += ` - ${t.id}: ${t.title}\n`; + } + } + + if (blocks.length > 0) { + output += `\n**Blocks:**\n`; + for (const t of blocks) { + output += ` - ${t.id}: ${t.title}\n`; + } + } + + return output; +} + +/** + * Close a task + */ +export async function taskClose(id: string): Promise { + const result = await execute( + `UPDATE tasks + SET status = 'completed', completed_at = NOW(), updated_at = NOW() + WHERE id = $1`, + [id] + ); + + if (result === 0) { + return `Task not found: ${id}`; + } + + return `Closed: ${id}`; +} + +/** + * Update a task + */ +export async function taskUpdate(args: TaskUpdateArgs): Promise { + const { id, status, priority, type, title } = args; + + const updates: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (status) { + updates.push(`status = $${paramIndex++}`); + params.push(status); + if (status === 'completed') { + updates.push(`completed_at = NOW()`); + } + } + if (priority) { + updates.push(`priority = $${paramIndex++}`); + params.push(priority); + } + if (type) { + updates.push(`type = $${paramIndex++}`); + params.push(type); + } + if (title) { + updates.push(`title = $${paramIndex++}`); + params.push(title); + } + + if (updates.length === 0) { + return 'No updates specified'; + } + + updates.push('updated_at = NOW()'); + params.push(id); + + const result = await execute( + `UPDATE tasks SET ${updates.join(', ')} WHERE id = $${paramIndex}`, + params + ); + + if (result === 0) { + return `Task not found: ${id}`; + } + + return `Updated: ${id}`; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..00c0be6 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,138 @@ +// Tool definitions for task-mcp + +export const toolDefinitions = [ + // CRUD Tools + { + name: 'task_add', + description: 'Create a new task with auto-generated ID and semantic embedding', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Task title (required)' }, + project: { type: 'string', description: 'Project key (e.g., ST, VPN). Auto-detected from CWD if not provided.' }, + type: { type: 'string', enum: ['task', 'bug', 'feature', 'debt'], description: 'Task type (default: task)' }, + priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'], description: 'Priority level (default: P2)' }, + description: { type: 'string', description: 'Optional description' }, + }, + required: ['title'], + }, + }, + { + name: 'task_list', + description: 'List tasks with optional filters', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Filter by project key' }, + status: { type: 'string', enum: ['open', 'in_progress', 'blocked', 'completed'], description: 'Filter by status' }, + type: { type: 'string', enum: ['task', 'bug', 'feature', 'debt'], description: 'Filter by type' }, + priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'], description: 'Filter by priority' }, + limit: { type: 'number', description: 'Max results (default: 20)' }, + }, + }, + }, + { + name: 'task_show', + description: 'Show task details including checklist and dependencies', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID (e.g., ST-1, VPN-45)' }, + }, + required: ['id'], + }, + }, + { + name: 'task_close', + description: 'Mark a task as completed', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID to close' }, + }, + required: ['id'], + }, + }, + { + name: 'task_update', + description: 'Update task fields (status, priority, type, title)', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID to update' }, + status: { type: 'string', enum: ['open', 'in_progress', 'blocked', 'completed'], description: 'New status' }, + priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'], description: 'New priority' }, + type: { type: 'string', enum: ['task', 'bug', 'feature', 'debt'], description: 'New type' }, + title: { type: 'string', description: 'New title' }, + }, + required: ['id'], + }, + }, + + // Semantic Search Tools + { + name: 'task_similar', + description: 'Find semantically similar tasks using pgvector', + 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: 'task_context', + description: 'Get related tasks for current work context (useful for delegations)', + inputSchema: { + type: 'object', + properties: { + description: { type: 'string', description: 'Description of current work' }, + project: { type: 'string', description: 'Current project' }, + limit: { type: 'number', description: 'Max related tasks (default: 3)' }, + }, + required: ['description'], + }, + }, + + // Relation Tools + { + name: 'task_link', + description: 'Create dependency between tasks', + inputSchema: { + type: 'object', + properties: { + from_id: { type: 'string', description: 'Source task ID' }, + to_id: { type: 'string', description: 'Target task ID' }, + link_type: { type: 'string', enum: ['blocks', 'relates_to', 'duplicates'], description: 'Relationship type' }, + }, + required: ['from_id', 'to_id', 'link_type'], + }, + }, + { + name: 'task_checklist_add', + description: 'Add a checklist item to a task', + inputSchema: { + type: 'object', + properties: { + task_id: { type: 'string', description: 'Task ID' }, + item: { type: 'string', description: 'Checklist item text' }, + }, + required: ['task_id', 'item'], + }, + }, + { + name: 'task_checklist_toggle', + description: 'Toggle a checklist item (check/uncheck)', + inputSchema: { + type: 'object', + properties: { + item_id: { type: 'number', description: 'Checklist item ID' }, + checked: { type: 'boolean', description: 'New checked state' }, + }, + required: ['item_id', 'checked'], + }, + }, +]; diff --git a/src/tools/relations.ts b/src/tools/relations.ts new file mode 100644 index 0000000..9fcb957 --- /dev/null +++ b/src/tools/relations.ts @@ -0,0 +1,81 @@ +// Task relations: dependencies and checklists + +import { query, queryOne, execute } from '../db.js'; + +interface TaskLinkArgs { + from_id: string; + to_id: string; + link_type: string; +} + +interface ChecklistAddArgs { + task_id: string; + item: string; +} + +interface ChecklistToggleArgs { + item_id: number; + checked: boolean; +} + +/** + * Create a dependency between tasks + */ +export async function taskLink(args: TaskLinkArgs): Promise { + const { from_id, to_id, link_type } = args; + + try { + await execute( + `INSERT INTO task_links (from_task_id, to_task_id, link_type) + VALUES ($1, $2, $3) + ON CONFLICT (from_task_id, to_task_id, link_type) DO NOTHING`, + [from_id, to_id, link_type] + ); + + return `Linked: ${from_id} ${link_type} ${to_id}`; + } catch (error) { + return `Error creating link: ${error}`; + } +} + +/** + * Add a checklist item to a task + */ +export async function checklistAdd(args: ChecklistAddArgs): Promise { + const { task_id, item } = args; + + // Get next position + const result = await queryOne<{ max: number }>( + `SELECT COALESCE(MAX(position), 0) + 1 as max + FROM task_checklist WHERE task_id = $1`, + [task_id] + ); + + const position = result?.max || 1; + + await execute( + `INSERT INTO task_checklist (task_id, item, position) + VALUES ($1, $2, $3)`, + [task_id, item, position] + ); + + return `Added to ${task_id}: ${item}`; +} + +/** + * Toggle a checklist item + */ +export async function checklistToggle(args: ChecklistToggleArgs): Promise { + const { item_id, checked } = args; + + const result = await execute( + `UPDATE task_checklist SET checked = $1 WHERE id = $2`, + [checked, item_id] + ); + + if (result === 0) { + return `Checklist item not found: ${item_id}`; + } + + return `${checked ? 'Checked' : 'Unchecked'}: item #${item_id}`; +} diff --git a/src/tools/search.ts b/src/tools/search.ts new file mode 100644 index 0000000..e360843 --- /dev/null +++ b/src/tools/search.ts @@ -0,0 +1,113 @@ +// Semantic search operations + +import { query, getProjectKey } from '../db.js'; +import { getEmbedding, formatEmbedding } from '../embeddings.js'; +import type { SimilarTask } from '../types.js'; + +interface TaskSimilarArgs { + query: string; + project?: string; + limit?: number; +} + +interface TaskContextArgs { + description: string; + project?: string; + limit?: number; +} + +/** + * Find semantically similar tasks using pgvector + */ +export async function taskSimilar(args: TaskSimilarArgs): Promise { + const { query: searchQuery, project, limit = 5 } = args; + + // Generate embedding for the query + const embedding = await getEmbedding(searchQuery); + if (!embedding) { + return 'Error: Could not generate embedding for search query'; + } + + const embeddingStr = formatEmbedding(embedding); + + let whereClause = 'WHERE embedding IS NOT NULL'; + const params: unknown[] = [embeddingStr, limit]; + let paramIndex = 3; + + if (project) { + const projectKey = await getProjectKey(project); + whereClause += ` AND project = $${paramIndex}`; + params.push(projectKey); + } + + const results = await query( + `SELECT id, title, type, status, priority, + 1 - (embedding <=> $1) as similarity + FROM tasks + ${whereClause} + ORDER BY embedding <=> $1 + LIMIT $2`, + params + ); + + if (results.length === 0) { + return 'No similar tasks found'; + } + + const lines = results.map(t => { + const pct = Math.round(t.similarity * 100); + const statusIcon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[>]' : '[ ]'; + return `${statusIcon} ${pct}% ${t.id}: ${t.title} [${t.type}] [${t.priority}]`; + }); + + return `Similar tasks for "${searchQuery}":\n\n${lines.join('\n')}`; +} + +/** + * Get related tasks for current work context + * Returns markdown suitable for injection into delegations + */ +export async function taskContext(args: TaskContextArgs): Promise { + const { description, project, limit = 3 } = args; + + // Generate embedding for the description + const embedding = await getEmbedding(description); + if (!embedding) { + return ''; + } + + const embeddingStr = formatEmbedding(embedding); + + let whereClause = 'WHERE embedding IS NOT NULL AND status != \'completed\''; + const params: unknown[] = [embeddingStr, limit]; + let paramIndex = 3; + + if (project) { + const projectKey = await getProjectKey(project); + whereClause += ` AND project = $${paramIndex}`; + params.push(projectKey); + } + + const results = await query( + `SELECT id, title, type, status, priority, + 1 - (embedding <=> $1) as similarity + FROM tasks + ${whereClause} + ORDER BY embedding <=> $1 + LIMIT $2`, + params + ); + + if (results.length === 0) { + return ''; + } + + // Format as markdown for delegation context + let output = '## Related Tasks\n\n'; + for (const t of results) { + const pct = Math.round(t.similarity * 100); + output += `- **${t.id}**: ${t.title} (${pct}% match, ${t.priority}, ${t.status})\n`; + } + + return output; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a08b926 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,71 @@ +// Task Management Types + +export interface Task { + id: string; + project: string; + title: string; + description?: string; + type: 'task' | 'bug' | 'feature' | 'debt'; + status: 'open' | 'in_progress' | 'blocked' | 'completed'; + priority: 'P0' | 'P1' | 'P2' | 'P3'; + version_id?: string; + epic_id?: string; + created_at: Date; + updated_at: Date; + completed_at?: Date; +} + +export interface Project { + key: string; + name: string; + path?: string; + active: boolean; + created_at: Date; +} + +export interface ChecklistItem { + id: number; + task_id: string; + item: string; + checked: boolean; + position: number; + created_at: Date; +} + +export interface TaskLink { + id: number; + from_task_id: string; + to_task_id: string; + link_type: 'blocks' | 'relates_to' | 'duplicates'; + created_at: Date; +} + +export interface Version { + id: string; + project: string; + version: string; + build_number?: number; + status: 'planned' | 'in_progress' | 'released' | 'archived'; + release_date?: Date; + release_notes?: string; + created_at: Date; +} + +export interface Epic { + id: string; + project: string; + title: string; + description?: string; + status: 'open' | 'in_progress' | 'completed'; + target_version_id?: string; + created_at: Date; +} + +export interface SimilarTask { + id: string; + title: string; + type: string; + status: string; + priority: string; + similarity: number; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ae3fd93 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}