autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
288 lines (287 loc) • 12 kB
JavaScript
/**
* MCP Handlers — V3 知识条目提交 & 生命周期
* submitKnowledge, submitKnowledgeBatch, knowledgeLifecycle
*/
import { UnifiedValidator } from '#domain/knowledge/UnifiedValidator.js';
import { getDeveloperIdentity } from '#shared/developer-identity.js';
import { resolveProjectRoot } from '#shared/resolveProjectRoot.js';
import { envelope } from '../envelope.js';
// ─── 限流 ──────────────────────────────────────────────────
async function _checkRateLimit(toolName, clientId, container) {
const { checkRecipeSave } = await import('#http/middleware/RateLimiter.js');
const projectRoot = resolveProjectRoot(container);
const limitCheck = checkRecipeSave(projectRoot, clientId || process.env.USER || 'mcp-client');
if (!limitCheck.allowed) {
return envelope({
success: false,
message: `提交过于频繁,请 ${limitCheck.retryAfter}s 后再试。`,
errorCode: 'RATE_LIMIT',
meta: { tool: toolName },
});
}
return null;
}
function _enrichToV3(args, container) {
const data = { ...args };
// 来源标记(非 Cursor 职责)
if (!data.source) {
data.source = 'mcp';
}
// RecipeExtractor 语义标签(程序化)
try {
const recipeExtractor = container?.get?.('recipeExtractor');
if (recipeExtractor) {
const codeForTags = data.content?.pattern || '';
if (codeForTags) {
const extracted = recipeExtractor.extractFromContent(codeForTags, `${data.title || 'unknown'}.${data.language || 'unknown'}`, '');
if (extracted.semanticTags?.length > 0) {
data.tags = [...new Set([...(data.tags || []), ...extracted.semanticTags])];
}
if ((!data.category || data.category === 'Utility') &&
extracted.category &&
extracted.category !== 'general') {
data.category = extracted.category;
}
}
}
}
catch {
/* best effort */
}
return data;
}
// ─── V3 wire format → KnowledgeService.create() ────────────
/**
* 单条知识提交 (autosnippet_submit_knowledge)
*
* MCP wire format → V3 增强 → KnowledgeService.create()
* 增强包括:source='mcp'、reasoning 默认值、Delivery 字段补齐、QualityScorer、语义标签。
*/
export async function submitKnowledge(ctx, args) {
// 限流
const blocked = await _checkRateLimit('autosnippet_submit_knowledge', args.client_id, ctx.container);
if (blocked) {
return blocked;
}
// Recipe-Ready 前置校验 — 使用 UnifiedValidator (统一门控)
// 注意: 必须在 service.create() 之前校验,防止不合格数据入库
const validator = new UnifiedValidator();
const validation = validator.validate(args, { skipUniqueness: true });
const service = ctx.container.get('knowledgeService');
// V3 字段增强
const enrichedData = _enrichToV3(args, ctx.container);
const entry = await service.create(enrichedData, { userId: getDeveloperIdentity() });
// ── QualityScorer 自动评分(R9: create 后置执行)──
try {
await service.updateQuality(entry.id, { userId: 'mcp' });
}
catch {
/* best effort — 不阻塞创建流程 */
}
const data = {
id: entry.id,
lifecycle: entry.lifecycle,
title: entry.title,
kind: entry.kind,
};
if (!validation.pass) {
data.recipeReadyHints = {
ready: false,
missingFields: validation.errors,
suggestions: validation.warnings,
};
}
else if (validation.warnings.length > 0) {
data.recipeReadyHints = {
ready: true,
missingFields: [],
suggestions: validation.warnings,
};
}
return envelope({
success: true,
data,
meta: { tool: 'autosnippet_submit_knowledge' },
});
}
export async function submitKnowledgeBatch(ctx, args) {
if (!args.target_name || !Array.isArray(args.items) || args.items.length === 0) {
throw new Error('需要 target_name 与 items(非空数组)');
}
// 限流
const blocked = await _checkRateLimit('autosnippet_submit_knowledge_batch', args.client_id, ctx.container);
if (blocked) {
return blocked;
}
// 去重(可选)
let items = args.items;
if (args.deduplicate !== false) {
try {
const { aggregateCandidates } = await import('#service/candidate/CandidateAggregator.js');
// 对 title 字段做去重
const readinessItems = items.map((it) => ({
...it,
code: it.content?.pattern || it.code || '',
}));
const result = aggregateCandidates(readinessItems);
// 保留原始 items 顺序中去重后的
if (result.items && result.items.length < items.length) {
const titles = new Set(result.items.map((it) => it.title));
items = items.filter((it) => titles.has(it.title));
}
}
catch (err) {
// CandidateAggregator 加载失败时降级:不去重,但记录日志
const { default: Logger } = await import('#infra/logging/Logger.js');
Logger.getInstance().warn(`[submitKnowledgeBatch] CandidateAggregator 加载失败,跳过去重: ${err instanceof Error ? err.message : String(err)}`);
}
}
const service = ctx.container.get('knowledgeService');
const source = args.source || 'cursor-scan';
let count = 0;
const itemErrors = [];
const rejectedItems = [];
const successIds = []; // 成功入库的 recipe ID 列表,供 dimension_complete 使用
let session = null;
let currentDimId = null;
try {
const sessionManager = ctx.container.get('bootstrapSessionManager');
session = sessionManager?.getSession?.();
if (session?.submissionTracker) {
const progress = session.getProgress();
// 优先使用 Agent 显式传递的 dimensionId,其次推断 remainingDimIds[0]
currentDimId =
args.dimensionId || progress.remainingDimIds[0] || args.target_name || 'unknown';
}
}
catch {
/* best effort */
}
// UnifiedValidator — 统一前置校验
// v3: 注入前序维度已提交的标题,实现跨维度硬去重
let existingTitles;
try {
if (session?.submissionTracker?.getAllSubmittedTitles) {
existingTitles = session.submissionTracker.getAllSubmittedTitles();
}
}
catch {
/* best effort */
}
const validator = new UnifiedValidator(existingTitles ? { existingTitles } : {});
for (let i = 0; i < items.length; i++) {
// ── 严格前置校验:缺少必要字段的条目直接拒绝,不入库 ──
const validation = validator.validate(items[i], { skipUniqueness: false });
if (!validation.pass) {
rejectedItems.push({
index: i,
title: items[i].title || '(untitled)',
missingFields: validation.errors,
suggestions: validation.warnings,
});
// v2: 记录拒绝到 tracker
if (session?.submissionTracker && currentDimId) {
try {
session.submissionTracker.recordRejection(currentDimId, items[i].title || '(untitled)', validation.errors.join(', '));
}
catch {
/* best effort */
}
}
// 记录标题/指纹供后续去重检测
validator.recordSubmission(items[i].title, items[i].content?.pattern);
continue;
}
try {
const itemData = _enrichToV3({ ...items[i], source }, ctx.container);
const entry = await service.create(itemData, { userId: getDeveloperIdentity() });
// ── QualityScorer 自动评分(R9: create 后置执行)──
try {
await service.updateQuality(entry.id, { userId: getDeveloperIdentity() });
}
catch {
/* best effort — 不阻塞批量提交 */
}
count++;
successIds.push(entry.id);
// 记录标题/指纹供后续去重检测
validator.recordSubmission(items[i].title, items[i].content?.pattern);
// v2: 记录成功提交到 tracker
if (session?.submissionTracker && currentDimId && entry?.id) {
try {
session.submissionTracker.recordSubmission(currentDimId, items[i], entry.id);
}
catch {
/* best effort */
}
}
}
catch (err) {
itemErrors.push({
index: i,
title: items[i].title || '(untitled)',
error: err instanceof Error ? err.message : String(err),
});
}
}
const data = {
count,
total: items.length,
targetName: args.target_name,
};
if (successIds.length > 0) {
data.ids = successIds; // recipe ID 列表,供 dimension_complete 的 submittedRecipeIds 使用
}
if (itemErrors.length > 0) {
data.errors = itemErrors;
}
// 被拒绝的条目:告知 Agent 需补齐哪些字段
if (rejectedItems.length > 0) {
const allMissing = [...new Set(rejectedItems.flatMap((it) => it.missingFields))];
data.rejectedItems = rejectedItems;
data.rejectedSummary = {
rejectedCount: rejectedItems.length,
totalCount: items.length,
commonMissingFields: allMissing,
message: `${rejectedItems.length}/${items.length} 条知识条目因缺少必要字段被拒绝(${allMissing.join(', ')})。请一次性补齐所有字段后重新提交被拒绝的条目。`,
};
}
return envelope({
success: true,
data,
message: `已提交 ${count}/${items.length} 条知识条目。`,
meta: { tool: 'autosnippet_submit_knowledge_batch' },
});
}
/**
* 知识条目生命周期操作 (autosnippet_knowledge_lifecycle)
*
* 简化为 3 状态: pending / active / deprecated
* 外部 Agent 允许 reactivate(废弃 → 待审核);发布/废弃由开发者在 Dashboard 操作
* 外部 Agent 也可以通过 submitKnowledge / submitKnowledgeBatch 提交新条目(→ pending)
*/
const MCP_ALLOWED_LIFECYCLE_ACTIONS = new Set(['reactivate']);
export async function knowledgeLifecycle(ctx, args) {
const { id, action } = args;
if (!id || !action) {
throw new Error('需要 id 和 action');
}
if (!MCP_ALLOWED_LIFECYCLE_ACTIONS.has(action)) {
throw new Error(`[PERMISSION_DENIED] 外部 Agent 不允许执行 "${action}" 操作,仅支持: reactivate。发布、废弃等操作请在 Dashboard 中完成。提交新知识请使用 autosnippet_submit_knowledge 工具。`);
}
const service = ctx.container.get('knowledgeService');
const context = { userId: getDeveloperIdentity() };
const entry = await service.reactivate(id, context);
return envelope({
success: true,
data: {
id: entry.id,
lifecycle: entry.lifecycle,
title: entry.title,
action,
},
meta: { tool: 'autosnippet_knowledge_lifecycle' },
});
}
// ─── (已删除: saveDocument — 已合并到 submit_knowledge 统一管线) ──
// ─── (已删除: _toReadinessInput — 统一使用 UnifiedValidator) ──