UNPKG

@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.

607 lines (606 loc) 25.3 kB
import { BaseRepository } from './base-repository.js'; import { RelatedItemsHelper } from '../types/unified-types.js'; import { StatusRepository } from './status-repository.js'; import { TagRepository } from './tag-repository.js'; import { parseMarkdown, generateMarkdown } from '../utils/markdown-parser.js'; import { normalizeVersion, denormalizeVersion } from '../utils/version-utils.js'; import * as path from 'path'; import * as fs from 'fs/promises'; import { glob } from 'glob'; export class ItemRepository extends BaseRepository { statusRepository; tagRepository; dataDir; knownTypes; constructor(db, dataDir, statusRepository, tagRepository) { super(db, 'ItemRepository', 'items'); this.dataDir = dataDir; this.statusRepository = statusRepository || new StatusRepository(db); this.tagRepository = tagRepository || new TagRepository(db); this.knownTypes = new Map([ ['issues', { type: 'issues', baseType: 'tasks' }], ['plans', { type: 'plans', baseType: 'tasks' }], ['bugs', { type: 'bugs', baseType: 'tasks' }], ['docs', { type: 'docs', baseType: 'documents' }], ['knowledge', { type: 'knowledge', baseType: 'documents' }], ['recipe', { type: 'recipe', baseType: 'documents' }], ['tutorial', { type: 'tutorial', baseType: 'documents' }], ['sessions', { type: 'sessions', baseType: 'sessions' }], ['dailies', { type: 'dailies', baseType: 'dailies' }] ]); } async getType(typeName) { if (this.knownTypes.has(typeName)) { return this.knownTypes.get(typeName); } const row = await this.db.getAsync('SELECT type, base_type FROM sequences WHERE type = ?', [typeName]); if (row) { const typeDef = { type: String(row.type), baseType: String(row.base_type) }; this.knownTypes.set(typeName, typeDef); return typeDef; } return null; } mapRowToEntity(row) { const tags = row.tags ? JSON.parse(String(row.tags)) : []; const related = row.related ? JSON.parse(String(row.related)) : []; const item = { id: String(row.id), type: row.type, title: row.title, description: row.description || undefined, version: denormalizeVersion(row.version) || undefined, content: row.content || '', priority: row.priority || 'medium', status_id: row.status_id || 1, start_date: row.start_date, end_date: row.end_date, start_time: row.start_time, tags, related, created_at: row.created_at, updated_at: row.updated_at }; return item; } mapEntityToRow(entity) { return { type: entity.type, id: entity.id, title: entity.title, description: entity.description || null, version: normalizeVersion(entity.version) || null, content: entity.content, priority: entity.priority, status_id: entity.status_id, start_date: entity.start_date, end_date: entity.end_date, start_time: entity.start_time, tags: JSON.stringify(entity.tags), related: JSON.stringify(entity.related), created_at: entity.created_at, updated_at: entity.updated_at }; } async createItem(params) { const { type, ...data } = params; const typeDef = await this.getType(type); if (!typeDef) { throw new Error(`Unknown type: '${type}'. Use the 'get_types' tool to see all available types and their descriptions.`); } let id; if (type === 'sessions') { id = data.id || this.generateSessionId(); } else if (type === 'dailies') { id = data.start_date || new Date().toISOString().split('T')[0]; const existing = await this.getById(type, id); if (existing) { throw new Error(`Daily summary already exists for date: ${id}. Use 'update_item' to modify the existing summary, or 'get_item_detail' with type='dailies' and id='${id}' to view it.`); } } else { const numId = await this.getNextId(type); id = numId.toString(); } let statusId = 1; if (data.status) { const status = await this.statusRepository.getStatusByName(data.status); if (!status) { throw new Error(`Unknown status: '${data.status}'. Use the 'get_statuses' tool to see all available statuses.`); } statusId = status.id; } const priority = data.priority || 'medium'; const now = new Date().toISOString(); let startDate = data.start_date || null; let startTime = data.start_time || null; if (type === 'sessions') { startDate = id.split('-').slice(0, 3).join('-'); startTime = id.split('-').slice(3).join(':').replace(/\./g, ':'); } else if (type === 'dailies') { startDate = id; } const item = { id, type, title: data.title, description: data.description || undefined, version: data.version || undefined, content: data.content || '', priority, status_id: statusId, start_date: startDate, end_date: data.end_date || null, start_time: startTime, tags: data.tags || [], related: data.related || [], created_at: now, updated_at: now }; if (data.version) { this.logger.debug('Creating item with version', { type, id, version: data.version }); } console.log('[DEBUG] createItem - data.version:', data.version); console.log('[DEBUG] createItem - item.version:', item.version); await this.saveToMarkdown(item); await this.syncToDatabase(item); if (item.tags.length > 0) { await this.tagRepository.registerTags(item.tags); } return item; } async getById(type, id) { const filePath = this.getFilePath(type, id); try { const content = await fs.readFile(filePath, 'utf8'); const { metadata, content: bodyContent } = parseMarkdown(content); if (!metadata.id) { return null; } const tags = Array.isArray(metadata.tags) ? metadata.tags : []; const related = Array.isArray(metadata.related) ? metadata.related : (Array.isArray(metadata.related_tasks) && Array.isArray(metadata.related_documents) ? [...metadata.related_tasks, ...metadata.related_documents] : []); let statusId = metadata.status_id; if (!statusId && metadata.status) { const status = await this.statusRepository.getStatusByName(metadata.status); statusId = status?.id || 1; } let item; if (type === 'sessions' || type === 'dailies') { item = { id: String(metadata.id), type, title: metadata.title, description: metadata.description || undefined, version: metadata.version || undefined, content: bodyContent, priority: metadata.priority || 'medium', status_id: statusId || 1, start_date: metadata.start_date || metadata.date || new Date().toISOString().split('T')[0], end_date: null, start_time: type === 'sessions' ? (metadata.start_time || null) : null, tags, related, created_at: metadata.created_at || new Date().toISOString(), updated_at: metadata.updated_at || new Date().toISOString() }; } else { item = { id: String(metadata.id), type, title: metadata.title, description: metadata.description || undefined, version: metadata.version || undefined, content: bodyContent, priority: metadata.priority || 'medium', status_id: statusId || 1, start_date: metadata.start_date || metadata.date || null, end_date: metadata.end_date || null, start_time: null, tags, related, created_at: metadata.created_at || new Date().toISOString(), updated_at: metadata.updated_at || new Date().toISOString() }; } return item; } catch { return null; } } async update(type, id, params) { const existing = await this.getById(type, id); if (!existing) { return null; } let statusId = existing.status_id; if (params.status) { const status = await this.statusRepository.getStatusByName(params.status); if (!status) { throw new Error(`Unknown status: '${params.status}'. Use the 'get_statuses' tool to see all available statuses.`); } statusId = status.id; } const updated = { ...existing, title: params.title ?? existing.title, description: params.description !== undefined ? params.description : existing.description, version: params.version !== undefined ? params.version : existing.version, content: params.content ?? existing.content, priority: params.priority ?? existing.priority, status_id: statusId, start_date: params.start_date !== undefined ? params.start_date : existing.start_date, end_date: params.end_date !== undefined ? params.end_date : existing.end_date, start_time: params.start_time ?? existing.start_time, tags: params.tags ?? existing.tags, related: params.related ?? existing.related, updated_at: new Date().toISOString() }; await this.saveToMarkdown(updated); await this.syncToDatabase(updated); if (params.tags) { await this.tagRepository.registerTags(params.tags); } return updated; } async delete(type, id) { const filePath = this.getFilePath(type, id); try { await fs.unlink(filePath); await this.db.runAsync('DELETE FROM items WHERE type = ? AND id = ?', [type, id]); await this.db.runAsync('DELETE FROM related_items WHERE (source_type = ? AND source_id = ?) OR (target_type = ? AND target_id = ?)', [type, id, type, id]); await this.db.runAsync('DELETE FROM item_tags WHERE item_type = ? AND item_id = ?', [type, id]); return true; } catch { return false; } } async search(params) { let query = 'SELECT DISTINCT i.* FROM items i'; const joins = []; const conditions = []; const values = []; if (params.type) { conditions.push('i.type = ?'); values.push(params.type); } else if (params.types && params.types.length > 0) { conditions.push(`i.type IN (${params.types.map(() => '?').join(',')})`); values.push(...params.types); } if (params.query) { joins.push('JOIN items_fts ON items_fts.rowid = i.rowid'); conditions.push('items_fts MATCH ?'); values.push(params.query); } if (params.tags && params.tags.length > 0) { for (let i = 0; i < params.tags.length; i++) { const tag = params.tags[i]; const tagAlias = `t${i}`; const tagId = await this.tagRepository.getTagIdByName(tag); if (tagId) { joins.push(`JOIN item_tags ${tagAlias} ON ${tagAlias}.item_type = i.type AND ${tagAlias}.item_id = i.id`); conditions.push(`${tagAlias}.tag_id = ?`); values.push(tagId); } } } if (params.status) { const status = await this.statusRepository.getStatusByName(params.status); if (status) { conditions.push('i.status_id = ?'); values.push(status.id); } } else if (!params.includeClosedStatuses) { const closedStatuses = await this.statusRepository.getClosedStatusIds(); if (closedStatuses.length > 0) { conditions.push(`i.status_id NOT IN (${closedStatuses.map(() => '?').join(',')})`); values.push(...closedStatuses); } } if (params.priority) { conditions.push('i.priority = ?'); values.push(params.priority); } if (params.version) { if (params.version.startsWith('>=')) { conditions.push('i.version >= ?'); values.push(normalizeVersion(params.version.substring(2).trim())); } else if (params.version.startsWith('>')) { conditions.push('i.version > ?'); values.push(normalizeVersion(params.version.substring(1).trim())); } else if (params.version.startsWith('<=')) { conditions.push('i.version <= ?'); values.push(normalizeVersion(params.version.substring(2).trim())); } else if (params.version.startsWith('<')) { conditions.push('i.version < ?'); values.push(normalizeVersion(params.version.substring(1).trim())); } else { conditions.push('i.version = ?'); values.push(normalizeVersion(params.version)); } } if (params.startDate) { conditions.push('i.start_date >= ?'); values.push(params.startDate); } if (params.endDate) { conditions.push('i.start_date <= ?'); values.push(params.endDate); } if (joins.length > 0) { query += ' ' + joins.join(' '); } if (conditions.length > 0) { query += ' WHERE ' + conditions.join(' AND '); } query += ' ORDER BY i.created_at DESC'; if (params.limit) { query += ' LIMIT ?'; values.push(params.limit); if (params.offset) { query += ' OFFSET ?'; values.push(params.offset); } } const rows = await this.db.allAsync(query, values); const items = []; for (const row of rows) { const item = await this.getById(String(row.type), String(row.id)); if (item) { items.push(item); } } return items; } async getAllByType(type) { const dir = this.getTypeDirectory(type); try { await fs.access(dir); } catch { return []; } const pattern = path.join(dir, `${type}-*.md`); const files = await glob(pattern); const items = []; for (const file of files) { const filename = path.basename(file); const match = filename.match(new RegExp(`${type}-(.*)\\.md$`)); if (match) { const id = match[1]; const item = await this.getById(type, id); if (item) { items.push(item); } } } return items; } generateSessionId() { const now = new Date(); const pad = (n, width) => n.toString().padStart(width, '0'); return `${now.getFullYear()}-${pad(now.getMonth() + 1, 2)}-${pad(now.getDate(), 2)}-` + `${pad(now.getHours(), 2)}.${pad(now.getMinutes(), 2)}.${pad(now.getSeconds(), 2)}.` + `${pad(now.getMilliseconds(), 3)}`; } getTypeDirectory(type) { if (type === 'sessions') { return path.join(this.dataDir, 'sessions'); } return path.join(this.dataDir, type); } getFilePath(type, id) { const idStr = String(id); if (idStr.includes('..') || idStr.includes('/') || idStr.includes('\\') || idStr.includes('\0') || idStr.includes('%') || idStr === '.' || path.isAbsolute(idStr)) { throw new Error(`Invalid ID format: ${idStr}`); } if (!/^[a-zA-Z0-9\-_.]+$/.test(idStr)) { throw new Error(`Invalid ID format: ${idStr}`); } if (type === 'sessions') { const dateMatch = id.match(/^(\d{4}-\d{2}-\d{2})/); if (dateMatch) { const date = dateMatch[1]; return path.join(this.dataDir, 'sessions', date, `sessions-${id}.md`); } } else if (type === 'dailies') { return path.join(this.dataDir, 'sessions', id, `dailies-${id}.md`); } const dir = this.getTypeDirectory(type); return path.join(dir, `${type}-${id}.md`); } async saveToMarkdown(item) { const filePath = this.getFilePath(item.type, item.id); const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); const status = await this.statusRepository.getStatusById(item.status_id); const statusName = status?.name || 'Open'; const typeDef = await this.getType(item.type); const baseType = typeDef?.baseType || 'documents'; const metadata = { base: baseType, id: item.type === 'sessions' || item.type === 'dailies' ? item.id : parseInt(item.id), title: item.title, priority: item.priority, status: statusName, tags: item.tags, related: item.related, created_at: item.created_at, updated_at: item.updated_at }; if (item.description) { metadata.description = item.description; } console.log('[DEBUG] saveToMarkdown - item.version:', item.version); if (item.version) { metadata.version = item.version; this.logger.debug('Adding version to metadata', { type: item.type, id: item.id, version: item.version }); console.log('[DEBUG] saveToMarkdown - metadata after adding version:', metadata); } else { this.logger.debug('No version field in item', { type: item.type, id: item.id }); console.log('[DEBUG] saveToMarkdown - No version field in item'); } if (item.start_date) { metadata.start_date = item.start_date; } if (item.end_date) { metadata.end_date = item.end_date; } if (item.start_time) { metadata.start_time = item.start_time; } const markdown = generateMarkdown(metadata, item.content); await fs.writeFile(filePath, markdown, 'utf8'); } async searchByTag(tag) { const sql = ` SELECT DISTINCT item_type as type, item_id as id FROM item_tags it JOIN tags t ON t.id = it.tag_id WHERE t.name = ? `; const rows = await this.db.allAsync(sql, [tag]); const items = []; for (const row of rows) { const item = await this.getById(String(row.type), String(row.id)); if (item) { items.push(item); } } return items; } async syncToDatabase(item) { const tagsJson = JSON.stringify(item.tags); const relatedJson = JSON.stringify(item.related); await this.db.runAsync(` INSERT OR REPLACE INTO items (type, id, title, description, content, priority, status_id, start_date, end_date, start_time, version, tags, related, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ item.type, item.id, item.title, item.description || null, item.content, item.priority, item.status_id, item.start_date, item.end_date, item.start_time, item.version || null, tagsJson, relatedJson, item.created_at, item.updated_at ]); await this.db.runAsync(` INSERT OR REPLACE INTO items_fts (rowid, type, title, description, content, tags) VALUES ( (SELECT rowid FROM items WHERE type = ? AND id = ?), ?, ?, ?, ?, ? ) `, [ item.type, item.id, item.type, item.title, item.description || '', item.content, tagsJson ]); await this.db.runAsync('DELETE FROM item_tags WHERE item_type = ? AND item_id = ?', [item.type, item.id]); for (const tagName of item.tags) { const tagId = await this.tagRepository.getTagIdByName(tagName); if (tagId) { await this.db.runAsync('INSERT INTO item_tags (item_type, item_id, tag_id) VALUES (?, ?, ?)', [item.type, item.id, tagId]); } } await this.db.runAsync('DELETE FROM related_items WHERE source_type = ? AND source_id = ?', [item.type, item.id]); for (const related of item.related) { const { type: targetType, id: targetId } = RelatedItemsHelper.parse(related); await this.db.runAsync('INSERT INTO related_items (source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?)', [item.type, item.id, targetType, targetId]); } } async changeItemType(fromType, fromId, toType) { this.logger.info(`Changing type from ${fromType}-${fromId} to ${toType}`); try { const fromTypeDef = await this.getType(fromType); const toTypeDef = await this.getType(toType); if (!fromTypeDef || !toTypeDef) { return { success: false, error: 'Invalid type specified' }; } if (fromTypeDef.baseType !== toTypeDef.baseType) { return { success: false, error: `Cannot change between different base types: ${fromTypeDef.baseType} → ${toTypeDef.baseType}` }; } if (['sessions', 'dailies'].includes(fromType) || ['sessions', 'dailies'].includes(toType)) { return { success: false, error: 'Sessions and dailies cannot be type-changed' }; } const originalItem = await this.getById(fromType, String(fromId)); if (!originalItem) { return { success: false, error: 'Item not found' }; } const newItem = await this.createItem({ type: toType, title: originalItem.title, description: originalItem.description, content: originalItem.content || '', priority: originalItem.priority, status: originalItem.status, tags: originalItem.tags, start_date: originalItem.start_date || undefined, end_date: originalItem.end_date || undefined, related_tasks: originalItem.related_tasks, related_documents: originalItem.related_documents }); const oldReference = `${fromType}-${fromId}`; const newReference = `${toType}-${newItem.id}`; let relatedUpdates = 0; const relatedRows = await this.db.allAsync(` SELECT DISTINCT type, id FROM items WHERE related LIKE ? `, [`%"${oldReference}"%`]); for (const row of relatedRows) { const item = await this.getById(String(row.type), String(row.id)); if (item) { const updatedRelated = item.related.map((ref) => ref === oldReference ? newReference : ref); await this.update(String(row.type), String(row.id), { type: String(row.type), id: String(row.id), related: updatedRelated }); relatedUpdates++; } } await this.delete(fromType, String(fromId)); return { success: true, newId: Number(newItem.id), relatedUpdates }; } catch (error) { this.logger.error('Failed to change item type', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } }