@shirokuma-library/mcp-knowledge-base
Version:
Shirokuma MCP Server for comprehensive knowledge management including issues, plans, documents, and work sessions. All stored data is structured for AI processing, not human readability.
155 lines (154 loc) • 5.51 kB
JavaScript
import { createLogger } from '../utils/logger.js';
import { parseSearchQuery, toFTS5Query, hasFieldSpecificSearch } from '../utils/search-query-parser.js';
export class FullTextSearchRepository {
db;
logger = createLogger('FullTextSearchRepository');
constructor(db) {
this.db = db;
}
async search(query, options) {
const defaultLimit = 20;
const maxLimit = 1000;
const limit = Math.min(Math.max(1, options?.limit || defaultLimit), maxLimit);
const offset = Math.max(0, options?.offset || 0);
const trimmedQuery = query.trim();
let ftsQuery;
if (!trimmedQuery) {
throw new Error('Search query cannot be empty');
}
const parsed = parseSearchQuery(trimmedQuery);
ftsQuery = toFTS5Query(parsed);
if (!ftsQuery) {
throw new Error('Invalid search query');
}
let typeFilter = '';
let params = [ftsQuery, limit, offset];
if (options?.types && options.types.length > 0) {
const placeholders = options.types.map(() => '?').join(',');
typeFilter = `AND items.type IN (${placeholders})`;
params = [ftsQuery, ...options.types, limit, offset];
}
const sql = `
SELECT
items.type as type,
items.id as id,
items.title as title,
snippet(items_fts, 3, '<mark>', '</mark>', '...', 50) as snippet,
bm25(items_fts) as score
FROM items_fts
JOIN items ON items.rowid = items_fts.rowid
WHERE items_fts MATCH ? ${typeFilter}
ORDER BY score, items.id
LIMIT ? OFFSET ?
`;
try {
const rows = await this.db.allAsync(sql, params);
return rows.map(row => ({
type: String(row.type),
id: String(row.id),
title: String(row.title),
snippet: String(row.snippet),
score: Math.abs(Number(row.score) || 0)
}));
}
catch (error) {
this.logger.error('Full-text search failed:', error);
throw error;
}
}
async suggest(query, options) {
const defaultLimit = 10;
const maxLimit = 100;
const limit = Math.min(Math.max(1, options?.limit || defaultLimit), maxLimit);
const trimmedQuery = query.trim();
let ftsQuery;
if (!trimmedQuery) {
return [];
}
const parsed = parseSearchQuery(trimmedQuery);
function addPrefixToRightmostTerm(expr) {
if (expr.type === 'term') {
if (!expr.value.endsWith('*')) {
return { ...expr, value: expr.value + '*' };
}
return expr;
}
else if (expr.type === 'boolean') {
return {
...expr,
right: addPrefixToRightmostTerm(expr.right)
};
}
return expr;
}
const modifiedExpression = addPrefixToRightmostTerm(parsed.expression);
const modifiedParsed = { ...parsed, expression: modifiedExpression };
ftsQuery = toFTS5Query(modifiedParsed);
let typeFilter = '';
let params = [ftsQuery, limit];
if (options?.types && options.types.length > 0) {
const placeholders = options.types.map(() => '?').join(',');
typeFilter = `AND items.type IN (${placeholders})`;
params = [ftsQuery, ...options.types, limit];
}
const sql = `
SELECT DISTINCT items.title
FROM items_fts
JOIN items ON items.rowid = items_fts.rowid
WHERE items_fts MATCH ? ${typeFilter}
ORDER BY bm25(items_fts)
LIMIT ?
`;
try {
const rows = await this.db.allAsync(sql, params);
return rows.map(row => row.title);
}
catch (error) {
this.logger.error('Search suggestion failed:', error);
return [];
}
}
async count(query, options) {
const trimmedQuery = query.trim();
let ftsQuery;
if (!trimmedQuery) {
throw new Error('Search query cannot be empty');
}
if (hasFieldSpecificSearch(trimmedQuery)) {
const parsed = parseSearchQuery(trimmedQuery);
ftsQuery = toFTS5Query(parsed);
}
else {
const words = trimmedQuery.split(/\s+/).filter(word => word.length > 0);
if (words.length === 1) {
ftsQuery = words[0];
}
else {
ftsQuery = words.map(word => {
return word.replace(/['"]/g, '');
}).join(' AND ');
}
}
let typeFilter = '';
let params = [ftsQuery];
if (options?.types && options.types.length > 0) {
const placeholders = options.types.map(() => '?').join(',');
typeFilter = `AND items.type IN (${placeholders})`;
params = [ftsQuery, ...options.types];
}
const sql = `
SELECT COUNT(*) as count
FROM items_fts
JOIN items ON items.rowid = items_fts.rowid
WHERE items_fts MATCH ? ${typeFilter}
`;
try {
const result = await this.db.getAsync(sql, params);
return result?.count || 0;
}
catch (error) {
this.logger.error('Search count failed:', error);
return 0;
}
}
}