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.

438 lines (437 loc) 20.6 kB
import sqlite3 from 'sqlite3'; import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; import { createLogger } from '../utils/logger.js'; import { getProgramVersion, setDbVersion } from '../utils/db-version-utils.js'; import { globSync } from 'glob'; export class BaseRepository { db; logger; constructor(db, loggerName) { this.db = db; this.logger = createLogger(loggerName || this.constructor.name); } getEntityFileName(sequenceType, 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}`); } return `${sequenceType}-${idStr}.md`; } async getSequenceType(sequenceName) { const row = await this.db.getAsync('SELECT type FROM sequences WHERE type = ?', [sequenceName]); return row?.type || null; } async getNextSequenceValue(sequenceName) { try { await this.db.runAsync('UPDATE sequences SET current_value = current_value + 1 WHERE type = ?', [sequenceName]); const row = await this.db.getAsync('SELECT current_value FROM sequences WHERE type = ?', [sequenceName]); if (!row || !row.current_value) { throw new Error(`Failed to get sequence value for ${sequenceName}`); } return row.current_value; } catch (err) { this.logger.error('Error getting sequence value:', { error: err, sequenceName }); throw new Error(`Failed to get sequence value for ${sequenceName}: ${err instanceof Error ? err.message : 'Unknown error'}`); } } } export class DatabaseConnection { dbPath; dataDir; db; initializationPromise = null; initializationComplete = false; logger; constructor(dbPath, dataDir) { this.dbPath = dbPath; this.dataDir = dataDir; this.logger = createLogger('DatabaseConnection'); if (this.isMCPEnvironment()) { const noop = () => this.logger; this.logger.debug = noop; this.logger.info = noop; this.logger.warn = noop; this.logger.error = noop; } } isMCPEnvironment() { if (process.env.NODE_ENV === 'test' || process.env.MCP_MODE === 'false') { return false; } return process.argv.some(arg => arg.includes('server.js')) || process.env.MCP_MODE === 'true' || process.env.NODE_ENV === 'production'; } async initialize() { if (this.initializationPromise) { return this.initializationPromise; } this.initializationPromise = this.initializeDatabase(); return this.initializationPromise; } async initializeDatabase() { this.logger.debug('Starting database initialization...'); const sqlite = sqlite3; const dbDir = path.dirname(this.dbPath); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } const isNewDatabase = !fs.existsSync(this.dbPath); this.db = new sqlite.Database(this.dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE); this.logger.debug('Database connection created'); this.db.runAsync = (sql, params) => { return new Promise((resolve, reject) => { this.db.run(sql, params || [], function (err) { if (err) { reject(err); } else { resolve(this); } }); }); }; this.db.getAsync = promisify(this.db.get.bind(this.db)); this.db.allAsync = promisify(this.db.all.bind(this.db)); this.logger.debug('Promise wrappers added'); await this.createTables(); this.logger.debug('Tables created'); if (isNewDatabase) { const programVersion = await getProgramVersion(); await setDbVersion(this.db, programVersion); this.logger.info(`New database - version set to: ${programVersion}`); } if (isNewDatabase) { const markdownDir = this.dataDir || dbDir; const hasExistingData = await this.checkForExistingMarkdownFiles(markdownDir); if (hasExistingData) { this.logger.warn('New database created, but existing markdown files detected'); this.logger.warn('To import existing data, run: npm run rebuild:mcp'); await this.db.runAsync('INSERT INTO db_metadata (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)', ['needs_rebuild', 'true']); } } this.initializationComplete = true; this.logger.debug('Database initialization complete'); } async checkForExistingMarkdownFiles(markdownDir) { try { const patterns = [ 'issues/*.md', 'plans/*.md', 'docs/*.md', 'knowledge/*.md', 'sessions/**/*.md' ]; for (const pattern of patterns) { const fullPattern = path.join(markdownDir, pattern); const files = globSync(fullPattern); if (files.length > 0) { this.logger.info(`Found existing markdown files in ${pattern}`); return true; } } return false; } catch (error) { this.logger.error('Error checking for markdown files', error); return false; } } async createTables() { this.logger.debug('Creating tables...'); this.logger.debug('Creating statuses table...'); await this.db.runAsync(` CREATE TABLE IF NOT EXISTS statuses ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, is_closed BOOLEAN DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); this.logger.debug('Creating tags table...'); await this.db.runAsync(` CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); this.logger.debug('Creating sequences table...'); await this.db.runAsync(` CREATE TABLE IF NOT EXISTS sequences ( type TEXT PRIMARY KEY, current_value INTEGER DEFAULT 0, -- @ai-note: 0 for sessions/dailies (unused) base_type TEXT NOT NULL, description TEXT, -- @ai-note: Type description and usage guidelines created_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); try { await this.db.runAsync('ALTER TABLE sequences ADD COLUMN description TEXT'); this.logger.debug('Added description column to sequences table'); } catch { } this.logger.debug('Creating db_metadata table...'); await this.db.runAsync(` CREATE TABLE IF NOT EXISTS db_metadata ( key TEXT PRIMARY KEY, value TEXT, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); this.logger.debug('Inserting default statuses...'); await this.db.runAsync(`INSERT OR IGNORE INTO statuses (name, is_closed) VALUES ('Open', 0), ('In Progress', 0), ('Review', 0), ('Completed', 1), ('Closed', 1), ('On Hold', 0), ('Cancelled', 1)`); this.logger.debug('Initializing sequences...'); const existingSequences = await this.db.allAsync('SELECT type FROM sequences'); if (existingSequences.length === 0) { const sequenceValues = []; const typeDefinitions = [ { type: 'issues', baseType: 'tasks', description: 'Bug reports, feature requests, and general tasks. Includes priority, status, and timeline tracking.' }, { type: 'plans', baseType: 'tasks', description: 'Project plans and milestones with start/end dates. Used for planning and tracking larger initiatives.' }, { type: 'docs', baseType: 'documents', description: 'Technical documentation, API references, and user guides. Structured content with required text.' }, { type: 'knowledge', baseType: 'documents', description: 'Knowledge base articles, best practices, and how-to guides. Searchable reference material.' } ]; for (const def of typeDefinitions) { sequenceValues.push(`('${def.type}', 0, '${def.baseType}', '${def.description.replace(/'/g, "''")}')`); } await this.db.runAsync(`INSERT INTO sequences (type, current_value, base_type, description) VALUES ${sequenceValues.join(', ')}`); await this.db.runAsync(`INSERT INTO sequences (type, current_value, base_type, description) VALUES ('sessions', 0, 'sessions', 'Work session tracking. Content is optional - can be created at session start and updated later. Uses timestamp-based IDs.'), ('dailies', 0, 'documents', 'Daily summaries with required content. One entry per date. Uses date as ID (YYYY-MM-DD).')`); } else { await this.db.runAsync(`INSERT OR IGNORE INTO sequences (type, current_value, base_type, description) VALUES ('issues', 0, 'tasks', 'Bug reports, feature requests, and general tasks. Includes priority, status, and timeline tracking.'), ('plans', 0, 'tasks', 'Project plans and milestones with start/end dates. Used for planning and tracking larger initiatives.'), ('knowledge', 0, 'documents', 'Knowledge base articles, best practices, and how-to guides. Searchable reference material.'), ('docs', 0, 'documents', 'Technical documentation, API references, and user guides. Structured content with required text.'), ('sessions', 0, 'sessions', 'Work session tracking. Content is optional - can be created at session start and updated later. Uses timestamp-based IDs.'), ('dailies', 0, 'documents', 'Daily summaries with required content. One entry per date. Uses date as ID (YYYY-MM-DD).')`); } this.logger.debug('Creating search tables...'); await this.createSearchTables(); this.logger.debug('Creating tag relationship tables...'); await this.createTagRelationshipTables(); this.logger.debug('Creating type fields table...'); await this.createTypeFieldsTable(); this.logger.debug('Creating indexes...'); await this.createIndexes(); this.logger.debug('All tables created successfully'); } async createSearchTables() { await this.db.runAsync(` CREATE TABLE IF NOT EXISTS items ( type TEXT NOT NULL, -- Any type name (no hardcoded restrictions) id TEXT NOT NULL, -- INTEGER or STRING (for sessions) title TEXT NOT NULL, description TEXT, content TEXT, -- Main content (required for most types, optional for sessions) priority TEXT, -- high/medium/low (used as importance for all types) status_id INTEGER, -- Used by all types (documents use Open/Closed) start_date TEXT, -- For tasks, or date for sessions/summaries end_date TEXT, -- For tasks start_time TEXT, -- For sessions (HH:MM:SS) version TEXT, -- Version information (e.g. "0.7.5", "v1.2.0") tags TEXT, -- JSON array @ai-redundant: Also in item_tags related TEXT, -- JSON array ["type-id", ...] @ai-redundant: Also in related_items created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (type, id) ) `); await this.db.runAsync(` CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5( type, title, description, content, tags ) `); await this.db.runAsync(` CREATE INDEX IF NOT EXISTS idx_items_date ON items(start_date) WHERE type = 'sessions' `); await this.db.runAsync(` CREATE INDEX IF NOT EXISTS idx_items_status ON items(status_id) `); await this.db.runAsync(` CREATE INDEX IF NOT EXISTS idx_items_priority ON items(priority) `); await this.db.runAsync(` CREATE INDEX IF NOT EXISTS idx_items_version ON items(version) `); } async createTagRelationshipTables() { await this.db.runAsync(` CREATE TABLE IF NOT EXISTS item_tags ( item_type TEXT NOT NULL, item_id TEXT NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (item_type, item_id, tag_id), FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ) `); await this.db.runAsync(` CREATE INDEX IF NOT EXISTS idx_item_tags_tag ON item_tags(tag_id) `); await this.db.runAsync(` CREATE TABLE IF NOT EXISTS related_items ( source_type TEXT NOT NULL, source_id TEXT NOT NULL, target_type TEXT NOT NULL, target_id TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (source_type, source_id, target_type, target_id) ) `); await this.db.runAsync(` CREATE INDEX IF NOT EXISTS idx_related_source ON related_items(source_type, source_id) `); await this.db.runAsync(` CREATE INDEX IF NOT EXISTS idx_related_target ON related_items(target_type, target_id) `); } async createTypeFieldsTable() { await this.db.runAsync(` CREATE TABLE IF NOT EXISTS type_fields ( type TEXT NOT NULL, field_name TEXT NOT NULL, field_type TEXT NOT NULL, -- 'string', 'text', 'date', 'priority', 'status', 'tags', 'related' required BOOLEAN DEFAULT 0, default_value TEXT, description TEXT, PRIMARY KEY (type, field_name) ) `); const defaultFields = [ { types: ['issues', 'plans', 'docs', 'knowledge', 'sessions', 'dailies'], fields: [ { name: 'id', type: 'string', required: true, default: '', desc: 'Unique identifier' }, { name: 'title', type: 'string', required: true, default: '', desc: 'Title of the item' }, { name: 'description', type: 'string', required: false, default: '', desc: 'Brief description' }, { name: 'version', type: 'string', required: false, default: '', desc: 'Version information' }, { name: 'tags', type: 'tags', required: false, default: '[]', desc: 'Tags for categorization' }, { name: 'created_at', type: 'date', required: true, default: '', desc: 'Creation timestamp' }, { name: 'updated_at', type: 'date', required: true, default: '', desc: 'Last update timestamp' } ] }, { types: ['issues', 'plans'], fields: [ { name: 'content', type: 'text', required: true, default: '', desc: 'Main content' }, { name: 'priority', type: 'priority', required: false, default: 'medium', desc: 'Priority level' }, { name: 'status', type: 'status', required: false, default: 'Open', desc: 'Current status' }, { name: 'start_date', type: 'date', required: false, default: '', desc: 'Start date' }, { name: 'end_date', type: 'date', required: false, default: '', desc: 'End date' }, { name: 'related_tasks', type: 'related', required: false, default: '[]', desc: 'Related tasks' }, { name: 'related_documents', type: 'related', required: false, default: '[]', desc: 'Related documents' } ] }, { types: ['docs', 'knowledge'], fields: [ { name: 'content', type: 'text', required: true, default: '', desc: 'Document content' }, { name: 'priority', type: 'priority', required: false, default: 'medium', desc: 'Document importance' }, { name: 'status', type: 'status', required: false, default: 'Open', desc: 'Document status' }, { name: 'related_documents', type: 'related', required: false, default: '[]', desc: 'Related documents' } ] }, { types: ['sessions'], fields: [ { name: 'content', type: 'text', required: false, default: '', desc: 'Session notes' }, { name: 'related_tasks', type: 'related', required: false, default: '[]', desc: 'Related tasks' }, { name: 'related_documents', type: 'related', required: false, default: '[]', desc: 'Related documents' } ] }, { types: ['dailies'], fields: [ { name: 'content', type: 'text', required: true, default: '', desc: 'Daily summary content' }, { name: 'related_tasks', type: 'related', required: false, default: '[]', desc: 'Related tasks' }, { name: 'related_documents', type: 'related', required: false, default: '[]', desc: 'Related documents' } ] } ]; for (const group of defaultFields) { for (const type of group.types) { for (const field of group.fields) { await this.db.runAsync(` INSERT OR IGNORE INTO type_fields (type, field_name, field_type, required, default_value, description) VALUES (?, ?, ?, ?, ?, ?) `, [type, field.name, field.type, field.required ? 1 : 0, field.default, field.desc]); } } } } async createIndexes() { await this.db.runAsync('CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name)'); await this.db.runAsync('CREATE INDEX IF NOT EXISTS idx_items_type ON items(type)'); await this.db.runAsync('CREATE INDEX IF NOT EXISTS idx_items_type_status ON items(type, status_id)'); await this.db.runAsync('CREATE INDEX IF NOT EXISTS idx_items_type_priority ON items(type, priority)'); await this.db.runAsync('CREATE INDEX IF NOT EXISTS idx_item_tags_item ON item_tags(item_type, item_id)'); await this.db.runAsync('CREATE INDEX IF NOT EXISTS idx_type_fields_type ON type_fields(type)'); try { await this.db.runAsync('ALTER TABLE statuses ADD COLUMN updated_at TEXT DEFAULT CURRENT_TIMESTAMP'); this.logger.debug('Added updated_at column to statuses table'); } catch (err) { if (!err.message.includes('duplicate column name')) { this.logger.error('Error adding updated_at to statuses:', err); } } try { await this.db.runAsync('ALTER TABLE items ADD COLUMN version TEXT'); this.logger.debug('Added version column to items table'); } catch (err) { if (!err.message.includes('duplicate column name')) { this.logger.error('Error adding version to items:', err); } } } getDatabase() { if (!this.db) { throw new Error('Database not initialized'); } return this.db; } async close() { if (!this.db) { return; } this.initializationComplete = false; this.initializationPromise = null; return new Promise((resolve) => { this.db.close((err) => { if (err) { this.logger.error('Error closing database:', err); } else { this.logger.debug('Database connection closed'); } this.db = null; resolve(); }); }); } isInitialized() { return this.initializationComplete; } }