Skip to main content
Glama

Scratchpad MCP

by pc035860
ScratchpadDatabase.ts44.4 kB
/** * Main database class for Scratchpad MCP Server */ import Database from 'better-sqlite3'; import { randomUUID } from 'crypto'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); import { initializeSchema, checkSchemaVersion, hasFTS5Support, validateFTS5Index, rebuildFTS5Index, } from './schema.js'; import type { Workflow, WorkflowDbRow, Scratchpad, SearchResult, DatabaseConfig, CreateWorkflowParams, CreateScratchpadParams, AppendScratchpadParams, ListScratchpadsParams, SearchScratchpadsParams, } from './types.js'; import { assertScratchpad, assertWorkflowDbRow } from './types.js'; export class ScratchpadDatabase { private db: Database.Database; private hasFTS5: boolean = false; private hasSimpleTokenizer = false; // Simple 中文分詞擴展可用性 private hasJiebaTokenizer = false; // Jieba 結巴分詞功能可用性 private readonly MAX_SCRATCHPAD_SIZE = 1024 * 1024; // 1MB private readonly MAX_SCRATCHPADS_PER_WORKFLOW = 50; /** * 嘗試載入 Simple 中文分詞擴展 */ private loadSimpleTokenizer(): void { try { // 智慧檢測:如果在 dist/ 目錄中運行,調整路徑深度 const isInDist = __dirname.includes('/dist'); const relativePath = isInDist ? '../extensions/libsimple' : '../../extensions/libsimple'; const extensionPath = path.resolve(__dirname, relativePath); // 嘗試載入擴展 this.db.loadExtension(extensionPath); // 測試 simple 函數是否可用 const rawTestResult = this.db.prepare("SELECT simple_query('test') as result").get(); // 直接檢查結果而不使用 assertVersionResult(它期望 value 欄位但我們有 result 欄位) if ( rawTestResult && typeof rawTestResult === 'object' && 'result' in rawTestResult && typeof rawTestResult.result === 'string' && rawTestResult.result.length > 0 ) { this.hasSimpleTokenizer = true; console.log('✅ Simple 中文分詞擴展載入成功'); } else { throw new Error('simple_query returned invalid result'); } } catch (error) { console.warn( '⚠️ Simple 擴展載入失敗,將使用預設 tokenizer:', error instanceof Error ? error.message : error ); this.hasSimpleTokenizer = false; } } constructor(config: DatabaseConfig) { this.db = new Database(config.filename, { readonly: config.readonly ?? false, timeout: config.timeout ?? 30000, }); // Configure database performance settings // 使用 WAL 模式實現最佳併發效能,與 FTS5 完全相容 this.db.pragma('journal_mode = WAL'); this.db.pragma('synchronous = NORMAL'); this.db.pragma('cache_size = 1000'); this.db.pragma('temp_store = memory'); // WAL 模式特定優化 this.db.pragma('wal_autocheckpoint = 1000'); // 每 1000 頁自動 checkpoint // 確保 FTS5 觸發器在 WAL 模式下正常運作 // SQLite 3.31+ 的 trusted_schema 預設值因環境而異: // - better-sqlite3 程式庫預設為 ON(向後相容) // - sqlite3 命令列工具 3.42+ 預設為 OFF(安全考量) // 本地 MCP 伺服器應用,schema 完全可信,明確設為 ON this.db.pragma('trusted_schema = ON'); // 啟動時執行更可靠的 checkpoint 策略 try { this.db.pragma('wal_checkpoint(TRUNCATE)'); // 強制清空 WAL,確保啟動時一致性 console.debug('WAL checkpoint (TRUNCATE) completed successfully'); } catch (error) { console.warn('TRUNCATE checkpoint failed, trying RESTART:', error); try { this.db.pragma('wal_checkpoint(RESTART)'); // 降級到 RESTART 模式 console.debug('WAL checkpoint (RESTART) completed as fallback'); } catch (restartError) { console.warn( 'RESTART checkpoint also failed, continuing with normal operation:', restartError ); // 繼續執行,因為這不是致命錯誤 } } // 嘗試載入 Simple 中文分詞擴展 this.loadSimpleTokenizer(); // Check for FTS5 support this.hasFTS5 = hasFTS5Support(this.db); // Initialize schema if needed if (!checkSchemaVersion(this.db)) { const tokenizer = this.hasSimpleTokenizer ? 'simple' : 'porter unicode61'; initializeSchema(this.db, tokenizer); } // Validate and rebuild FTS5 index if necessary if (this.hasFTS5) { const isValidIndex = validateFTS5Index(this.db); if (!isValidIndex) { console.warn('FTS5 索引驗證失敗,嘗試重建...'); const rebuildSuccess = rebuildFTS5Index(this.db); if (!rebuildSuccess) { console.warn('FTS5 索引重建失敗,將使用 LIKE 搜尋作為後備方案'); this.hasFTS5 = false; } } } // Prepare common statements this.prepareStatements(); } private prepareStatements(): void { // Workflow statements this.insertWorkflow = this.db.prepare(` INSERT INTO workflows (id, name, description, created_at, updated_at, scratchpad_count, is_active, project_scope) VALUES (?, ?, ?, unixepoch(), unixepoch(), 0, 1, ?) `); this.getWorkflow = this.db.prepare(` SELECT * FROM workflows WHERE id = ? `); this.listWorkflows = this.db.prepare(` SELECT * FROM workflows ORDER BY updated_at DESC `); this.updateWorkflowTimestamp = this.db.prepare(` UPDATE workflows SET updated_at = unixepoch() WHERE id = ? `); this.incrementScratchpadCount = this.db.prepare(` UPDATE workflows SET scratchpad_count = scratchpad_count + 1 WHERE id = ? `); // New workflow statements for is_active support this.getLatestActiveWorkflowStmt = this.db.prepare(` SELECT * FROM workflows WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1 `); this.updateWorkflowActiveStatusStmt = this.db.prepare(` UPDATE workflows SET is_active = ?, updated_at = unixepoch() WHERE id = ? `); // Scratchpad statements this.insertScratchpad = this.db.prepare(` INSERT INTO scratchpads (id, workflow_id, title, content, created_at, updated_at, size_bytes) VALUES (?, ?, ?, ?, unixepoch(), unixepoch(), ?) `); this.getScratchpad = this.db.prepare(` SELECT * FROM scratchpads WHERE id = ? `); this.updateScratchpad = this.db.prepare(` UPDATE scratchpads SET content = ?, size_bytes = ?, updated_at = unixepoch() WHERE id = ? `); this.listScratchpadsByWorkflow = this.db.prepare(` SELECT * FROM scratchpads WHERE workflow_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ? `); this.countScratchpadsByWorkflow = this.db.prepare(` SELECT COUNT(*) as count FROM scratchpads WHERE workflow_id = ? `); // Search statements if (this.hasFTS5) { // 根據是否有 simple tokenizer 使用不同的準備語句 if (this.hasSimpleTokenizer) { this.searchScratchpadsFTS = this.db.prepare(` SELECT s.id, s.workflow_id, s.title, s.content, s.created_at, s.updated_at, s.size_bytes, w.id as w_id, w.name as w_name, w.description as w_description, w.created_at as w_created_at, w.updated_at as w_updated_at, w.scratchpad_count as w_scratchpad_count, w.is_active as w_is_active, w.project_scope as w_project_scope, fts.rank FROM scratchpads_fts fts JOIN scratchpads s ON s.rowid = fts.rowid JOIN workflows w ON s.workflow_id = w.id WHERE scratchpads_fts MATCH simple_query(?) AND (? IS NULL OR s.workflow_id = ?) ORDER BY fts.rank LIMIT ? `); } else { this.searchScratchpadsFTS = this.db.prepare(` SELECT s.id, s.workflow_id, s.title, s.content, s.created_at, s.updated_at, s.size_bytes, w.id as w_id, w.name as w_name, w.description as w_description, w.created_at as w_created_at, w.updated_at as w_updated_at, w.scratchpad_count as w_scratchpad_count, w.is_active as w_is_active, w.project_scope as w_project_scope, fts.rank FROM scratchpads_fts fts JOIN scratchpads s ON s.rowid = fts.rowid JOIN workflows w ON s.workflow_id = w.id WHERE scratchpads_fts MATCH ? AND (? IS NULL OR s.workflow_id = ?) ORDER BY fts.rank LIMIT ? `); } } this.searchScratchpadsLike = this.db.prepare(` SELECT s.id, s.workflow_id, s.title, s.content, s.created_at, s.updated_at, s.size_bytes, w.id as w_id, w.name as w_name, w.description as w_description, w.created_at as w_created_at, w.updated_at as w_updated_at, w.scratchpad_count as w_scratchpad_count, w.is_active as w_is_active, w.project_scope as w_project_scope, 1.0 as rank FROM scratchpads s JOIN workflows w ON s.workflow_id = w.id WHERE (s.title LIKE ? OR s.content LIKE ?) ${this.hasFTS5 ? '' : 'AND (? IS NULL OR s.workflow_id = ?)'} ORDER BY s.updated_at DESC LIMIT ? `); // Workflows search statements if (this.hasFTS5) { if (this.hasSimpleTokenizer) { this.searchWorkflowsFTS = this.db.prepare(` SELECT w.id, w.name, w.description, w.created_at, w.updated_at, w.scratchpad_count, w.is_active, w.project_scope, fts.rank FROM workflows_fts fts JOIN workflows w ON w.rowid = fts.rowid WHERE workflows_fts MATCH simple_query(?) AND (? IS NULL OR w.project_scope = ?) ORDER BY fts.rank LIMIT ? `); } else { this.searchWorkflowsFTS = this.db.prepare(` SELECT w.id, w.name, w.description, w.created_at, w.updated_at, w.scratchpad_count, w.is_active, w.project_scope, fts.rank FROM workflows_fts fts JOIN workflows w ON w.rowid = fts.rowid WHERE workflows_fts MATCH ? AND (? IS NULL OR w.project_scope = ?) ORDER BY fts.rank LIMIT ? `); } } this.searchWorkflowsLike = this.db.prepare(` SELECT w.id, w.name, w.description, w.created_at, w.updated_at, w.scratchpad_count, w.is_active, w.project_scope, 1.0 as rank FROM workflows w WHERE (w.name LIKE ? OR COALESCE(w.description, '') LIKE ?) AND (? IS NULL OR w.project_scope = ?) ORDER BY w.updated_at DESC LIMIT ? `); } // Note: sanitizeFTS5Query was removed as it's not used - buildFTS5Query handles escaping /** * 構建安全的 FTS5 搜尋查詢 * 現在直接返回原始查詢,讓 prepared statement 處理參數化 */ private buildFTS5Query(query: string): string { // 如果有 simple 擴展,使用 simple_query() 函數進行智能查詢 if (this.hasSimpleTokenizer) { // 直接返回查詢,讓 simple_query(?) 的參數化處理安全問題 return query; } // 降級到基本 FTS5 查詢 - 使用安全的參數化格式 // 轉義雙引號以防止 FTS5 語法錯誤 const escaped = query.replace(/"/g, '""'); // 在 title 和 content 欄位中搜尋,避免將特殊字符解析為欄位分隔符 return `title:"${escaped}" OR content:"${escaped}"`; } /** * 使用 jieba_query() 進行結巴分詞搜尋 * 提供更智慧的中文語意分析和搜尋體驗 */ private searchWithJieba( query: string, workflowId?: string, limit: number = 20 ): Array<{ id: string; workflow_id: string; title: string; content: string; created_at: number; updated_at: number; size_bytes: number; w_id: string; w_name: string; w_description: string | null; w_created_at: number; w_updated_at: number; w_scratchpad_count: number; w_is_active: number; w_project_scope: string | null; rank: number; }> { // 使用參數化查詢防止 SQL 注入 const sql = ` SELECT s.id, s.workflow_id, s.title, s.content, s.created_at, s.updated_at, s.size_bytes, w.id as w_id, w.name as w_name, w.description as w_description, w.created_at as w_created_at, w.updated_at as w_updated_at, w.scratchpad_count as w_scratchpad_count, w.is_active as w_is_active, w.project_scope as w_project_scope, fts.rank FROM scratchpads_fts fts JOIN scratchpads s ON s.rowid = fts.rowid JOIN workflows w ON s.workflow_id = w.id WHERE scratchpads_fts MATCH jieba_query(?) AND (? IS NULL OR s.workflow_id = ?) ORDER BY fts.rank LIMIT ? `; const stmt = this.db.prepare(sql); return stmt.all(query, workflowId, workflowId, limit) as Array<{ id: string; workflow_id: string; title: string; content: string; created_at: number; updated_at: number; size_bytes: number; w_id: string; w_name: string; w_description: string | null; w_created_at: number; w_updated_at: number; w_scratchpad_count: number; w_is_active: number; w_project_scope: string | null; rank: number; }>; } /** * 檢查 FTS5 健康狀態 * 如果 FTS5 出現問題,自動降級到 LIKE 搜尋 */ private checkFTS5Health(): boolean { if (!this.hasFTS5) return false; try { // 嘗試簡單的 FTS5 操作 this.db.prepare('SELECT COUNT(*) FROM scratchpads_fts LIMIT 1').get(); // 如果使用 simple tokenizer,也要檢查 simple 函數 if (this.hasSimpleTokenizer) { try { this.db.prepare("SELECT simple_query('test') as result").get(); // 重新啟用 jieba 檢查,字典路徑已透過符號連結解決 try { // 測試 jieba_query() 是否正常工作 - 使用更安全的測試方式 const rawTestResult = this.db.prepare("SELECT jieba_query('test') as result").get(); // 直接檢查結果而不使用 assertVersionResult if ( rawTestResult && typeof rawTestResult === 'object' && 'result' in rawTestResult && typeof rawTestResult.result === 'string' && rawTestResult.result.length > 0 ) { this.hasJiebaTokenizer = true; console.log('✅ Jieba 結巴分詞功能完全可用'); } else { throw new Error('jieba_query returned empty result'); } } catch (jiebaError) { console.warn( '⚠️ Jieba 功能不可用,使用 simple_query:', jiebaError instanceof Error ? jiebaError.message : jiebaError ); this.hasJiebaTokenizer = false; } } catch (simpleError) { console.warn('⚠️ Simple 擴展函數測試失敗,降級到基本 FTS5:', simpleError); this.hasSimpleTokenizer = false; this.hasJiebaTokenizer = false; // Simple 不可用時,jieba 也不可用 } } return true; } catch (error) { console.warn('FTS5 健康檢查失敗,切換到 LIKE 搜尋:', error); this.hasFTS5 = false; this.hasSimpleTokenizer = false; return false; } } // New workflow-related statements private getLatestActiveWorkflowStmt!: Database.Statement<[]>; private updateWorkflowActiveStatusStmt!: Database.Statement<[number, string]>; // Statement properties private insertWorkflow!: Database.Statement<[string, string, string | null, string | null]>; private getWorkflow!: Database.Statement<[string]>; private listWorkflows!: Database.Statement<[]>; private updateWorkflowTimestamp!: Database.Statement<[string]>; private incrementScratchpadCount!: Database.Statement<[string]>; private insertScratchpad!: Database.Statement<[string, string, string, string, number]>; private getScratchpad!: Database.Statement<[string]>; private updateScratchpad!: Database.Statement<[string, number, string]>; private listScratchpadsByWorkflow!: Database.Statement<[string, number, number]>; private countScratchpadsByWorkflow!: Database.Statement<[string]>; private searchScratchpadsFTS?: Database.Statement<[string, string | null, string | null, number]>; private searchScratchpadsLike!: Database.Statement< [string, string, string | null, string | null, number] >; private searchWorkflowsFTS?: Database.Statement<[string, string | null, string | null, number]>; private searchWorkflowsLike!: Database.Statement<[string, string, string | null, number]>; /** * Create a new workflow */ createWorkflow(params: CreateWorkflowParams): Workflow { const id = randomUUID(); const now = Math.floor(Date.now() / 1000); this.insertWorkflow.run( id, params.name, params.description ?? null, params.project_scope ?? null ); return { id, name: params.name, description: params.description ?? null, created_at: now, updated_at: now, scratchpad_count: 0, is_active: true, project_scope: params.project_scope ?? null, }; } /** * Get workflow by ID */ getWorkflowById(id: string): Workflow | null { const rawResult = this.getWorkflow.get(id); const result = assertWorkflowDbRow(rawResult, 'getWorkflowById'); if (!result) { return null; } return { ...result, is_active: Boolean(result.is_active), }; } /** * List all workflows */ getWorkflows(projectScope?: string): Workflow[] { let results: WorkflowDbRow[]; if (projectScope) { // Use project-specific query const scopedStmt = this.db.prepare(` SELECT * FROM workflows WHERE project_scope = ? ORDER BY updated_at DESC `); results = scopedStmt.all(projectScope) as WorkflowDbRow[]; } else { // Use the existing global query results = this.listWorkflows.all() as WorkflowDbRow[]; } return results.map((result) => ({ ...result, is_active: Boolean(result.is_active), })); } /** * Get the latest active workflow */ getLatestActiveWorkflow(projectScope?: string): Workflow | null { let result: WorkflowDbRow | null; if (projectScope) { // Use project-specific query const scopedStmt = this.db.prepare(` SELECT * FROM workflows WHERE is_active = 1 AND project_scope = ? ORDER BY updated_at DESC LIMIT 1 `); const rawResult = scopedStmt.get(projectScope); result = assertWorkflowDbRow(rawResult, 'getLatestActiveWorkflow with projectScope'); } else { // Use the existing global query const rawResult = this.getLatestActiveWorkflowStmt.get(); result = assertWorkflowDbRow(rawResult, 'getLatestActiveWorkflow global'); } if (!result) { return null; } return { ...result, is_active: Boolean(result.is_active), }; } /** * Update workflow active status */ setWorkflowActiveStatus(id: string, isActive: boolean): Workflow | null { const existing = this.getWorkflowById(id); if (!existing) { throw new Error(`Workflow not found: ${id}`); } this.updateWorkflowActiveStatusStmt.run(isActive ? 1 : 0, id); return this.getWorkflowById(id); } /** * Create a new scratchpad */ createScratchpad(params: CreateScratchpadParams): Scratchpad { // Validate workflow exists const workflow = this.getWorkflowById(params.workflow_id); if (!workflow) { throw new Error(`Workflow not found: ${params.workflow_id}`); } // Check if workflow is active if (!workflow.is_active) { throw new Error(`Cannot create scratchpad: workflow is not active: ${params.workflow_id}`); } // Check size limit const sizeBytes = Buffer.byteLength(params.content, 'utf8'); if (sizeBytes > this.MAX_SCRATCHPAD_SIZE) { throw new Error( `Scratchpad content too large: ${sizeBytes} bytes (max: ${this.MAX_SCRATCHPAD_SIZE} bytes)` ); } // Check scratchpad count limit const count = this.countScratchpadsByWorkflow.get(params.workflow_id) as { count: number }; if (count.count >= this.MAX_SCRATCHPADS_PER_WORKFLOW) { throw new Error( `Too many scratchpads in workflow: ${count.count} (max: ${this.MAX_SCRATCHPADS_PER_WORKFLOW})` ); } const id = randomUUID(); const now = Math.floor(Date.now() / 1000); // Use transaction for consistency const transaction = this.db.transaction(() => { this.insertScratchpad.run(id, params.workflow_id, params.title, params.content, sizeBytes); this.incrementScratchpadCount.run(params.workflow_id); this.updateWorkflowTimestamp.run(params.workflow_id); }); transaction(); return { id, workflow_id: params.workflow_id, title: params.title, content: params.content, created_at: now, updated_at: now, size_bytes: sizeBytes, }; } /** * Get scratchpad by ID */ getScratchpadById(id: string): Scratchpad | null { const rawResult = this.getScratchpad.get(id); return assertScratchpad(rawResult, 'getScratchpadById'); } /** * Append content to existing scratchpad */ appendToScratchpad(params: AppendScratchpadParams): Scratchpad { const existing = this.getScratchpadById(params.id); if (!existing) { throw new Error(`Scratchpad not found: ${params.id}`); } // Check if the workflow is active const workflow = this.getWorkflowById(existing.workflow_id); if (!workflow || !workflow.is_active) { throw new Error( `Cannot append to scratchpad: workflow is not active: ${existing.workflow_id}` ); } // Block-based 分隔模板:兩個空行 + 分隔線 + block 標記 const appendTemplate = '\n\n---\n<!--- block start --->\n'; const newContent = existing.content.trim() === '' ? params.content // 首次 append 不需要分隔符 : existing.content + appendTemplate + params.content; const newSizeBytes = Buffer.byteLength(newContent, 'utf8'); if (newSizeBytes > this.MAX_SCRATCHPAD_SIZE) { throw new Error( `Appending would exceed size limit: ${newSizeBytes} bytes (max: ${this.MAX_SCRATCHPAD_SIZE} bytes)` ); } // 使用簡化的事務處理,依賴 SQLite 原生 FTS5 觸發器 // WAL 模式 + FTS5 觸發器需要 PRAGMA trusted_schema = ON(已在構造函數設定) const transaction = this.db.transaction(() => { this.updateScratchpad.run(newContent, newSizeBytes, params.id); this.updateWorkflowTimestamp.run(existing.workflow_id); }); try { transaction(); } catch (error) { // FTS5 觸發器理論上已透過 PRAGMA trusted_schema = ON 解決 // 若仍出錯,記錄警告並降級到 LIKE 搜尋後重試 if (error instanceof Error && error.message.includes('fts')) { console.warn( 'FTS5 觸發器錯誤(已降級至 LIKE 搜尋);請檢查 PRAGMA trusted_schema 設定:', error.message ); this.hasFTS5 = false; // 重試事務(觸發器會自動跳過 FTS5 操作) transaction(); } else { // 重新拋出非 FTS5 相關的錯誤 throw error; } } const updated = this.getScratchpadById(params.id); if (!updated) { throw new Error('Failed to update scratchpad'); } return updated; } /** * Update scratchpad content directly (for operations like chop) */ updateScratchpadContent(id: string, newContent: string): Scratchpad { const existing = this.getScratchpadById(id); if (!existing) { throw new Error(`Scratchpad not found: ${id}`); } // Check if the workflow is active const workflow = this.getWorkflowById(existing.workflow_id); if (!workflow || !workflow.is_active) { throw new Error(`Cannot update scratchpad: workflow is not active: ${existing.workflow_id}`); } const newSizeBytes = Buffer.byteLength(newContent, 'utf8'); // Use transaction for consistency const transaction = this.db.transaction(() => { this.updateScratchpad.run(newContent, newSizeBytes, id); this.updateWorkflowTimestamp.run(existing.workflow_id); }); try { transaction(); } catch (error) { // FTS5 觸發器理論上已透過 PRAGMA trusted_schema = ON 解決 // 若仍出錯,記錄警告並降級到 LIKE 搜尋後重試 if (error instanceof Error && error.message.includes('fts')) { console.warn( 'FTS5 觸發器錯誤(已降級至 LIKE 搜尋);請檢查 PRAGMA trusted_schema 設定:', error.message ); this.hasFTS5 = false; transaction(); // Retry transaction without FTS5 hooks } else { throw error; } } const updated = this.getScratchpadById(id); if (!updated) { throw new Error('Failed to update scratchpad'); } return updated; } /** * List scratchpads in a workflow */ listScratchpads(params: ListScratchpadsParams): Scratchpad[] { const limit = Math.min(params.limit ?? 50, 100); const offset = params.offset ?? 0; return this.listScratchpadsByWorkflow.all(params.workflow_id, limit, offset) as Scratchpad[]; } /** * Search scratchpads using FTS5 or LIKE fallback */ searchScratchpads(params: SearchScratchpadsParams): SearchResult[] { const limit = Math.min(params.limit ?? 20, 50); // 檢查 FTS5 健康狀態並嘗試使用 FTS5 搜尋 if (this.checkFTS5Health()) { try { let results: Array<{ id: string; workflow_id: string; title: string; content: string; created_at: number; updated_at: number; size_bytes: number; w_id: string; w_name: string; w_description: string | null; w_created_at: number; w_updated_at: number; w_scratchpad_count: number; w_is_active: number; w_project_scope: string | null; rank: number; }>; // 智慧模式選擇:自動偵測中文內容並選擇最佳搜尋方式 const hasChinese = /[\u4e00-\u9fa5]/.test(params.query); const shouldUseJieba = params.useJieba || (hasChinese && this.hasJiebaTokenizer); if (shouldUseJieba && this.hasJiebaTokenizer) { // 優先使用 jieba_query() 結巴分詞搜尋 - 提供最佳中文搜尋體驗 try { results = this.searchWithJieba(params.query, params.workflow_id, limit); } catch (jiebaError) { console.warn('⚠️ Jieba 搜尋失敗,降級到 simple_query:', jiebaError); // 降級到 simple_query,使用參數化查詢 const sql = ` SELECT s.id, s.workflow_id, s.title, s.content, s.created_at, s.updated_at, s.size_bytes, w.id as w_id, w.name as w_name, w.description as w_description, w.created_at as w_created_at, w.updated_at as w_updated_at, w.scratchpad_count as w_scratchpad_count, w.is_active as w_is_active, w.project_scope as w_project_scope, fts.rank FROM scratchpads_fts fts JOIN scratchpads s ON s.rowid = fts.rowid JOIN workflows w ON s.workflow_id = w.id WHERE scratchpads_fts MATCH simple_query(?) AND (? IS NULL OR s.workflow_id = ?) ORDER BY fts.rank LIMIT ? `; const stmt = this.db.prepare(sql); results = stmt.all( params.query, params.workflow_id, params.workflow_id, limit ) as Array<{ id: string; workflow_id: string; title: string; content: string; created_at: number; updated_at: number; size_bytes: number; w_id: string; w_name: string; w_description: string | null; w_created_at: number; w_updated_at: number; w_scratchpad_count: number; w_is_active: number; w_project_scope: string | null; rank: number; }>; } } else if (this.hasSimpleTokenizer) { // 使用 simple_query() 函數,使用參數化查詢 const sql = ` SELECT s.id, s.workflow_id, s.title, s.content, s.created_at, s.updated_at, s.size_bytes, w.id as w_id, w.name as w_name, w.description as w_description, w.created_at as w_created_at, w.updated_at as w_updated_at, w.scratchpad_count as w_scratchpad_count, w.is_active as w_is_active, w.project_scope as w_project_scope, fts.rank FROM scratchpads_fts fts JOIN scratchpads s ON s.rowid = fts.rowid JOIN workflows w ON s.workflow_id = w.id WHERE scratchpads_fts MATCH simple_query(?) AND (? IS NULL OR s.workflow_id = ?) ORDER BY fts.rank LIMIT ? `; const stmt = this.db.prepare(sql); results = stmt.all(params.query, params.workflow_id, params.workflow_id, limit) as Array<{ id: string; workflow_id: string; title: string; content: string; created_at: number; updated_at: number; size_bytes: number; w_id: string; w_name: string; w_description: string | null; w_created_at: number; w_updated_at: number; w_scratchpad_count: number; w_is_active: number; w_project_scope: string | null; rank: number; }>; } else if (this.searchScratchpadsFTS) { // 使用標準 FTS5 查詢 - 現在安全地直接傳遞參數 if (this.hasSimpleTokenizer) { // simple_query(?) 會安全地處理用戶輸入 results = this.searchScratchpadsFTS.all( params.query, // 直接傳遞原始查詢 params.workflow_id ?? null, params.workflow_id ?? null, limit ) as Array<{ id: string; workflow_id: string; title: string; content: string; created_at: number; updated_at: number; size_bytes: number; w_id: string; w_name: string; w_description: string | null; w_created_at: number; w_updated_at: number; w_scratchpad_count: number; w_is_active: number; w_project_scope: string | null; rank: number; }>; } else { // 基本 FTS5 查詢 - 需要使用構建的安全查詢 const safeQuery = this.buildFTS5Query(params.query); results = this.searchScratchpadsFTS.all( safeQuery, params.workflow_id ?? null, params.workflow_id ?? null, limit ) as Array<{ id: string; workflow_id: string; title: string; content: string; created_at: number; updated_at: number; size_bytes: number; w_id: string; w_name: string; w_description: string | null; w_created_at: number; w_updated_at: number; w_scratchpad_count: number; w_is_active: number; w_project_scope: string | null; rank: number; }>; } } else { throw new Error('No FTS search method available'); } return results.map((row) => ({ scratchpad: { id: row.id, workflow_id: row.workflow_id, title: row.title, content: row.content, created_at: row.created_at, updated_at: row.updated_at, size_bytes: row.size_bytes, }, workflow: { id: row.w_id, name: row.w_name, description: row.w_description, created_at: row.w_created_at, updated_at: row.w_updated_at, scratchpad_count: row.w_scratchpad_count, is_active: Boolean(row.w_is_active), project_scope: row.w_project_scope, }, rank: row.rank, })); } catch (error) { console.warn('FTS5 搜尋失敗,降級到 LIKE 搜尋:', error); this.hasFTS5 = false; // 禁用 FTS5 避免重複錯誤 this.hasSimpleTokenizer = false; // 也禁用 simple 擴展 } } // Fallback to LIKE search (或 FTS5 失敗時的降級搜尋) const searchPattern = `%${params.query}%`; const results = this.searchScratchpadsLike.all( searchPattern, searchPattern, params.workflow_id ?? null, params.workflow_id ?? null, limit ) as Array<{ id: string; workflow_id: string; title: string; content: string; created_at: number; updated_at: number; size_bytes: number; w_id: string; w_name: string; w_description: string | null; w_created_at: number; w_updated_at: number; w_scratchpad_count: number; w_is_active: number; w_project_scope: string | null; rank: number; }>; return results.map((row) => ({ scratchpad: { id: row.id, workflow_id: row.workflow_id, title: row.title, content: row.content, created_at: row.created_at, updated_at: row.updated_at, size_bytes: row.size_bytes, }, workflow: { id: row.w_id, name: row.w_name, description: row.w_description, created_at: row.w_created_at, updated_at: row.w_updated_at, scratchpad_count: row.w_scratchpad_count, is_active: Boolean(row.w_is_active), project_scope: row.w_project_scope, }, rank: row.rank, })); } /** * Search workflows using FTS5 or LIKE fallback */ searchWorkflows(params: { query: string; project_scope?: string; limit?: number; }): Array<{ id: string; name: string; description: string | null; created_at: number; updated_at: number; scratchpad_count: number; is_active: boolean; project_scope: string | null; rank: number; }> { const limit = Math.min(params.limit ?? 20, 50); // 檢查 FTS5 健康狀態並嘗試使用 FTS5 搜尋 if (this.checkFTS5Health()) { try { let results: Array<{ id: string; name: string; description: string | null; created_at: number; updated_at: number; scratchpad_count: number; is_active: number; project_scope: string | null; rank: number; }>; // 智慧模式選擇:自動偵測中文內容並選擇最佳搜尋方式 const hasChinese = /[\u4e00-\u9fa5]/.test(params.query); const shouldUseJieba = hasChinese && this.hasJiebaTokenizer; if (shouldUseJieba && this.hasJiebaTokenizer) { // 使用 jieba_query() 結巴分詞搜尋 try { results = this.searchWorkflowsWithJieba(params.query, params.project_scope, limit); } catch (jiebaError) { console.warn('⚠️ Jieba workflows 搜尋失敗,降級到 simple_query:', jiebaError); // 降級到 simple_query if (this.searchWorkflowsFTS) { results = this.searchWorkflowsFTS.all( params.query, params.project_scope ?? null, params.project_scope ?? null, limit ) as Array<{ id: string; name: string; description: string | null; created_at: number; updated_at: number; scratchpad_count: number; is_active: number; project_scope: string | null; rank: number; }>; } else { throw new Error('No workflows FTS search method available'); } } } else if (this.hasSimpleTokenizer && this.searchWorkflowsFTS) { // 使用 simple_query() 函數 results = this.searchWorkflowsFTS.all( params.query, params.project_scope ?? null, params.project_scope ?? null, limit ) as Array<{ id: string; name: string; description: string | null; created_at: number; updated_at: number; scratchpad_count: number; is_active: number; project_scope: string | null; rank: number; }>; } else if (this.searchWorkflowsFTS) { // 使用標準 FTS5 查詢 const safeQuery = this.buildWorkflowsFTS5Query(params.query); results = this.searchWorkflowsFTS.all( safeQuery, params.project_scope ?? null, params.project_scope ?? null, limit ) as Array<{ id: string; name: string; description: string | null; created_at: number; updated_at: number; scratchpad_count: number; is_active: number; project_scope: string | null; rank: number; }>; } else { throw new Error('No FTS workflows search method available'); } return results.map((row) => ({ id: row.id, name: row.name, description: row.description, created_at: row.created_at, updated_at: row.updated_at, scratchpad_count: row.scratchpad_count, is_active: Boolean(row.is_active), project_scope: row.project_scope, rank: row.rank, })); } catch (error) { console.warn('FTS5 workflows 搜尋失敗,降級到 LIKE 搜尋:', error); this.hasFTS5 = false; } } // Fallback to LIKE search const searchPattern = `%${params.query}%`; const results = this.searchWorkflowsLike.all( searchPattern, searchPattern, params.project_scope ?? null, limit ) as Array<{ id: string; name: string; description: string | null; created_at: number; updated_at: number; scratchpad_count: number; is_active: number; project_scope: string | null; rank: number; }>; return results.map((row) => ({ id: row.id, name: row.name, description: row.description, created_at: row.created_at, updated_at: row.updated_at, scratchpad_count: row.scratchpad_count, is_active: Boolean(row.is_active), project_scope: row.project_scope, rank: row.rank, })); } /** * 使用 jieba_query() 進行 workflows 結巴分詞搜尋 */ private searchWorkflowsWithJieba( query: string, projectScope?: string, limit: number = 20 ): Array<{ id: string; name: string; description: string | null; created_at: number; updated_at: number; scratchpad_count: number; is_active: number; project_scope: string | null; rank: number; }> { const sql = ` SELECT w.id, w.name, w.description, w.created_at, w.updated_at, w.scratchpad_count, w.is_active, w.project_scope, fts.rank FROM workflows_fts fts JOIN workflows w ON w.rowid = fts.rowid WHERE workflows_fts MATCH jieba_query(?) AND (? IS NULL OR w.project_scope = ?) ORDER BY fts.rank LIMIT ? `; const stmt = this.db.prepare(sql); return stmt.all(query, projectScope, projectScope, limit) as Array<{ id: string; name: string; description: string | null; created_at: number; updated_at: number; scratchpad_count: number; is_active: number; project_scope: string | null; rank: number; }>; } /** * 構建安全的 workflows FTS5 搜尋查詢 */ private buildWorkflowsFTS5Query(query: string): string { // 如果有 simple 擴展,直接返回查詢 if (this.hasSimpleTokenizer) { return query; } // 降級到基本 FTS5 查詢 const escaped = query.replace(/"/g, '""'); return `name:"${escaped}" OR description:"${escaped}"`; } /** * Close the database connection */ close(): void { this.db.close(); } /** * Get database statistics including WAL mode health monitoring */ getStats(): { totalWorkflows: number; totalScratchpads: number; hasFTS5: boolean; hasSimpleTokenizer: boolean; hasJiebaTokenizer: boolean; walMode: boolean; walSize?: number; ftsIndexHealth: boolean; lastCheckpoint?: string; } { const workflowCount = this.db.prepare('SELECT COUNT(*) as count FROM workflows').get() as { count: number; }; const scratchpadCount = this.db.prepare('SELECT COUNT(*) as count FROM scratchpads').get() as { count: number; }; // WAL 模式監控 const journalMode = this.db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }; const isWalMode = journalMode.journal_mode.toLowerCase() === 'wal'; let walSize: number | undefined; let ftsIndexHealth = true; let lastCheckpoint: string | undefined; if (isWalMode) { try { // 檢查 WAL 檔案大小(頁數) const walInfo = this.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() as { busy: number; log: number; checkpointed: number; }; walSize = walInfo.log; // WAL 檔案中的頁數 lastCheckpoint = new Date().toISOString(); } catch (error) { console.warn('WAL checkpoint 狀態檢查失敗:', error); } } // FTS5 索引健康檢查 if (this.hasFTS5) { try { const mainCount = this.db.prepare('SELECT COUNT(*) as count FROM scratchpads').get() as { count: number; }; const ftsCount = this.db.prepare('SELECT COUNT(*) as count FROM scratchpads_fts').get() as { count: number; }; ftsIndexHealth = mainCount.count === ftsCount.count; } catch (error) { console.warn('FTS5 索引健康檢查失敗:', error); ftsIndexHealth = false; } } return { totalWorkflows: workflowCount.count, totalScratchpads: scratchpadCount.count, hasFTS5: this.hasFTS5, hasSimpleTokenizer: this.hasSimpleTokenizer, hasJiebaTokenizer: this.hasJiebaTokenizer, walMode: isWalMode, ...(walSize !== undefined && { walSize }), ftsIndexHealth, ...(lastCheckpoint !== undefined && { lastCheckpoint }), }; } /** * Perform manual WAL checkpoint to optimize database performance */ performCheckpoint(): { success: boolean; walPages: number; checkpointedPages: number } { try { const result = this.db.prepare('PRAGMA wal_checkpoint(RESTART)').get() as { busy: number; log: number; checkpointed: number; }; return { success: result.busy === 0, walPages: result.log, checkpointedPages: result.checkpointed, }; } catch (error) { console.warn('手動 checkpoint 執行失敗:', error); return { success: false, walPages: 0, checkpointedPages: 0, }; } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pc035860/scratchpad-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server