UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

582 lines (581 loc) 27.5 kB
/** * MCP Handlers — Cursor-Native Wiki 生成 * * - wikiPlan: 数据收集 + 主题发现 → 返回写作规划 * - wikiFinalize: Agent 写完所有文章后调用 → meta.json + 去重 + 验证 * * 设计理念: * 现有 WikiGenerator 的核心价值在于 **数据收集 + 主题发现**(AST、模块图、知识库)。 * 文章撰写由外部 Agent 完成(200K+ context),AutoSnippet 只做规划和元数据。 * bootstrap Phase 1-4 的分析缓存可被 wikiPlan 复用,避免重复计算。 * * @module handlers/wiki-external */ import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import Logger from '#infra/logging/Logger.js'; import { WikiGenerator } from '#service/wiki/WikiGenerator.js'; import { dedup } from '#service/wiki/WikiUtils.js'; import { DEFAULT_KNOWLEDGE_BASE_DIR } from '#shared/ProjectMarkers.js'; import { resolveProjectRoot } from '#shared/resolveProjectRoot.js'; import { envelope } from '../envelope.js'; import { getActiveSession } from './bootstrap-external.js'; const logger = Logger.getInstance(); // ── 辅助:安全获取容器服务 ────────────────────────────────── function tryGet(container, name) { try { return container.get(name); } catch { return null; } } // ════════════════════════════════════════════════════════════ // wikiRouter — 统一入口 (autosnippet_wiki) // ════════════════════════════════════════════════════════════ /** * 统一 Wiki 路由入口 (autosnippet_wiki) * * @param args.operation 'plan' | 'finalize' */ export async function wikiRouter(ctx, args) { const op = args.operation; if (op === 'finalize') { return wikiFinalize(ctx, args); } return wikiPlan(ctx, args); } // ════════════════════════════════════════════════════════════ // wikiPlan — 规划 Wiki 主题 + 数据包 // ════════════════════════════════════════════════════════════ /** * 规划 Wiki 文档生成 (autosnippet_wiki operation=plan) * * 复用 WikiGenerator 的数据收集和主题发现逻辑(Phase 1-5), * 但不撰写文章,只返回规划清单和每个主题的数据包。 * * @param ctx { container, logger, startedAt } * @param args { language?: 'zh'|'en', sessionId?: string } */ export async function wikiPlan(ctx, args) { const t0 = Date.now(); const language = args.language || 'zh'; const container = ctx.container; const projectRoot = resolveProjectRoot(container); // ── 优先复用 bootstrap 已有的分析缓存 ── // eslint-disable-next-line @typescript-eslint/no-explicit-any -- getActiveSession accepts ServiceContainer, container is McpServiceContainer let projectInfo, astInfo, moduleInfo, knowledgeInfo; let cacheHit = false; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- McpServiceContainer is compatible at runtime const session = getActiveSession(container, args.sessionId); const cachedData = session?.snapshotCache; if (cachedData?.astProjectSummary) { // Bootstrap phase cache → WikiGenerator-compatible format 转换 const allFiles = cachedData.allFiles; const ast = cachedData.astProjectSummary; // projectInfo: 从 bootstrap 文件列表和语言统计构建 const filesByModule = {}; for (const f of allFiles) { const mod = f.targetName || '_default'; if (!filesByModule[mod]) { filesByModule[mod] = []; } filesByModule[mod].push(f.relativePath); } projectInfo = { name: path.basename(projectRoot), root: projectRoot, sourceFiles: allFiles.map((f) => f.relativePath), languages: cachedData.langStats || {}, primaryLanguage: cachedData.primaryLang || 'unknown', sourceFilesByModule: filesByModule, buildSystems: [], }; // astInfo: 从 AstAnalyzer 结果构建 const classesByModule = {}; const protocolsByModule = {}; for (const cls of ast.classes || []) { const mod = cls.targetName || '_default'; if (!classesByModule[mod]) { classesByModule[mod] = []; } classesByModule[mod].push(cls.name); } for (const p of ast.protocols || []) { const mod = p.targetName || '_default'; if (!protocolsByModule[mod]) { protocolsByModule[mod] = []; } protocolsByModule[mod].push(p.name); } astInfo = { classes: (ast.classes || []).map((c) => c.name), protocols: (ast.protocols || []).map((p) => p.name), overview: ast.projectMetrics || null, classNamesByModule: classesByModule, protocolNamesByModule: protocolsByModule, }; // moduleInfo: 从依赖图和 targets 构建 moduleInfo = { targets: (cachedData.targetsSummary || []).map((t) => ({ name: t.name, type: t.type, fileCount: t.fileCount, })), depGraph: cachedData.depGraphData || null, }; // knowledgeInfo: 始终从 DB 获取最新(bootstrap 期间可能已写入知识) try { const ks = tryGet(container, 'knowledgeService'); if (ks) { const items = await ks.list({ limit: 200 }); const stats = typeof ks.getStats === 'function' ? await ks.getStats() : null; knowledgeInfo = { recipes: (items?.items || []), stats }; } else { knowledgeInfo = { recipes: [], stats: null }; } } catch { knowledgeInfo = { recipes: [], stats: null }; } cacheHit = true; logger.info('[wiki-plan] Reusing bootstrap phase cache (converted to WikiGenerator format)'); } else { // 无缓存(独立调用 wiki_plan 或进程已重启)→ 重新扫描 logger.info('[wiki-plan] No bootstrap cache, running fresh scan...'); const generator = new WikiGenerator({ projectRoot, moduleService: tryGet(container, 'moduleService'), knowledgeService: tryGet(container, 'knowledgeService'), projectGraph: tryGet(container, 'projectGraph'), codeEntityGraph: tryGet(container, 'codeEntityGraph'), aiProvider: null, // 不需要 AI — 只做规划 options: { language }, }); projectInfo = await generator._scanProject(); astInfo = await generator._analyzeAST(); moduleInfo = await generator._parseModules(); knowledgeInfo = await generator._integrateKnowledge(); } // ── 主题发现(复用 WikiGenerator._discoverTopics) ── const generator = new WikiGenerator({ projectRoot, moduleService: tryGet(container, 'moduleService'), knowledgeService: tryGet(container, 'knowledgeService'), projectGraph: tryGet(container, 'projectGraph'), codeEntityGraph: tryGet(container, 'codeEntityGraph'), aiProvider: null, options: { language }, }); const rawTopics = generator._discoverTopics(projectInfo, astInfo, moduleInfo, knowledgeInfo); // ── 为每个主题构建 dataBundle ── const structuredData = { projectInfo: projectInfo || {}, astInfo: astInfo || {}, moduleInfo: moduleInfo || {}, knowledgeInfo: knowledgeInfo || { recipes: [], stats: null }, }; const isZh = language === 'zh'; const topics = rawTopics.map((topic) => { const mapped = { id: topic.id, path: topic.path, title: topic.title, type: topic.type, priority: topic.priority, writingGuide: _buildWritingGuide(topic, isZh), dataBundle: _buildTopicDataBundle(topic, structuredData), }; // 添加其他主题引用(供导航链接) mapped.dataBundle.otherTopicPaths = rawTopics .filter((t) => t.id !== topic.id) .map((t) => ({ path: t.path, title: t.title })); return mapped; }); // ── 确保 Wiki 目录存在 ── const wikiDir = path.join(projectRoot, DEFAULT_KNOWLEDGE_BASE_DIR, 'wiki'); _ensureDir(wikiDir); if (topics.some((t) => t.path.startsWith('modules/'))) { _ensureDir(path.join(wikiDir, 'modules')); } if (topics.some((t) => t.path.startsWith('patterns/'))) { _ensureDir(path.join(wikiDir, 'patterns')); } if (topics.some((t) => t.path.startsWith('folders/'))) { _ensureDir(path.join(wikiDir, 'folders')); } return envelope({ success: true, data: { wikiDir: path.join(DEFAULT_KNOWLEDGE_BASE_DIR, 'wiki'), absoluteWikiDir: wikiDir, topicCount: topics.length, topics, writingGuidelines: _buildWritingGuidelines(isZh), cacheHit, }, meta: { tool: 'autosnippet_wiki_plan', responseTimeMs: Date.now() - t0, }, }); } // ════════════════════════════════════════════════════════════ // wikiFinalize — 写入 meta.json + 去重 + 验证 // ════════════════════════════════════════════════════════════ /** * 完成 Wiki 生成 (autosnippet_wiki_finalize) * * Agent 写完所有文章后调用。负责: * 1. 验证文件存在性 * 2. 去重检查(内容相似度) * 3. 写入 meta.json * 4. 同步 Cursor 端文档(可选) * * @param ctx { container, logger, startedAt } * @param args { articlesWritten: string[] } */ export async function wikiFinalize(ctx, args) { const t0 = Date.now(); const { articlesWritten } = args; if (!Array.isArray(articlesWritten) || articlesWritten.length === 0) { return envelope({ success: false, message: 'articlesWritten is required and must be a non-empty array of file paths', errorCode: 'VALIDATION_ERROR', meta: { tool: 'autosnippet_wiki_finalize' }, }); } const container = ctx.container; const projectRoot = resolveProjectRoot(container); const wikiDir = path.join(projectRoot, DEFAULT_KNOWLEDGE_BASE_DIR, 'wiki'); // ── 1. 验证文件存在性 ── const missingFiles = []; const thinFiles = []; const fileDetails = []; let totalSize = 0; for (const relPath of articlesWritten) { const fullPath = path.join(wikiDir, relPath); // 安全检查 — 防路径遍历 const resolved = path.resolve(fullPath); if (!resolved.startsWith(path.resolve(wikiDir))) { missingFiles.push(relPath); continue; } if (!fs.existsSync(fullPath)) { missingFiles.push(relPath); continue; } const stat = fs.statSync(fullPath); const content = fs.readFileSync(fullPath, 'utf-8'); totalSize += stat.size; if (content.length < 200) { thinFiles.push(relPath); } fileDetails.push({ path: relPath, size: stat.size, hash: createHash('md5').update(content).digest('hex'), }); } // ── 2. 去重检查 ── let dedupResult = { removed: [], kept: 0 }; try { const files = fileDetails.map((f) => ({ path: f.path, hash: f.hash, size: f.size, })); dedupResult = dedup(files, wikiDir, () => { }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.warn(`[wiki-finalize] Dedup check failed: ${msg}`); } // ── 3. 写入 meta.json ── // 计算 sourceHash — 与 WikiGenerator._computeSourceHash() 保持一致 // 使得 getStatus()._detectChanges() 对比时能正确判定"无变更" let sourceHash; try { const generator = new WikiGenerator({ projectRoot, options: { language: 'zh' }, }); sourceHash = generator._computeSourceHash(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.warn(`[wiki-finalize] Failed to compute sourceHash: ${msg}`); } const meta = { generatedAt: new Date().toISOString(), version: '3.0-cursor-native', source: 'external-agent', filesCount: fileDetails.length, totalSize, files: fileDetails, ...(sourceHash ? { sourceHash } : {}), }; try { _ensureDir(wikiDir); fs.writeFileSync(path.join(wikiDir, 'meta.json'), JSON.stringify(meta, null, 2)); } catch (e) { const msg = e instanceof Error ? e.message : String(e); return envelope({ success: false, message: `Failed to write meta.json: ${msg}`, errorCode: 'IO_ERROR', meta: { tool: 'autosnippet_wiki_finalize' }, }); } // ── 4. 同步 Cursor 端文档(仅检测,不修改 Agent 写的内容)── let syncedDocs = 0; try { const devdocsDir = path.join(projectRoot, '.cursor', 'skills', 'autosnippet-devdocs', 'references'); if (fs.existsSync(devdocsDir)) { const docsDir = path.join(wikiDir, 'documents'); _ensureDir(docsDir); const mdFiles = fs.readdirSync(devdocsDir).filter((f) => f.endsWith('.md')); for (const file of mdFiles) { const src = path.join(devdocsDir, file); const dest = path.join(docsDir, file); if (!fs.existsSync(dest)) { // 只同步 Agent 没写的文档 const content = fs.readFileSync(src, 'utf-8'); const header = `<!-- synced from .cursor/skills/autosnippet-devdocs/references/${file} -->\n\n`; fs.writeFileSync(dest, header + content); syncedDocs++; } } } } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.debug(`[wiki-finalize] Cursor docs sync skipped: ${msg}`); } return envelope({ success: true, data: { fileCount: fileDetails.length, totalSize: `${(totalSize / 1024).toFixed(1)} KB`, dedup: dedupResult, validation: { missingFiles, thinFiles, passed: missingFiles.length === 0, }, syncedDocs, meta, }, meta: { tool: 'autosnippet_wiki_finalize', responseTimeMs: Date.now() - t0, }, }); } // ════════════════════════════════════════════════════════════ // 内部辅助函数 // ════════════════════════════════════════════════════════════ function _ensureDir(dir) { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } /** 为主题生成写作指南 */ function _buildWritingGuide(topic, isZh) { const guides = { overview: isZh ? '撰写完整的项目概述文档。包含: 项目简介(解释项目做什么)、模块总览(表格形式)、技术栈分析、核心数据指标。底部包含导航索引,链接到其他 wiki 文档。' : 'Write a comprehensive project overview. Include: project introduction (what it does), module overview (table format), tech stack analysis, key metrics. Add navigation index at bottom linking to other wiki docs.', architecture: isZh ? '撰写项目架构文档。包含: 整体架构图(描述层次关系)、模块职责划分、模块间依赖关系(文字描述或 Mermaid 图)、核心设计决策、扩展点说明。' : 'Write architecture documentation. Include: overall architecture diagram (layer relationships), module responsibilities, inter-module dependencies (Mermaid diagrams), core design decisions, extension points.', 'getting-started': isZh ? '撰写快速上手文档。包含: 环境要求、安装步骤、构建命令、运行方式、项目结构简介。面向项目新成员。' : 'Write getting started guide. Include: prerequisites, installation steps, build commands, how to run, project structure intro. Target new team members.', module: isZh ? '撰写模块深度文档。包含: 模块定位与职责、核心类及其关系、公共 API 概览(主要方法列表)、依赖关系、设计模式、使用示例。' : 'Write module deep-dive documentation. Include: module purpose, core classes and relationships, public API overview, dependencies, design patterns, usage examples.', patterns: isZh ? '基于知识库中的 Recipe 整理代码模式文档。按分类组织,每个模式包含: 名称、触发场景、规则内容、代码示例。' : 'Organize code patterns from knowledge base recipes. Group by category, each pattern includes: name, trigger scenario, rule content, code examples.', 'pattern-category': isZh ? '撰写该分类下的代码模式文档。每个模式包含: 模式名称、应用场景、具体规则、代码示例。' : 'Write code patterns for this category. Each pattern: name, applicable scenario, specific rules, code examples.', reference: isZh ? '撰写协议/接口参考文档。按功能分组,每个协议包含: 名称、职责描述、方法签名列表、实现类。' : 'Write protocol/interface reference. Group by function, each includes: name, responsibility, method signatures, implementations.', 'folder-overview': isZh ? '撰写项目结构分析文档。概述各个重要目录的功能定位、文件组织方式、命名规范。' : 'Write project structure analysis. Overview important directory purposes, file organization, naming conventions.', 'folder-profile': isZh ? '撰写该目录的详细分析文档。包含: 目录职责、文件列表与说明、入口点、命名模式、与其他目录的关系。' : 'Write detailed directory analysis. Include: purpose, file list with descriptions, entry points, naming patterns, relationships with other directories.', }; return (guides[topic.type] || (isZh ? '撰写详细的技术文档,结构清晰,内容准确。' : 'Write detailed technical documentation with clear structure and accurate content.')); } /** 为主题构建数据包 */ function _buildTopicDataBundle(topic, structuredData) { const { projectInfo, astInfo, moduleInfo, knowledgeInfo } = structuredData; const bundle = {}; // Helper: safely access array-like from Record<string, unknown> const arr = (obj, key) => (Array.isArray(obj[key]) ? obj[key] : []); const rec = (obj, key) => (obj[key] && typeof obj[key] === 'object' ? obj[key] : {}); switch (topic.type) { case 'overview': bundle.projectName = projectInfo.name; bundle.sourceFileCount = arr(projectInfo, 'sourceFiles').length; bundle.primaryLanguage = projectInfo.primaryLanguage; bundle.langProfile = projectInfo.langProfile; bundle.buildSystems = projectInfo.buildSystems; bundle.languages = projectInfo.languages; bundle.moduleCount = arr(moduleInfo, 'targets').length; bundle.moduleList = arr(moduleInfo, 'targets').map((t) => ({ name: t.name, type: t.type || rec(t, 'info').type || 'unknown', fileCount: t.sourceFileCount || rec(t, 'info').sourceFileCount || 0, dependencies: (arr(t, 'dependencies').length > 0 ? arr(t, 'dependencies') : arr(rec(t, 'info'), 'dependencies')).slice(0, 10), })); bundle.astOverview = astInfo.overview || {}; bundle.recipeCount = knowledgeInfo.recipes?.length || 0; break; case 'architecture': bundle.modules = arr(moduleInfo, 'targets').map((t) => ({ name: t.name, type: t.type || rec(t, 'info').type || 'unknown', path: t.path || rec(t, 'info').path || '', dependencies: t.dependencies || rec(t, 'info').dependencies || [], })); bundle.depGraph = moduleInfo.depGraph ? { nodes: arr(rec(moduleInfo, 'depGraph'), 'nodes').length, edges: arr(rec(moduleInfo, 'depGraph'), 'edges').length, } : null; // 热实体信息(高入度类/协议) bundle.classCount = arr(astInfo, 'classes').length; bundle.protocolCount = arr(astInfo, 'protocols').length; bundle.hotClasses = arr(astInfo, 'classes').slice(0, 15); bundle.hotProtocols = arr(astInfo, 'protocols').slice(0, 10); break; case 'getting-started': bundle.projectName = projectInfo.name; bundle.buildSystems = projectInfo.buildSystems; bundle.primaryLanguage = projectInfo.primaryLanguage; bundle.hasPackageSwift = projectInfo.hasPackageSwift; bundle.hasPodfile = projectInfo.hasPodfile; bundle.hasXcodeproj = projectInfo.hasXcodeproj; bundle.entryPoints = arr(rec(astInfo, 'overview'), 'entryPoints'); break; case 'module': { const md = (topic._moduleData || {}); const mdTarget = rec(md, 'target'); bundle.targetInfo = md.target ? { name: mdTarget.name, type: mdTarget.type || 'unknown', path: mdTarget.path || '' } : { name: topic.title }; bundle.classNames = arr(rec(astInfo, 'classNamesByModule'), topic.title).slice(0, 30); bundle.protocolNames = arr(rec(astInfo, 'protocolNamesByModule'), topic.title).slice(0, 15); bundle.sourceFiles = arr(md, 'moduleFiles').slice(0, 30); bundle.classCount = md.classCount || 0; bundle.protoCount = md.protoCount || 0; bundle.dependencies = mdTarget.dependencies || rec(mdTarget, 'info').dependencies || []; break; } case 'patterns': { const groups = {}; for (const r of knowledgeInfo.recipes || []) { const json = typeof r.toJSON === 'function' ? r.toJSON() : r; const cat = json.category || 'Other'; if (!groups[cat]) { groups[cat] = []; } groups[cat].push({ title: json.title || json.name, trigger: json.trigger || json.name, kind: json.kind || 'pattern', summary: json.summary || json.description || '', }); } bundle.recipesByCategory = groups; bundle.totalRecipes = knowledgeInfo.recipes?.length || 0; break; } case 'pattern-category': { const pd = (topic._patternData || {}); bundle.category = pd.category; bundle.recipes = (pd.recipes || []).map((r) => ({ title: r.title || r.name, trigger: r.trigger || r.name, kind: r.kind || 'pattern', summary: r.summary || r.description || '', content: typeof r.content === 'string' ? r.content.substring(0, 500) : '', // 截断长内容 })); break; } case 'reference': bundle.protocols = arr(astInfo, 'protocols').slice(0, 40); bundle.protocolsByModule = astInfo.protocolNamesByModule || {}; break; case 'folder-overview': bundle.folderProfiles = (topic._folderProfiles || []).map((fp) => ({ relPath: fp.relPath, fileCount: fp.fileCount, languages: fp.languages, entryPoints: arr(fp, 'entryPoints').slice(0, 5), namingPatterns: arr(fp, 'namingPatterns').slice(0, 5), hasReadme: !!fp.readme, })); break; case 'folder-profile': { const fp = (topic._folderProfile || {}); bundle.relPath = fp.relPath; bundle.fileCount = fp.fileCount; bundle.languages = fp.languages; bundle.files = arr(fp, 'files').slice(0, 30); bundle.entryPoints = fp.entryPoints || []; bundle.namingPatterns = fp.namingPatterns || []; bundle.imports = arr(fp, 'imports').slice(0, 20); bundle.headerComments = arr(fp, 'headerComments').slice(0, 10); bundle.readme = typeof fp.readme === 'string' ? fp.readme.substring(0, 500) : null; break; } } return bundle; } /** 构建写作指导手册 */ function _buildWritingGuidelines(isZh) { return { language: isZh ? 'zh' : 'en', style: isZh ? '技术文档风格,面向项目新成员。清晰、结构化、有深度。' : 'Technical documentation style targeting new team members. Clear, structured, in-depth.', minChars: 500, format: isZh ? [ 'Markdown 格式,使用 # 标题、## 分节', '适当使用代码块、表格、Mermaid 图', '引用具体文件路径(相对于项目根目录)', '每篇文章底部包含相关文档链接', ] : [ 'Markdown format with # titles and ## sections', 'Use code blocks, tables, and Mermaid diagrams where appropriate', 'Reference specific file paths (relative to project root)', 'Include related document links at the bottom of each article', ], navigation: isZh ? '每篇文章末尾添加 "## 相关文档" 节,链接到其他 wiki 页面' : 'Add a "## Related Documents" section at the end, linking to other wiki pages', }; }