UNPKG

lynkr

Version:

Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.

494 lines (454 loc) 12.5 kB
const store = require("./store"); const search = require("./search"); const retriever = require("./retriever"); const logger = require("../logger"); // ============================================================================ // VALIDATION CONSTANTS // ============================================================================ const VALID_TYPES = ['fact', 'preference', 'decision', 'entity', 'relationship']; const VALID_CATEGORIES = ['code', 'user', 'project', 'general']; const MAX_QUERY_LENGTH = 1000; const MAX_CONTENT_LENGTH = 5000; // ============================================================================ // MEMORY TOOLS (UPDATED WITH VALIDATION) // ============================================================================ /** * Tool: memory_search * Search long-term memories for relevant facts * * UPDATED: Added input validation to prevent FTS5 errors and injection */ async function memory_search(args, context = {}) { const { query, limit = 10, type, category } = args; // ✅ Validate query exists and is string if (!query || typeof query !== 'string') { return { ok: false, content: JSON.stringify({ error: 'Query parameter is required and must be a string' }), }; } // ✅ Validate query length if (query.length > MAX_QUERY_LENGTH) { return { ok: false, content: JSON.stringify({ error: `Query too long (max ${MAX_QUERY_LENGTH} characters)`, provided: query.length }), }; } // ✅ Validate type if provided if (type && !VALID_TYPES.includes(type)) { return { ok: false, content: JSON.stringify({ error: `Invalid type. Must be one of: ${VALID_TYPES.join(', ')}`, provided: type }), }; } // ✅ Validate category if provided if (category && !VALID_CATEGORIES.includes(category)) { return { ok: false, content: JSON.stringify({ error: `Invalid category. Must be one of: ${VALID_CATEGORIES.join(', ')}`, provided: category }), }; } // ✅ Validate limit if (typeof limit !== 'number' || limit < 1 || limit > 100) { return { ok: false, content: JSON.stringify({ error: 'Limit must be a number between 1 and 100', provided: limit }), }; } try { const results = search.searchMemories({ query, // Will be sanitized by prepareFTS5Query in search.js limit, types: type ? [type] : undefined, categories: category ? [category] : undefined, sessionId: context.session?.id, }); const formatted = results.map((mem, idx) => ({ index: idx + 1, type: mem.type, content: mem.content, importance: mem.importance, age: retriever.formatAge(Date.now() - mem.createdAt), category: mem.category, })); return { ok: true, content: JSON.stringify({ query, resultCount: results.length, memories: formatted, }, null, 2), metadata: { resultCount: results.length }, }; } catch (err) { logger.error({ err, query: query.substring(0, 100) }, 'Memory search failed'); return { ok: false, content: JSON.stringify({ error: 'Memory search failed', message: err.message }), }; } } /** * Tool: memory_add * Manually add a fact to long-term memory * * UPDATED: Enhanced validation */ async function memory_add(args, context = {}) { const { content, type = 'fact', category = 'general', importance = 0.5, } = args; // ✅ Validate content if (!content || typeof content !== 'string') { return { ok: false, content: JSON.stringify({ error: 'Content parameter is required and must be a string' }), }; } // ✅ Validate content length if (content.length > MAX_CONTENT_LENGTH) { return { ok: false, content: JSON.stringify({ error: `Content too long (max ${MAX_CONTENT_LENGTH} characters)`, provided: content.length }), }; } if (content.length < 10) { return { ok: false, content: JSON.stringify({ error: 'Content too short (min 10 characters)', provided: content.length }), }; } // ✅ Validate type if (!VALID_TYPES.includes(type)) { return { ok: false, content: JSON.stringify({ error: `Invalid type. Must be one of: ${VALID_TYPES.join(', ')}`, provided: type }), }; } // ✅ Validate category if (!VALID_CATEGORIES.includes(category)) { return { ok: false, content: JSON.stringify({ error: `Invalid category. Must be one of: ${VALID_CATEGORIES.join(', ')}`, provided: category }), }; } // ✅ Validate importance if (typeof importance !== 'number' || importance < 0 || importance > 1) { return { ok: false, content: JSON.stringify({ error: 'Importance must be a number between 0 and 1', provided: importance }), }; } try { const memory = store.createMemory({ content, type, category, sessionId: context.session?.id, importance, surpriseScore: 0.5, // Manual additions get moderate surprise metadata: { manual: true, addedBy: 'user', timestamp: Date.now(), }, }); return { ok: true, content: JSON.stringify({ message: 'Memory stored successfully', memoryId: memory.id, memory: { id: memory.id, type: memory.type, content: memory.content, importance: memory.importance, category: memory.category, }, }, null, 2), metadata: { memoryId: memory.id }, }; } catch (err) { logger.error({ err, content: content.substring(0, 100) }, 'Memory add failed'); return { ok: false, content: JSON.stringify({ error: 'Failed to add memory', message: err.message }), }; } } /** * Tool: memory_forget * Remove memories matching a query * * UPDATED: Enhanced validation */ async function memory_forget(args, context = {}) { const { query, confirm = false } = args; // ✅ Validate query if (!query || typeof query !== 'string') { return { ok: false, content: JSON.stringify({ error: 'Query parameter is required and must be a string' }), }; } // ✅ Validate query length if (query.length > MAX_QUERY_LENGTH) { return { ok: false, content: JSON.stringify({ error: `Query too long (max ${MAX_QUERY_LENGTH} characters)`, provided: query.length }), }; } // ✅ Validate confirm is boolean if (typeof confirm !== 'boolean') { return { ok: false, content: JSON.stringify({ error: 'Confirm parameter must be a boolean', provided: typeof confirm }), }; } try { // Search for matching memories const matches = search.searchMemories({ query, limit: 50, // Check up to 50 matches sessionId: context.session?.id, }); if (matches.length === 0) { return { ok: true, content: JSON.stringify({ message: 'No memories found matching the query', query, }), }; } if (!confirm) { const preview = matches.slice(0, 5).map((mem, idx) => ({ index: idx + 1, type: mem.type, content: mem.content.substring(0, 100) + (mem.content.length > 100 ? '...' : ''), age: retriever.formatAge(Date.now() - mem.createdAt), })); return { ok: false, content: JSON.stringify({ message: 'Found memories matching query. Set confirm=true to delete them.', query, matchCount: matches.length, preview, warning: 'This action cannot be undone', }, null, 2), metadata: { requiresConfirmation: true, matchCount: matches.length }, }; } // Delete all matching memories let deletedCount = 0; for (const memory of matches) { const deleted = store.deleteMemory(memory.id); if (deleted) deletedCount++; } return { ok: true, content: JSON.stringify({ message: `Deleted ${deletedCount} memories`, query, deletedCount, }, null, 2), metadata: { deletedCount }, }; } catch (err) { logger.error({ err, query: query.substring(0, 100) }, 'Memory forget failed'); return { ok: false, content: JSON.stringify({ error: 'Failed to delete memories', message: err.message }), }; } } /** * Tool: memory_stats * Get statistics about stored memories */ async function memory_stats(args, context = {}) { try { const stats = retriever.getMemoryStats({ sessionId: context.session?.id }); if (!stats) { return { ok: false, content: JSON.stringify({ error: 'Failed to retrieve memory statistics' }), }; } return { ok: true, content: JSON.stringify({ total: stats.total, byType: stats.byType, byCategory: stats.byCategory, avgImportance: stats.avgImportance?.toFixed(2), recentCount: stats.recentCount, importantCount: stats.importantCount, sessionId: stats.sessionId || 'global', }, null, 2), }; } catch (err) { logger.error({ err }, 'Memory stats failed'); return { ok: false, content: JSON.stringify({ error: 'Failed to get statistics', message: err.message }), }; } } // ============================================================================ // TOOL DEFINITIONS (UPDATED) // ============================================================================ const MEMORY_TOOLS = { memory_search: { name: 'memory_search', description: 'Search long-term memories for relevant facts and information from previous conversations', input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Search query to find relevant memories (max 1000 characters)', }, limit: { type: 'integer', description: 'Maximum number of results to return (default: 10, max: 100)', minimum: 1, maximum: 100, }, type: { type: 'string', description: 'Filter by memory type', enum: VALID_TYPES, }, category: { type: 'string', description: 'Filter by category', enum: VALID_CATEGORIES, }, }, required: ['query'], }, handler: memory_search, }, memory_add: { name: 'memory_add', description: 'Manually add a fact or piece of information to long-term memory', input_schema: { type: 'object', properties: { content: { type: 'string', description: 'The fact or information to remember (10-5000 characters)', }, type: { type: 'string', description: 'Type of memory', enum: VALID_TYPES, }, category: { type: 'string', description: 'Category', enum: VALID_CATEGORIES, }, importance: { type: 'number', description: 'Importance score between 0 and 1 (default: 0.5)', minimum: 0, maximum: 1, }, }, required: ['content'], }, handler: memory_add, }, memory_forget: { name: 'memory_forget', description: 'Remove memories matching a search query', input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Query to match memories to delete (max 1000 characters)', }, confirm: { type: 'boolean', description: 'Set to true to confirm deletion (required for safety)', }, }, required: ['query'], }, handler: memory_forget, }, memory_stats: { name: 'memory_stats', description: 'Get statistics about stored memories', input_schema: { type: 'object', properties: {}, }, handler: memory_stats, }, }; module.exports = { memory_search, memory_add, memory_forget, memory_stats, MEMORY_TOOLS, };