@sofianedjerbi/knowledge-tree-mcp
Version:
MCP server for hierarchical project knowledge management
261 lines • 10.5 kB
JavaScript
/**
* Search tool implementation
* Provides advanced search functionality for knowledge entries
*/
import { join } from 'path';
import { PRIORITY_WEIGHTS, SEARCH_DEFAULTS } from '../constants/index.js';
import { readFile, convertEntryToMarkdown } from '../utils/index.js';
/**
* Handler for the search_knowledge tool
*/
export const searchKnowledgeHandler = async (args, context) => {
const { query, priority = [], category, searchIn = SEARCH_DEFAULTS.SEARCH_IN, regex = SEARCH_DEFAULTS.REGEX, caseSensitive = SEARCH_DEFAULTS.CASE_SENSITIVE, limit = SEARCH_DEFAULTS.LIMIT, sortBy = SEARCH_DEFAULTS.SORT_BY } = args;
// Log search activity
if (query) {
await context.logSearch(query, { priority, category, searchIn, sortBy });
}
const allEntries = await context.scanKnowledgeTree();
let matches = [];
for (const path of allEntries) {
const fullPath = join(context.knowledgeRoot, path);
try {
const content = await readFile(fullPath);
const entry = JSON.parse(content);
let match = true;
let score = 0;
const highlights = {};
// Priority filter (now supports array)
if (priority.length > 0 && !priority.includes(entry.priority)) {
match = false;
continue;
}
// Category filter
if (category && !path.toLowerCase().includes(category.toLowerCase())) {
match = false;
continue;
}
// Search query matching
if (query) {
const queryMatch = await matchQuery(query, entry, path, searchIn, regex, caseSensitive, highlights);
if (!queryMatch.match) {
match = false;
}
else {
score = queryMatch.score;
}
}
if (match) {
// Add priority weight to score
score += PRIORITY_WEIGHTS[entry.priority] || 0;
matches.push({ path, entry, score, highlights });
}
}
catch (error) {
// Skip invalid entries
}
}
// Sort results
matches = sortResults(matches, sortBy);
// Apply limit
const limitedMatches = matches.slice(0, limit);
// Enrich matches with linked knowledge
const enrichedMatches = limitedMatches.map(match => {
const enriched = {
path: match.path,
entry: match.entry,
score: match.score,
highlights: match.highlights
};
if (match.entry.related_to && match.entry.related_to.length > 0) {
enriched.links = match.entry.related_to;
}
return enriched;
});
// Always output in Markdown format for AI consumption
let markdown = `# Search Results\n\n`;
markdown += `**Total matches**: ${matches.length}\n`;
markdown += `**Showing**: ${enrichedMatches.length}\n\n`;
for (const match of enrichedMatches) {
markdown += `---\n\n`;
markdown += `## 📄 ${match.entry.title || match.path}\n\n`;
markdown += `**Path**: \`${match.path}\`\n`;
markdown += `**Priority**: ${match.entry.priority}\n`;
markdown += `**Score**: ${match.score}\n\n`;
// Add full entry in markdown format
markdown += convertEntryToMarkdown(match.entry);
markdown += `\n\n`;
}
return {
content: [
{
type: "text",
text: markdown,
},
],
};
};
/**
* Match a query against an entry
*/
async function matchQuery(query, entry, path, searchIn, regex, caseSensitive, highlights) {
// Check if query contains wildcards (* or ?)
const hasWildcards = query.includes('*') || query.includes('?');
// Check if query is quoted for exact phrase matching
const isQuotedPhrase = (query.startsWith('"') && query.endsWith('"')) ||
(query.startsWith("'") && query.endsWith("'"));
let searchTerms = [];
let isExactPhrase = false;
if (isQuotedPhrase) {
// Exact phrase search - remove quotes and treat as single term
const cleanQuery = query.slice(1, -1);
searchTerms = [cleanQuery];
isExactPhrase = true;
}
else if (hasWildcards) {
// Special case: single * means match everything
if (query.trim() === '*') {
return { match: true, score: 1 };
}
// Wildcards - treat as single pattern
searchTerms = [query];
}
else if (regex) {
// Regex mode - treat as single pattern
searchTerms = [query];
}
else {
// Multi-word search - split into individual words
searchTerms = query.trim().split(/\s+/).filter(term => term.length > 0);
}
const fieldsToSearch = searchIn.includes("all")
? ["title", "problem", "solution", "code", "path", "context", "tags"]
: searchIn;
let queryMatch = false;
let totalScore = 0;
// For multi-word queries, we need ALL words to match (AND logic)
// But they can match in different fields
let termMatches = new Map();
for (const field of fieldsToSearch) {
let fieldValue = "";
switch (field) {
case "title":
fieldValue = entry.title || "";
break;
case "problem":
fieldValue = entry.problem || "";
break;
case "solution":
fieldValue = entry.solution || "";
break;
case "context":
fieldValue = entry.context || "";
break;
case "code":
fieldValue = entry.code || "";
break;
case "path":
fieldValue = path;
break;
case "tags":
fieldValue = entry.tags ? entry.tags.join(" ") : "";
break;
}
if (!fieldValue)
continue;
const testValue = caseSensitive ? fieldValue : fieldValue.toLowerCase();
// Test each search term against this field
for (const term of searchTerms) {
let searchPattern;
let termFound = false;
if (hasWildcards && !isExactPhrase) {
// Convert wildcards to regex: * = .*, ? = .
const regexQuery = term
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex chars except * and ?
.replace(/\\\*/g, '.*') // Convert \* back to .*
.replace(/\\\?/g, '.'); // Convert \? back to .
searchPattern = new RegExp(regexQuery, caseSensitive ? 'g' : 'gi');
const matches = testValue.match(searchPattern);
if (matches) {
termFound = true;
termMatches.set(term, true);
if (!highlights[field])
highlights[field] = [];
highlights[field].push(...matches);
// Score based on field importance and match count
const fieldWeight = field === "title" ? 5 : field === "problem" ? 3 : field === "solution" ? 2 : field === "tags" ? 2 : 1;
totalScore += matches.length * fieldWeight;
}
}
else if (regex && !isExactPhrase) {
searchPattern = new RegExp(term, caseSensitive ? 'g' : 'gi');
const matches = testValue.match(searchPattern);
if (matches) {
termFound = true;
termMatches.set(term, true);
if (!highlights[field])
highlights[field] = [];
highlights[field].push(...matches);
// Score based on field importance and match count
const fieldWeight = field === "title" ? 5 : field === "problem" ? 3 : field === "solution" ? 2 : field === "tags" ? 2 : 1;
totalScore += matches.length * fieldWeight;
}
}
else {
// Simple string matching
const searchStr = caseSensitive ? term : term.toLowerCase();
if (testValue.includes(searchStr)) {
termFound = true;
termMatches.set(term, true);
// Calculate score based on match position and frequency
const matchCount = (testValue.match(new RegExp(searchStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
const fieldWeight = field === "title" ? 5 : field === "problem" ? 3 : field === "solution" ? 2 : field === "tags" ? 2 : 1;
totalScore += matchCount * fieldWeight;
// Bonus for matches at the beginning
if (testValue.startsWith(searchStr)) {
totalScore += 5;
}
// Store simple highlight
if (!highlights[field])
highlights[field] = [];
highlights[field].push(searchStr);
}
}
}
}
// For multi-word queries, ALL terms must match somewhere
if (searchTerms.length > 1 && !isExactPhrase) {
queryMatch = termMatches.size === searchTerms.length;
}
else {
// For single terms, exact phrases, wildcards, or regex
queryMatch = termMatches.size > 0;
}
return { match: queryMatch, score: totalScore };
}
/**
* Sort search results based on the specified criteria
*/
function sortResults(matches, sortBy) {
const sorted = [...matches];
switch (sortBy) {
case "relevance":
sorted.sort((a, b) => b.score - a.score);
break;
case "priority":
sorted.sort((a, b) => {
const aWeight = PRIORITY_WEIGHTS[a.entry.priority] || 0;
const bWeight = PRIORITY_WEIGHTS[b.entry.priority] || 0;
return bWeight - aWeight;
});
break;
case "path":
sorted.sort((a, b) => a.path.localeCompare(b.path));
break;
case "recent":
// Would need file stats for this, using path as fallback
sorted.sort((a, b) => b.path.localeCompare(a.path));
break;
}
return sorted;
}
//# sourceMappingURL=search.js.map