From 4776a72f1cf233278e771012817c8ffa41d4106f Mon Sep 17 00:00:00 2001 From: Colin Hill Date: Wed, 21 Jan 2026 14:27:12 -0500 Subject: [PATCH 1/3] Migrated to deployment using node v24 LTS Updated better-sqlite3 to v12.x.x requiring more stringent special character checks to meet the new stricter fts5 query formatting requirements. - src/memory/search.js and src/memory/tools.js were both refactored to enhance robustness and achive the stringency requirements. --- package-lock.json | 36 +++-- package.json | 2 +- src/memory/search.js | 364 ++++++++++++++++++++++++++++--------------- src/memory/tools.js | 206 ++++++++++++++++++++---- 4 files changed, 437 insertions(+), 171 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4fe532f..3a053ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "lynkr", - "version": "4.2.0", + "version": "4.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lynkr", - "version": "4.2.0", + "version": "4.2.1", "license": "Apache-2.0", "dependencies": { "@azure/openai": "^2.0.0", - "better-sqlite3": "^9.4.0", + "better-sqlite3": "^12.6.2", "compression": "^1.7.4", "diff": "^5.2.0", "dotenv": "^16.4.5", @@ -341,6 +341,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -467,14 +468,17 @@ "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", - "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, "node_modules/binary-extensions": { @@ -948,9 +952,9 @@ } }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -1075,6 +1079,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -1260,6 +1265,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2619,9 +2625,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -3343,9 +3349,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz", - "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index 630baea..ec5fe0e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "@azure/openai": "^2.0.0", - "better-sqlite3": "^9.4.0", + "better-sqlite3": "^12.6.2", "compression": "^1.7.4", "diff": "^5.2.0", "dotenv": "^16.4.5", diff --git a/src/memory/search.js b/src/memory/search.js index 8d40bd9..4e65f77 100644 --- a/src/memory/search.js +++ b/src/memory/search.js @@ -2,6 +2,152 @@ const db = require("../db"); const logger = require("../logger"); const store = require("./store"); +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const MAX_QUERY_LENGTH = 1000; +const MAX_OR_TERMS = 50; + +// ============================================================================ +// KEYWORD SANITIZATION (NEW - Critical for FTS5 Safety) +// ============================================================================ + +/** + * Sanitize a single keyword for use in FTS5 queries + * Removes all FTS5 special characters that can cause syntax errors + * + * CRITICAL: FTS5 has many special characters that cause errors: + * - Dash (-) is interpreted as column filter + * - @ is invalid bareword character + * - Parentheses, brackets, quotes can break query syntax + * - Commas and periods can cause issues in newer SQLite versions + */ +function sanitizeKeyword(keyword) { + if (!keyword || typeof keyword !== 'string') { + return ''; + } + + // Remove ALL FTS5 special characters + const sanitized = keyword + .replace(/[*()<>\-:\[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + return sanitized; +} + +/** + * Sanitize array of keywords + * Returns only keywords that are 3+ characters after sanitization + */ +function sanitizeKeywords(keywords) { + if (!Array.isArray(keywords)) { + return []; + } + + return keywords + .map(sanitizeKeyword) + .filter(k => k.length >= 3); +} + +// ============================================================================ +// FTS5 QUERY PREPARATION (UPDATED - Hardened) +// ============================================================================ + +/** + * Prepare FTS5 query - handle special characters and phrases + * + * CRITICAL FIX for better-sqlite3 v12+ (SQLite 3.46+): + * - Commas and periods inside quoted strings cause "fts5: syntax error near ," + * - Solution: Extract keywords and search for them individually with OR + * - This is more robust than attempting to quote complex phrases + */ +function prepareFTS5Query(query) { + let cleaned = query.trim(); + + // Length validation + if (cleaned.length > MAX_QUERY_LENGTH) { + logger.warn({ + queryLength: cleaned.length, + truncatedTo: MAX_QUERY_LENGTH + }, 'Query truncated due to excessive length'); + cleaned = cleaned.substring(0, MAX_QUERY_LENGTH); + } + + if (!cleaned) { + return '"empty query"'; // Safe fallback + } + + // Step 1: Remove XML/HTML tags (common in error messages and code) + cleaned = cleaned.replace(/<[^>]+>/g, ' '); + cleaned = cleaned.replace(/\s+/g, ' ').trim(); + + if (!cleaned) { + return '"empty query"'; + } + + // Step 2: Check for FTS5 operators (AND, OR, NOT) + // If present, user is doing advanced search - preserve operators + const hasFTS5Operators = /\b(AND|OR|NOT)\b/.test(cleaned); + + // Step 3: Remove ALL FTS5 special characters and punctuation + // CRITICAL: These can cause syntax errors even in quoted strings: + // - Commas (,) and periods (.) → "syntax error near ," + // - Dashes (-) → interpreted as column filter + // - @ symbol → "syntax error near @" + // - Quotes (") → can break string quoting + // - Parentheses, brackets → break grouping syntax + cleaned = cleaned.replace(/[*()<>\-:\[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' '); + cleaned = cleaned.replace(/\s+/g, ' ').trim(); + + if (!cleaned) { + return '"empty query"'; + } + + // Step 4: If has operators, return as-is for advanced search + if (hasFTS5Operators) { + // Advanced users can use AND/OR/NOT + // Characters are already sanitized above + return cleaned; + } + + // Step 5: Extract keywords (min 3 chars, max 50 words to prevent DoS) + const words = cleaned + .split(/\s+/) + .filter(w => w.length >= 3) + .slice(0, MAX_OR_TERMS); + + if (words.length === 0) { + // No valid keywords - try with shorter words + const anyWords = cleaned + .split(/\s+/) + .filter(w => w.length > 0) + .slice(0, 10); + + if (anyWords.length === 0) { + return '"empty query"'; + } + + // Quote each word individually + return anyWords.map(w => `"${w}"`).join(' OR '); + } + + // Step 6: Single word - simple quote + if (words.length === 1) { + return `"${words[0]}"`; + } + + // Step 7: Multiple words - create OR query of individual quoted words + // This is the safest approach that avoids all FTS5 syntax errors + // Example: "word1" OR "word2" OR "word3" + return words.map(word => `"${word}"`).join(' OR '); +} + +// ============================================================================ +// SEARCH FUNCTIONS (UPDATED) +// ============================================================================ + /** * Search memories using FTS5 full-text search */ @@ -9,9 +155,9 @@ function searchMemories(options) { const { query, limit = 10, - types = null, // Filter by memory types - categories = null, // Filter by categories - sessionId = null, // Filter by session + types = null, + categories = null, + sessionId = null, minImportance = null, } = options; @@ -20,52 +166,69 @@ function searchMemories(options) { return []; } - // Build FTS5 query - escape special characters - const ftsQuery = prepareFTS5Query(query); - - // Build SQL with filters - let sql = ` - SELECT m.id, m.session_id, m.content, m.type, m.category, - m.importance, m.surprise_score, m.access_count, m.decay_factor, - m.source_turn_id, m.created_at, m.updated_at, m.last_accessed_at, m.metadata, - fts.rank - FROM memories_fts fts - JOIN memories m ON m.id = fts.rowid - WHERE memories_fts MATCH ? - `; - - const params = [ftsQuery]; - - // Add filters - if (sessionId) { - sql += ` AND (m.session_id = ? OR m.session_id IS NULL)`; - params.push(sessionId); - } + try { + // Build FTS5 query - now hardened against syntax errors + const ftsQuery = prepareFTS5Query(query); + + logger.debug({ + originalQuery: query.substring(0, 100), + ftsQuery: ftsQuery.substring(0, 100) + }, 'FTS5 query prepared'); + + // Build SQL with filters + let sql = ` + SELECT m.id, m.session_id, m.content, m.type, m.category, + m.importance, m.surprise_score, m.access_count, m.decay_factor, + m.source_turn_id, m.created_at, m.updated_at, m.last_accessed_at, m.metadata, + memories_fts.rank + FROM memories_fts + JOIN memories m ON m.id = memories_fts.rowid + WHERE memories_fts MATCH ? + `; + + const params = [ftsQuery]; + + // Add filters + if (sessionId) { + sql += ` AND (m.session_id = ? OR m.session_id IS NULL)`; + params.push(sessionId); + } - if (types && Array.isArray(types) && types.length > 0) { - const placeholders = types.map(() => "?").join(","); - sql += ` AND m.type IN (${placeholders})`; - params.push(...types); - } + if (types && Array.isArray(types) && types.length > 0) { + const placeholders = types.map(() => "?").join(","); + sql += ` AND m.type IN (${placeholders})`; + params.push(...types); + } - if (categories && Array.isArray(categories) && categories.length > 0) { - const placeholders = categories.map(() => "?").join(","); - sql += ` AND m.category IN (${placeholders})`; - params.push(...categories); - } + if (categories && Array.isArray(categories) && categories.length > 0) { + const placeholders = categories.map(() => "?").join(","); + sql += ` AND m.category IN (${placeholders})`; + params.push(...categories); + } - if (minImportance !== null && typeof minImportance === "number") { - sql += ` AND m.importance >= ?`; - params.push(minImportance); - } + if (minImportance !== null && typeof minImportance === "number") { + sql += ` AND m.importance >= ?`; + params.push(minImportance); + } - // Order by FTS5 rank and importance - sql += ` ORDER BY fts.rank, m.importance DESC LIMIT ?`; - params.push(limit); + // Order by FTS5 rank and importance + sql += ` ORDER BY memories_fts.rank, m.importance DESC LIMIT ?`; + params.push(limit); - try { + const startTime = Date.now(); const stmt = db.prepare(sql); const rows = stmt.all(...params); + const duration = Date.now() - startTime; + + // Log slow queries for monitoring + if (duration > 100) { + logger.warn({ + query: query.substring(0, 50), + ftsQuery: ftsQuery.substring(0, 50), + duration, + resultCount: rows.length + }, 'Slow FTS5 query detected'); + } return rows.map((row) => ({ id: row.id, @@ -82,78 +245,42 @@ function searchMemories(options) { updatedAt: row.updated_at, lastAccessedAt: row.last_accessed_at ?? null, metadata: row.metadata ? JSON.parse(row.metadata) : {}, - rank: row.rank, // FTS5 relevance score + rank: row.rank, })); } catch (err) { - logger.error({ err, query: ftsQuery }, "FTS5 search failed"); + logger.error({ + err, + query: query.substring(0, 100), + sqliteCode: err.code, + message: err.message + }, "FTS5 search failed"); return []; } } /** - * Prepare FTS5 query - handle special characters and phrases - */ -function prepareFTS5Query(query) { - // FTS5 special characters: " * ( ) < > - : AND OR NOT - // Strategy: Strip XML/HTML tags, then sanitize remaining text - let cleaned = query.trim(); - - // Step 1: Remove XML/HTML tags (common in error messages) - // Matches: , , - cleaned = cleaned.replace(/<[^>]+>/g, ' '); - - // Step 2: Remove excess whitespace from tag removal - cleaned = cleaned.replace(/\s+/g, ' ').trim(); - - if (!cleaned) { - // Query was all tags, return safe fallback - return '"empty query"'; - } - - // Step 3: Check if query contains FTS5 operators (AND, OR, NOT) - const hasFTS5Operators = /\b(AND|OR|NOT)\b/i.test(cleaned); - - // Step 4: Remove or escape remaining FTS5 special characters - // Characters: * ( ) < > - : [ ] - // Strategy: Remove them since they're rarely useful in memory search - cleaned = cleaned.replace(/[*()<>\-:\[\]]/g, ' '); - cleaned = cleaned.replace(/\s+/g, ' ').trim(); - - // Step 5: Escape double quotes (FTS5 uses "" for literal quote) - cleaned = cleaned.replace(/"/g, '""'); - - // Step 6: Wrap in quotes for phrase search (safest approach) - if (!hasFTS5Operators) { - // Treat as literal phrase search - cleaned = `"${cleaned}"`; - } - - // If query has FTS5 operators, let FTS5 parse them (advanced users) - return cleaned; -} - -/** - * Search with keyword expansion (extract key terms) + * Search with keyword expansion (UPDATED - now uses sanitized keywords) */ function searchWithExpansion(options) { const { query, limit = 10 } = options; // Extract keywords from query const keywords = extractKeywords(query); + const sanitizedKeywords = sanitizeKeywords(keywords); // ✅ ADDED - // Search with original query + // Search with original query (already sanitized by prepareFTS5Query) const results = searchMemories({ ...options, limit: limit * 2 }); // If not enough results, try individual keywords - if (results.length < limit && keywords.length > 1) { + if (results.length < limit && sanitizedKeywords.length > 1) { const seen = new Set(results.map((r) => r.id)); - for (const keyword of keywords) { + for (const keyword of sanitizedKeywords) { // ✅ CHANGED - use sanitized if (results.length >= limit) break; const kwResults = searchMemories({ ...options, - query: keyword, + query: keyword, // Now guaranteed safe limit: limit - results.length, }); @@ -175,31 +302,9 @@ function searchWithExpansion(options) { function extractKeywords(text) { if (!text) return []; - // Simple keyword extraction: - // - Split on whitespace - // - Remove stopwords - // - Keep words > 3 characters - // - Lowercase - const stopwords = new Set([ - "the", - "is", - "at", - "which", - "on", - "and", - "or", - "not", - "this", - "that", - "with", - "from", - "for", - "to", - "in", - "of", - "a", - "an", + "the", "is", "at", "which", "on", "and", "or", "not", "this", "that", + "with", "from", "for", "to", "in", "of", "a", "an", ]); return text @@ -210,7 +315,7 @@ function extractKeywords(text) { } /** - * Find similar memories by keyword overlap + * Find similar memories by keyword overlap (UPDATED - sanitized) */ function findSimilar(memoryId, limit = 5) { const memory = store.getMemory(memoryId); @@ -219,28 +324,31 @@ function findSimilar(memoryId, limit = 5) { } const keywords = extractKeywords(memory.content); - if (keywords.length === 0) return []; + const sanitizedKeywords = sanitizeKeywords(keywords); // ✅ ADDED + + if (sanitizedKeywords.length === 0) return []; - // Build OR query for keywords - const query = keywords.join(" OR "); + // Build OR query with SANITIZED keywords + const query = sanitizedKeywords.join(" OR "); // ✅ CHANGED - use sanitized const results = searchMemories({ query, - limit: limit + 1, // +1 to exclude self + limit: limit + 1, }); - // Filter out the original memory return results.filter((r) => r.id !== memoryId).slice(0, limit); } /** - * Search by content similarity (simple keyword-based) + * Search by content similarity (UPDATED - sanitized) */ function searchByContent(content, options = {}) { const keywords = extractKeywords(content); - if (keywords.length === 0) return []; + const sanitizedKeywords = sanitizeKeywords(keywords); // ✅ ADDED + + if (sanitizedKeywords.length === 0) return []; - const query = keywords.slice(0, 5).join(" OR "); // Top 5 keywords + const query = sanitizedKeywords.slice(0, 5).join(" OR "); // ✅ CHANGED return searchMemories({ ...options, query }); } @@ -252,6 +360,10 @@ function countSearchResults(options) { return results.length; } +// ============================================================================ +// EXPORTS +// ============================================================================ + module.exports = { searchMemories, searchWithExpansion, @@ -260,4 +372,6 @@ module.exports = { searchByContent, countSearchResults, prepareFTS5Query, -}; + sanitizeKeyword, // ✅ NEW - exported for testing + sanitizeKeywords, // ✅ NEW - exported for testing +}; \ No newline at end of file diff --git a/src/memory/tools.js b/src/memory/tools.js index 0a3b829..a02455f 100644 --- a/src/memory/tools.js +++ b/src/memory/tools.js @@ -3,28 +3,85 @@ const search = require("./search"); const retriever = require("./retriever"); const logger = require("../logger"); -/** - * Memory tools for explicit memory management - * These can be registered as tools for the model to use - */ +// ============================================================================ +// 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' }), + 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, + query, // Will be sanitized by prepareFTS5Query in search.js limit, types: type ? [type] : undefined, categories: category ? [category] : undefined, @@ -50,10 +107,13 @@ async function memory_search(args, context = {}) { metadata: { resultCount: results.length }, }; } catch (err) { - logger.error({ err, query }, 'Memory search failed'); + logger.error({ err, query: query.substring(0, 100) }, 'Memory search failed'); return { ok: false, - content: JSON.stringify({ error: 'Memory search failed', message: err.message }), + content: JSON.stringify({ + error: 'Memory search failed', + message: err.message + }), }; } } @@ -61,6 +121,8 @@ async function memory_search(args, context = {}) { /** * Tool: memory_add * Manually add a fact to long-term memory + * + * UPDATED: Enhanced validation */ async function memory_add(args, context = {}) { const { @@ -70,26 +132,67 @@ async function memory_add(args, context = {}) { 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' }), + 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 + }), }; } - if (!['fact', 'preference', 'decision', 'entity', 'relationship'].includes(type)) { + // ✅ Validate category + if (!VALID_CATEGORIES.includes(category)) { return { ok: false, content: JSON.stringify({ - error: 'Invalid type. Must be one of: fact, preference, decision, entity, relationship', + 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' }), + content: JSON.stringify({ + error: 'Importance must be a number between 0 and 1', + provided: importance + }), }; } @@ -124,10 +227,13 @@ async function memory_add(args, context = {}) { metadata: { memoryId: memory.id }, }; } catch (err) { - logger.error({ err, content }, 'Memory add failed'); + 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 }), + content: JSON.stringify({ + error: 'Failed to add memory', + message: err.message + }), }; } } @@ -135,14 +241,41 @@ async function memory_add(args, context = {}) { /** * 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' }), + 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 + }), }; } @@ -202,10 +335,13 @@ async function memory_forget(args, context = {}) { metadata: { deletedCount }, }; } catch (err) { - logger.error({ err, query }, 'Memory forget failed'); + 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 }), + content: JSON.stringify({ + error: 'Failed to delete memories', + message: err.message + }), }; } } @@ -216,7 +352,7 @@ async function memory_forget(args, context = {}) { */ async function memory_stats(args, context = {}) { try { - const stats = retriever.getMemoryStats(context.session?.id); + const stats = retriever.getMemoryStats({ sessionId: context.session?.id }); if (!stats) { return { @@ -230,6 +366,8 @@ async function memory_stats(args, context = {}) { 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', @@ -239,12 +377,18 @@ async function memory_stats(args, context = {}) { logger.error({ err }, 'Memory stats failed'); return { ok: false, - content: JSON.stringify({ error: 'Failed to get statistics', message: err.message }), + content: JSON.stringify({ + error: 'Failed to get statistics', + message: err.message + }), }; } } -// Tool definitions for registration +// ============================================================================ +// TOOL DEFINITIONS (UPDATED) +// ============================================================================ + const MEMORY_TOOLS = { memory_search: { name: 'memory_search', @@ -254,22 +398,23 @@ const MEMORY_TOOLS = { properties: { query: { type: 'string', - description: 'Search query to find relevant memories', + description: 'Search query to find relevant memories (max 1000 characters)', }, limit: { type: 'integer', - description: 'Maximum number of results to return (default: 10)', + description: 'Maximum number of results to return (default: 10, max: 100)', minimum: 1, - maximum: 50, + maximum: 100, }, type: { type: 'string', description: 'Filter by memory type', - enum: ['fact', 'preference', 'decision', 'entity', 'relationship'], + enum: VALID_TYPES, }, category: { type: 'string', - description: 'Filter by category (code, user, project, general)', + description: 'Filter by category', + enum: VALID_CATEGORIES, }, }, required: ['query'], @@ -285,16 +430,17 @@ const MEMORY_TOOLS = { properties: { content: { type: 'string', - description: 'The fact or information to remember', + description: 'The fact or information to remember (10-5000 characters)', }, type: { type: 'string', description: 'Type of memory', - enum: ['fact', 'preference', 'decision', 'entity', 'relationship'], + enum: VALID_TYPES, }, category: { type: 'string', - description: 'Category: code, user, project, or general', + description: 'Category', + enum: VALID_CATEGORIES, }, importance: { type: 'number', @@ -316,7 +462,7 @@ const MEMORY_TOOLS = { properties: { query: { type: 'string', - description: 'Query to match memories to delete', + description: 'Query to match memories to delete (max 1000 characters)', }, confirm: { type: 'boolean', @@ -345,4 +491,4 @@ module.exports = { memory_forget, memory_stats, MEMORY_TOOLS, -}; +}; \ No newline at end of file From 8c2470b9d9305b1a3ed74934de04fc65819bcd8a Mon Sep 17 00:00:00 2001 From: Colin Hill Date: Wed, 21 Jan 2026 14:34:57 -0500 Subject: [PATCH 2/3] Improved runtime model capabiity checking when using ollama, cleaned up unit tests to avoid environment polution --- src/clients/databricks.js | 16 +++++++++++++--- test/azure-openai-routing.test.js | 3 +++ test/routing.test.js | 3 +++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/clients/databricks.js b/src/clients/databricks.js index 550278c..efb1a47 100644 --- a/src/clients/databricks.js +++ b/src/clients/databricks.js @@ -201,7 +201,7 @@ async function invokeOllama(body) { throw new Error("Ollama endpoint is not configured."); } - const { convertAnthropicToolsToOllama } = require("./ollama-utils"); + const { convertAnthropicToolsToOllama, checkOllamaToolSupport } = require("./ollama-utils"); const endpoint = `${config.ollama.endpoint}/api/chat`; const headers = { "Content-Type": "application/json" }; @@ -278,8 +278,18 @@ async function invokeOllama(body) { logger.info({}, "Tool injection disabled for Ollama (INJECT_TOOLS_OLLAMA=false)"); } - // Add tools if present (for tool-capable models) - if (Array.isArray(toolsToSend) && toolsToSend.length > 0) { + // Check if model supports tools + const supportsTools = await checkOllamaToolSupport(config.ollama.model); + + if (!supportsTools) { + logger.warn({ + model: config.ollama.model, + toolCount: toolsToSend?.length || 0 + }, "Model does not support tool calling - stripping tools from request"); + } + + // Add tools if present AND model supports them + if (supportsTools && Array.isArray(toolsToSend) && toolsToSend.length > 0) { ollamaBody.tools = convertAnthropicToolsToOllama(toolsToSend); logger.info({ toolCount: toolsToSend.length, diff --git a/test/azure-openai-routing.test.js b/test/azure-openai-routing.test.js index b30b4fc..5212722 100644 --- a/test/azure-openai-routing.test.js +++ b/test/azure-openai-routing.test.js @@ -20,6 +20,9 @@ describe("Azure OpenAI Routing Tests", () => { process.env.MODEL_PROVIDER = "databricks"; // Set default to avoid validation errors process.env.DATABRICKS_API_KEY = "test-key"; process.env.DATABRICKS_API_BASE = "http://test.com"; + + // Explicitly set valid fallback to override any local .env pollution (e.g. lmstudio) + process.env.FALLBACK_PROVIDER = "databricks"; }); afterEach(() => { diff --git a/test/routing.test.js b/test/routing.test.js index e503ec5..288416a 100644 --- a/test/routing.test.js +++ b/test/routing.test.js @@ -15,6 +15,9 @@ describe("Routing Logic", () => { // Store original config originalConfig = { ...process.env }; + + // Explicitly set valid fallback to override any local .env pollution (e.g. lmstudio) + process.env.FALLBACK_PROVIDER = "databricks"; }); afterEach(() => { From be545727a343ec021137a10a9fa2aa54cee5a5e0 Mon Sep 17 00:00:00 2001 From: Colin Hill Date: Wed, 21 Jan 2026 14:39:42 -0500 Subject: [PATCH 3/3] Bumped the Dockerfile to use node v24 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1831530..1c743ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use a small Node.js base image -FROM node:20-alpine +FROM node:24-alpine # Add OCI labels for better container management LABEL org.opencontainers.image.title="Lynkr" \