feat: Add session memory system with pgvector
- Add session_memories table for persistent learnings - Add memory_add, memory_search, memory_list, memory_context tools - Supports semantic search via embeddings - Categories: pattern, fix, preference, gotcha, architecture Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
15
README.md
15
README.md
@@ -4,12 +4,13 @@ Exposes task management tools via Model Context Protocol. Uses PostgreSQL with p
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- SSH tunnel to docker-host: `ssh -L 5435:localhost:5435 docker-host -N &`
|
- SSH tunnel to docker-host: `ssh -L 5432:localhost:5432 docker-host -N &`
|
||||||
- PostgreSQL with pgvector on docker-host (litellm-pgvector container)
|
- 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
|
## Configuration
|
||||||
|
|
||||||
Add to `~/.claude/mcp_servers.json`:
|
Add to `~/.claude/settings.json` under `mcpServers`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -18,10 +19,10 @@ Add to `~/.claude/mcp_servers.json`:
|
|||||||
"args": ["/path/to/task-mcp/dist/index.js"],
|
"args": ["/path/to/task-mcp/dist/index.js"],
|
||||||
"env": {
|
"env": {
|
||||||
"DB_HOST": "localhost",
|
"DB_HOST": "localhost",
|
||||||
"DB_PORT": "5435",
|
"DB_PORT": "5432",
|
||||||
"DB_NAME": "litellm",
|
"DB_NAME": "agiliton",
|
||||||
"DB_USER": "litellm",
|
"DB_USER": "agiliton",
|
||||||
"DB_PASSWORD": "litellm",
|
"DB_PASSWORD": "<from /opt/docker/ci/.env>",
|
||||||
"LLM_API_URL": "https://llm.agiliton.cloud",
|
"LLM_API_URL": "https://llm.agiliton.cloud",
|
||||||
"LLM_API_KEY": "sk-master-..."
|
"LLM_API_KEY": "sk-master-..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
// Configuration from environment variables
|
// Configuration - FORCE port 5432 (not 5435 which is litellm)
|
||||||
const config = {
|
const config = {
|
||||||
host: process.env.DB_HOST || 'localhost',
|
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',
|
database: process.env.DB_NAME || 'agiliton',
|
||||||
user: process.env.DB_USER || 'agiliton',
|
user: process.env.DB_USER || 'agiliton',
|
||||||
password: process.env.DB_PASSWORD || 'QtqiwCOAUpQNF6pjzOMAREzUny2bY8V1',
|
password: process.env.DB_PASSWORD || 'QtqiwCOAUpQNF6pjzOMAREzUny2bY8V1',
|
||||||
|
|||||||
30
src/index.ts
30
src/index.ts
@@ -36,6 +36,7 @@ import {
|
|||||||
impactLearn,
|
impactLearn,
|
||||||
componentGraph,
|
componentGraph,
|
||||||
} from './tools/impact.js';
|
} from './tools/impact.js';
|
||||||
|
import { memoryAdd, memorySearch, memoryList, memoryContext } from './tools/memories.js';
|
||||||
|
|
||||||
// Create MCP server
|
// Create MCP server
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
@@ -320,6 +321,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
result = JSON.stringify(await componentGraph(a.component_id), null, 2);
|
result = JSON.stringify(await componentGraph(a.component_id), null, 2);
|
||||||
break;
|
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:
|
default:
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
259
src/tools/memories.ts
Normal file
259
src/tools/memories.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<Memory & { similarity: number }>(
|
||||||
|
`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<string> {
|
||||||
|
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<Memory>(
|
||||||
|
`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<string> {
|
||||||
|
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<string> {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Get project-specific memories
|
||||||
|
if (project) {
|
||||||
|
const projectMemories = await query<Memory>(
|
||||||
|
`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<Memory>(
|
||||||
|
`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<Memory>(
|
||||||
|
`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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user