autosnippet 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -240,7 +240,7 @@ asd install:vscode-copilot # 配置 MCP 和 Copilot 指令
240
240
 
241
241
  ## MCP 工具一览
242
242
 
243
- 38 个 MCP 工具按功能分组(省略了 **autosnippet_** 前缀):
243
+ 39 个 MCP 工具按功能分组(省略了 **autosnippet_** 前缀):
244
244
 
245
245
  | 分类 | 工具 |
246
246
  |------|------|
@@ -248,6 +248,7 @@ asd install:vscode-copilot # 配置 MCP 和 Copilot 指令
248
248
  | **搜索** | `search`(统合入口)、`context_search`(4 层漏斗)、`keyword_search`、`semantic_search` |
249
249
  | **Recipe 浏览** | `list_recipes`、`get_recipe`、`list_rules`、`patterns`、`list_facts`、`recipe_insights`、`confirm_usage` |
250
250
  | **候选管理** | `validate_candidate`、`check_duplicate`、`submit_knowledge`、`submit_knowledge_batch`、`enrich_candidates` |
251
+ | **开发文档** | `save_document` |
251
252
  | **知识图谱** | `graph_query`、`graph_impact`、`graph_path`、`graph_stats` |
252
253
  | **项目结构** | `get_targets`、`get_target_files`、`get_target_metadata` |
253
254
  | **Guard** | `guard_check`、`guard_audit_files`、`scan_project` |
package/bin/cli.js CHANGED
@@ -658,6 +658,7 @@ program
658
658
  console.log(` Channel A (Always-On Rules): ${result.channelA.rulesCount} 条规则 (${result.channelA.tokensUsed} tokens)`);
659
659
  console.log(` Channel B (Smart Rules): ${result.channelB.topicCount} 个主题, ${result.channelB.patternsCount} 个模式 (${result.channelB.totalTokens} tokens)`);
660
660
  console.log(` Channel C (Agent Skills): ${result.channelC.synced} 个 Skills 已同步`);
661
+ console.log(` Channel D (Dev Documents): ${result.channelD?.documentsCount || 0} 篇文档`);
661
662
  if (result.channelC.errors > 0) {
662
663
  console.log(` ⚠️ ${result.channelC.errors} 个错误`);
663
664
  }
@@ -181,7 +181,8 @@ export class UpgradeService {
181
181
  pipeline.deliver()
182
182
  .then(result => {
183
183
  console.log(` ✅ Cursor Delivery: ${result.channelA.rulesCount} rules, ` +
184
- `${result.channelB.topicCount} topics, ${result.channelC.synced} skills`);
184
+ `${result.channelB.topicCount} topics, ${result.channelC.synced} skills, ` +
185
+ `${result.channelD?.documentsCount || 0} documents`);
185
186
  })
186
187
  .catch(err => {
187
188
  console.log(` ⚠️ Cursor Delivery 跳过: ${err.message}`);
@@ -85,6 +85,7 @@ const KIND_MAP = {
85
85
  'call-chain': 'fact',
86
86
  'data-flow': 'fact',
87
87
  'module-dependency': 'fact',
88
+ 'dev-document': 'fact',
88
89
  };
89
90
 
90
91
  /**
@@ -20,6 +20,7 @@ import {
20
20
  import Logger from '../../infrastructure/logging/Logger.js';
21
21
  import { envelope } from './envelope.js';
22
22
  import { TOOLS, TOOL_GATEWAY_MAP } from './tools.js';
23
+ import { wrapHandler } from './errorHandler.js';
23
24
 
24
25
  // ─── Handler 模块 ─────────────────────────────────────────────
25
26
 
@@ -116,55 +117,70 @@ export class McpServer {
116
117
  await this._gatewayGate(name, args);
117
118
 
118
119
  const ctx = this._ctx;
119
- switch (name) {
120
+
121
+ // 查找 handler 并通过 wrapHandler 统一错误处理
122
+ const handler = this._resolveHandler(name);
123
+ if (!handler) throw new Error(`Unknown tool: ${name}`);
124
+
125
+ const wrapped = wrapHandler(name, handler);
126
+ return wrapped(ctx, args);
127
+ }
128
+
129
+ /**
130
+ * 解析工具名到 handler 函数
131
+ * @private
132
+ */
133
+ _resolveHandler(name) {
134
+ const HANDLER_MAP = {
120
135
  // 系统
121
- case 'autosnippet_health': return systemHandlers.health(ctx);
122
- case 'autosnippet_capabilities': return systemHandlers.capabilities();
136
+ autosnippet_health: (ctx) => systemHandlers.health(ctx),
137
+ autosnippet_capabilities: () => systemHandlers.capabilities(),
123
138
  // 搜索
124
- case 'autosnippet_search': return searchHandlers.search(ctx, args);
125
- case 'autosnippet_context_search': return searchHandlers.contextSearch(ctx, args);
126
- case 'autosnippet_keyword_search': return searchHandlers.keywordSearch(ctx, args);
127
- case 'autosnippet_semantic_search': return searchHandlers.semanticSearch(ctx, args);
139
+ autosnippet_search: (ctx, args) => searchHandlers.search(ctx, args),
140
+ autosnippet_context_search: (ctx, args) => searchHandlers.contextSearch(ctx, args),
141
+ autosnippet_keyword_search: (ctx, args) => searchHandlers.keywordSearch(ctx, args),
142
+ autosnippet_semantic_search: (ctx, args) => searchHandlers.semanticSearch(ctx, args),
128
143
  // 知识浏览
129
- case 'autosnippet_list_rules': return browseHandlers.listByKind(ctx, 'rule', args);
130
- case 'autosnippet_list_patterns': return browseHandlers.listByKind(ctx, 'pattern', args);
131
- case 'autosnippet_list_facts': return browseHandlers.listByKind(ctx, 'fact', args);
132
- case 'autosnippet_list_recipes': return browseHandlers.listRecipes(ctx, args);
133
- case 'autosnippet_get_recipe': return browseHandlers.getRecipe(ctx, args);
134
- case 'autosnippet_recipe_insights': return browseHandlers.recipeInsights(ctx, args);
135
- case 'autosnippet_confirm_usage': return browseHandlers.confirmUsage(ctx, args);
144
+ autosnippet_list_rules: (ctx, args) => browseHandlers.listByKind(ctx, 'rule', args),
145
+ autosnippet_list_patterns: (ctx, args) => browseHandlers.listByKind(ctx, 'pattern', args),
146
+ autosnippet_list_facts: (ctx, args) => browseHandlers.listByKind(ctx, 'fact', args),
147
+ autosnippet_list_recipes: (ctx, args) => browseHandlers.listRecipes(ctx, args),
148
+ autosnippet_get_recipe: (ctx, args) => browseHandlers.getRecipe(ctx, args),
149
+ autosnippet_recipe_insights: (ctx, args) => browseHandlers.recipeInsights(ctx, args),
150
+ autosnippet_confirm_usage: (ctx, args) => browseHandlers.confirmUsage(ctx, args),
136
151
  // 项目结构 & 图谱
137
- case 'autosnippet_get_targets': return structureHandlers.getTargets(ctx);
138
- case 'autosnippet_get_target_files': return structureHandlers.getTargetFiles(ctx, args);
139
- case 'autosnippet_get_target_metadata': return structureHandlers.getTargetMetadata(ctx, args);
140
- case 'autosnippet_graph_query': return structureHandlers.graphQuery(ctx, args);
141
- case 'autosnippet_graph_impact': return structureHandlers.graphImpact(ctx, args);
142
- case 'autosnippet_graph_path': return structureHandlers.graphPath(ctx, args);
143
- case 'autosnippet_graph_stats': return structureHandlers.graphStats(ctx);
144
- // 候选校验 & AI 补全(提交已移至 V3 knowledge handlers)
145
- case 'autosnippet_validate_candidate': return candidateHandlers.validateCandidate(ctx, args);
146
- case 'autosnippet_check_duplicate': return candidateHandlers.checkDuplicate(ctx, args);
147
- case 'autosnippet_enrich_candidates': return candidateHandlers.enrichCandidates(ctx, args);
152
+ autosnippet_get_targets: (ctx) => structureHandlers.getTargets(ctx),
153
+ autosnippet_get_target_files: (ctx, args) => structureHandlers.getTargetFiles(ctx, args),
154
+ autosnippet_get_target_metadata: (ctx, args) => structureHandlers.getTargetMetadata(ctx, args),
155
+ autosnippet_graph_query: (ctx, args) => structureHandlers.graphQuery(ctx, args),
156
+ autosnippet_graph_impact: (ctx, args) => structureHandlers.graphImpact(ctx, args),
157
+ autosnippet_graph_path: (ctx, args) => structureHandlers.graphPath(ctx, args),
158
+ autosnippet_graph_stats: (ctx) => structureHandlers.graphStats(ctx),
159
+ // 候选校验 & AI 补全
160
+ autosnippet_validate_candidate: (ctx, args) => candidateHandlers.validateCandidate(ctx, args),
161
+ autosnippet_check_duplicate: (ctx, args) => candidateHandlers.checkDuplicate(ctx, args),
162
+ autosnippet_enrich_candidates: (ctx, args) => candidateHandlers.enrichCandidates(ctx, args),
148
163
  // Guard & 扫描
149
- case 'autosnippet_guard_check': return guardHandlers.guardCheck(ctx, args);
150
- case 'autosnippet_guard_audit_files': return guardHandlers.guardAuditFiles(ctx, args);
151
- case 'autosnippet_scan_project': return guardHandlers.scanProject(ctx, args);
164
+ autosnippet_guard_check: (ctx, args) => guardHandlers.guardCheck(ctx, args),
165
+ autosnippet_guard_audit_files: (ctx, args) => guardHandlers.guardAuditFiles(ctx, args),
166
+ autosnippet_scan_project: (ctx, args) => guardHandlers.scanProject(ctx, args),
152
167
  // Bootstrap 冷启动
153
- case 'autosnippet_bootstrap_knowledge': return bootstrapHandlers.bootstrapKnowledge(ctx, args);
154
- case 'autosnippet_bootstrap_refine': return bootstrapHandlers.bootstrapRefine(ctx, args);
155
- // Skills 加载 & 创建 & 管理 & 推荐
156
- case 'autosnippet_list_skills': return skillHandlers.listSkills();
157
- case 'autosnippet_load_skill': return skillHandlers.loadSkill(ctx, args);
158
- case 'autosnippet_create_skill': return skillHandlers.createSkill(ctx, args);
159
- case 'autosnippet_delete_skill': return skillHandlers.deleteSkill(ctx, args);
160
- case 'autosnippet_update_skill': return skillHandlers.updateSkill(ctx, args);
161
- case 'autosnippet_suggest_skills': return skillHandlers.suggestSkills(ctx);
168
+ autosnippet_bootstrap_knowledge: (ctx, args) => bootstrapHandlers.bootstrapKnowledge(ctx, args),
169
+ autosnippet_bootstrap_refine: (ctx, args) => bootstrapHandlers.bootstrapRefine(ctx, args),
170
+ // Skills
171
+ autosnippet_list_skills: () => skillHandlers.listSkills(),
172
+ autosnippet_load_skill: (ctx, args) => skillHandlers.loadSkill(ctx, args),
173
+ autosnippet_create_skill: (ctx, args) => skillHandlers.createSkill(ctx, args),
174
+ autosnippet_delete_skill: (ctx, args) => skillHandlers.deleteSkill(ctx, args),
175
+ autosnippet_update_skill: (ctx, args) => skillHandlers.updateSkill(ctx, args),
176
+ autosnippet_suggest_skills: (ctx) => skillHandlers.suggestSkills(ctx),
162
177
  // V3 知识条目
163
- case 'autosnippet_submit_knowledge': return knowledgeHandlers.submitKnowledge(ctx, args);
164
- case 'autosnippet_submit_knowledge_batch': return knowledgeHandlers.submitKnowledgeBatch(ctx, args);
165
- case 'autosnippet_knowledge_lifecycle': return knowledgeHandlers.knowledgeLifecycle(ctx, args);
166
- default: throw new Error(`Unknown tool: ${name}`);
167
- }
178
+ autosnippet_submit_knowledge: (ctx, args) => knowledgeHandlers.submitKnowledge(ctx, args),
179
+ autosnippet_submit_knowledge_batch: (ctx, args) => knowledgeHandlers.submitKnowledgeBatch(ctx, args),
180
+ autosnippet_knowledge_lifecycle: (ctx, args) => knowledgeHandlers.knowledgeLifecycle(ctx, args),
181
+ autosnippet_save_document: (ctx, args) => knowledgeHandlers.saveDocument(ctx, args),
182
+ };
183
+ return HANDLER_MAP[name] || null;
168
184
  }
169
185
 
170
186
  /**
@@ -0,0 +1,106 @@
1
+ /**
2
+ * MCP 工具统一错误处理
3
+ *
4
+ * 提供 wrapHandler() 包装函数,将所有 handler 的异常统一转换为
5
+ * envelope 格式的错误响应,确保:
6
+ * 1. 已知业务错误 → 结构化 errorCode + message
7
+ * 2. 未知异常 → 通用 INTERNAL_ERROR + 原始 message
8
+ * 3. 一致的 meta.tool + meta.responseTimeMs
9
+ *
10
+ * @module external/mcp/errorHandler
11
+ */
12
+
13
+ import { envelope } from './envelope.js';
14
+ import {
15
+ ValidationError,
16
+ NotFoundError,
17
+ ConflictError,
18
+ PermissionDenied,
19
+ ConstitutionViolation,
20
+ } from '../../shared/errors/index.js';
21
+ import Logger from '../../infrastructure/logging/Logger.js';
22
+
23
+ const logger = Logger.getInstance();
24
+
25
+ /**
26
+ * 从已知错误类型推断 errorCode
27
+ * @param {Error} err
28
+ * @returns {string}
29
+ */
30
+ function inferErrorCode(err) {
31
+ if (err instanceof ValidationError) return 'VALIDATION_ERROR';
32
+ if (err instanceof NotFoundError) return 'NOT_FOUND';
33
+ if (err instanceof ConflictError) return 'CONFLICT';
34
+ if (err instanceof PermissionDenied) return 'PERMISSION_DENIED';
35
+ if (err instanceof ConstitutionViolation) return 'CONSTITUTION_VIOLATION';
36
+ if (err.code) return err.code;
37
+ return 'INTERNAL_ERROR';
38
+ }
39
+
40
+ /**
41
+ * 包装 MCP handler 函数,提供统一错误处理
42
+ *
43
+ * @param {string} toolName — 工具名(用于 meta.tool)
44
+ * @param {Function} handlerFn — 原始 handler: (ctx, args) => Promise<any>
45
+ * @returns {Function} — 包装后的 handler,保证 *不会* throw
46
+ *
47
+ * @example
48
+ * import { wrapHandler } from '../errorHandler.js';
49
+ * export const search = wrapHandler('autosnippet_search', async (ctx, args) => {
50
+ * // ... 正常返回 envelope(...)
51
+ * });
52
+ */
53
+ export function wrapHandler(toolName, handlerFn) {
54
+ return async function wrappedHandler(ctx, args) {
55
+ const t0 = Date.now();
56
+ try {
57
+ return await handlerFn(ctx, args);
58
+ } catch (err) {
59
+ const elapsed = Date.now() - t0;
60
+ const errorCode = inferErrorCode(err);
61
+ const message = err.message || 'Unknown error';
62
+
63
+ logger.error(`[MCP:${toolName}] ${errorCode}: ${message}`, {
64
+ tool: toolName,
65
+ errorCode,
66
+ durationMs: elapsed,
67
+ ...(err.details ? { details: err.details } : {}),
68
+ });
69
+
70
+ return envelope({
71
+ success: false,
72
+ message,
73
+ errorCode,
74
+ meta: {
75
+ tool: toolName,
76
+ responseTimeMs: elapsed,
77
+ },
78
+ });
79
+ }
80
+ };
81
+ }
82
+
83
+ /**
84
+ * 批量包装一个模块的所有 handler 函数
85
+ *
86
+ * @param {string} prefix — 工具名前缀(如 'autosnippet_search')
87
+ * @param {Record<string, Function>} handlersModule — handler 模块 exports
88
+ * @returns {Record<string, Function>} — 包装后的 handlers
89
+ *
90
+ * @example
91
+ * import * as rawSearchHandlers from './handlers/search.js';
92
+ * const searchHandlers = wrapHandlers('autosnippet', rawSearchHandlers);
93
+ */
94
+ export function wrapHandlers(prefix, handlersModule) {
95
+ const wrapped = {};
96
+ for (const [key, fn] of Object.entries(handlersModule)) {
97
+ if (typeof fn === 'function') {
98
+ wrapped[key] = wrapHandler(`${prefix}_${key}`, fn);
99
+ } else {
100
+ wrapped[key] = fn; // 非函数属性原样透传
101
+ }
102
+ }
103
+ return wrapped;
104
+ }
105
+
106
+ export default { wrapHandler, wrapHandlers };
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 设计:
8
8
  * - 缓存粒度: 完整工具调用 (key = toolName + normalized args)
9
- * - 失效策略: Bootstrap 会话内有效,无 TTL
9
+ * - 失效策略: TTL 过期 + 手动清理 (默认 30 分钟)
10
10
  * - 内存管理: 文件内容缓存上限 200 个,搜索结果上限 500 个
11
11
  *
12
12
  * 使用方式:
@@ -17,10 +17,13 @@
17
17
  */
18
18
 
19
19
  import Logger from '../../../../../infrastructure/logging/Logger.js';
20
+ import { CACHE } from '../../../../../shared/constants.js';
20
21
 
21
22
  /** 最大缓存条目 */
22
- const MAX_FILE_CACHE = 200;
23
- const MAX_SEARCH_CACHE = 500;
23
+ const MAX_FILE_CACHE = CACHE.MAX_FILE_ENTRIES;
24
+ const MAX_SEARCH_CACHE = CACHE.MAX_SEARCH_ENTRIES;
25
+ /** 缓存 TTL(毫秒),超过此时间的条目视为过期 */
26
+ const DEFAULT_TTL_MS = CACHE.DEFAULT_TTL_MS;
24
27
 
25
28
  export class ToolResultCache {
26
29
  /** @type {Map<string, {result: any, cachedAt: number, hitCount: number}>} */
@@ -32,11 +35,31 @@ export class ToolResultCache {
32
35
  /** @type {import('../../../../../lib/infrastructure/logging/Logger.js').default} */
33
36
  #logger;
34
37
 
35
- /** @type {{hits: number, misses: number}} */
36
- #stats = { hits: 0, misses: 0 };
38
+ /** @type {{hits: number, misses: number, evictions: number}} */
39
+ #stats = { hits: 0, misses: 0, evictions: 0 };
37
40
 
38
- constructor() {
41
+ /** @type {number} TTL in ms */
42
+ #ttlMs;
43
+
44
+ /** @type {ReturnType<typeof setInterval>|null} cleanup timer */
45
+ #cleanupTimer = null;
46
+
47
+ /**
48
+ * @param {object} [options]
49
+ * @param {number} [options.ttlMs] — 缓存 TTL(毫秒),默认取 CACHE.DEFAULT_TTL_MS
50
+ * @param {number} [options.cleanupIntervalMs] — 清理周期(毫秒),默认 5 分钟
51
+ */
52
+ constructor(options = {}) {
39
53
  this.#logger = Logger.getInstance();
54
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
55
+
56
+ // 定期清理过期条目,避免内存膨胀
57
+ const cleanupInterval = options.cleanupIntervalMs ?? 5 * 60 * 1000;
58
+ if (this.#ttlMs > 0 && cleanupInterval > 0) {
59
+ this.#cleanupTimer = setInterval(() => this.#evictExpired(), cleanupInterval);
60
+ // 允许进程退出时不被 timer 阻塞
61
+ if (this.#cleanupTimer.unref) this.#cleanupTimer.unref();
62
+ }
40
63
  }
41
64
 
42
65
  // ─── 搜索结果缓存 ────────────────────────────────────
@@ -67,6 +90,13 @@ export class ToolResultCache {
67
90
  getCachedSearch(pattern) {
68
91
  const entry = this.#searchCache.get(pattern);
69
92
  if (entry) {
93
+ // TTL 检查
94
+ if (this.#ttlMs > 0 && (Date.now() - entry.cachedAt) > this.#ttlMs) {
95
+ this.#searchCache.delete(pattern);
96
+ this.#stats.evictions++;
97
+ this.#stats.misses++;
98
+ return null;
99
+ }
70
100
  entry.hitCount++;
71
101
  this.#stats.hits++;
72
102
  return entry.result;
@@ -102,6 +132,13 @@ export class ToolResultCache {
102
132
  getCachedFile(filePath) {
103
133
  const entry = this.#fileCache.get(filePath);
104
134
  if (entry) {
135
+ // TTL 检查
136
+ if (this.#ttlMs > 0 && (Date.now() - entry.cachedAt) > this.#ttlMs) {
137
+ this.#fileCache.delete(filePath);
138
+ this.#stats.evictions++;
139
+ this.#stats.misses++;
140
+ return null;
141
+ }
105
142
  entry.hitCount++;
106
143
  this.#stats.hits++;
107
144
  return entry.content;
@@ -182,7 +219,49 @@ export class ToolResultCache {
182
219
  clear() {
183
220
  this.#searchCache.clear();
184
221
  this.#fileCache.clear();
185
- this.#stats = { hits: 0, misses: 0 };
222
+ this.#stats = { hits: 0, misses: 0, evictions: 0 };
223
+ }
224
+
225
+ /**
226
+ * 销毁缓存实例,释放定时器
227
+ * 应在 Bootstrap 会话结束时调用
228
+ */
229
+ dispose() {
230
+ this.clear();
231
+ if (this.#cleanupTimer) {
232
+ clearInterval(this.#cleanupTimer);
233
+ this.#cleanupTimer = null;
234
+ }
235
+ }
236
+
237
+ // ─── 内部 ─────────────────────────────────────────────
238
+
239
+ /**
240
+ * 清理两个 Map 中的过期条目
241
+ * @private
242
+ */
243
+ #evictExpired() {
244
+ if (this.#ttlMs <= 0) return;
245
+ const now = Date.now();
246
+ let evicted = 0;
247
+
248
+ for (const [key, entry] of this.#searchCache) {
249
+ if ((now - entry.cachedAt) > this.#ttlMs) {
250
+ this.#searchCache.delete(key);
251
+ evicted++;
252
+ }
253
+ }
254
+ for (const [key, entry] of this.#fileCache) {
255
+ if ((now - entry.cachedAt) > this.#ttlMs) {
256
+ this.#fileCache.delete(key);
257
+ evicted++;
258
+ }
259
+ }
260
+
261
+ if (evicted > 0) {
262
+ this.#stats.evictions += evicted;
263
+ this.#logger.debug(`[ToolResultCache] evicted ${evicted} expired entries`);
264
+ }
186
265
  }
187
266
  }
188
267
 
@@ -1108,7 +1108,8 @@ export async function fillDimensionsV3(fillContext) {
1108
1108
  logger.info(`[Bootstrap-v3] 🚀 Cursor Delivery complete — ` +
1109
1109
  `A: ${deliveryResult.channelA.rulesCount} rules, ` +
1110
1110
  `B: ${deliveryResult.channelB.topicCount} topics, ` +
1111
- `C: ${deliveryResult.channelC.synced} skills`);
1111
+ `C: ${deliveryResult.channelC.synced} skills, ` +
1112
+ `D: ${deliveryResult.channelD?.documentsCount || 0} documents`);
1112
1113
  }
1113
1114
  } catch (deliveryErr) {
1114
1115
  logger.warn(`[Bootstrap-v3] Cursor Delivery failed (non-blocking): ${deliveryErr.message}`);
@@ -237,6 +237,80 @@ export async function knowledgeLifecycle(ctx, args) {
237
237
 
238
238
  // ─── 内部辅助 ──────────────────────────────────────────────
239
239
 
240
+ /**
241
+ * 保存开发文档 (autosnippet_save_document)
242
+ *
243
+ * 精简入口:仅需 title + markdown。
244
+ * 自动设置 knowledgeType='dev-document', kind='fact', source='agent'。
245
+ * 不走 RecipeReadiness 检查(文档无需 doClause/trigger)。
246
+ * 支持 autoApprove — 文档直接进入 active 状态。
247
+ */
248
+ export async function saveDocument(ctx, args) {
249
+ if (!args.title || !args.title.trim()) {
250
+ throw new Error('title 必填');
251
+ }
252
+ if (!args.markdown || !args.markdown.trim()) {
253
+ throw new Error('markdown 必填');
254
+ }
255
+
256
+ // 限流
257
+ const blocked = await _checkRateLimit('autosnippet_save_document', args.client_id);
258
+ if (blocked) return blocked;
259
+
260
+ const service = ctx.container.get('knowledgeService');
261
+
262
+ const data = {
263
+ title: args.title.trim(),
264
+ description: args.description || '',
265
+ knowledgeType: 'dev-document',
266
+ kind: 'fact',
267
+ source: args.source || 'agent',
268
+ scope: args.scope || 'project-specific',
269
+ tags: args.tags || [],
270
+ content: {
271
+ markdown: args.markdown,
272
+ pattern: '',
273
+ },
274
+ // 文档不需要 Cursor Delivery 字段
275
+ trigger: '',
276
+ doClause: '',
277
+ dontClause: '',
278
+ whenClause: '',
279
+ topicHint: '',
280
+ coreCode: '',
281
+ // 基础推理
282
+ reasoning: {
283
+ whyStandard: 'Agent development document — preserved for team knowledge',
284
+ sources: ['agent'],
285
+ confidence: 0.8,
286
+ },
287
+ };
288
+
289
+ const entry = await service.create(data, { userId: 'mcp' });
290
+
291
+ // 自动发布(dev-document 不需要人工审核)
292
+ try {
293
+ await service.publish(entry.id, { userId: 'mcp' });
294
+ } catch {
295
+ // 发布失败保持 pending — 非阻塞
296
+ }
297
+
298
+ return envelope({
299
+ success: true,
300
+ data: {
301
+ id: entry.id,
302
+ lifecycle: 'active',
303
+ title: entry.title,
304
+ kind: 'fact',
305
+ knowledgeType: 'dev-document',
306
+ },
307
+ message: `文档「${entry.title}」已保存到知识库。`,
308
+ meta: { tool: 'autosnippet_save_document' },
309
+ });
310
+ }
311
+
312
+ // ─── 内部辅助 ──────────────────────────────────────────────
313
+
240
314
  /**
241
315
  * V3 wire format → RecipeReadinessChecker 兼容格式
242
316
  */
@@ -23,6 +23,7 @@ export const TOOL_GATEWAY_MAP = {
23
23
  autosnippet_submit_knowledge: { action: 'knowledge:create', resource: 'knowledge' },
24
24
  autosnippet_submit_knowledge_batch: { action: 'knowledge:create', resource: 'knowledge' },
25
25
  autosnippet_knowledge_lifecycle: { action: 'knowledge:update', resource: 'knowledge' },
26
+ autosnippet_save_document: { action: 'knowledge:create', resource: 'knowledge' },
26
27
  };
27
28
 
28
29
  export const TOOLS = [
@@ -751,4 +752,26 @@ export const TOOLS = [
751
752
  required: ['id', 'action'],
752
753
  },
753
754
  },
755
+ // 39. 保存开发文档
756
+ {
757
+ name: 'autosnippet_save_document',
758
+ description:
759
+ '保存开发文档到知识库(架构设计、排查报告、决策记录、调研笔记等)。\n' +
760
+ '仅需 title + markdown,无需填写 kind/doClause/trigger 等 Cursor Delivery 字段。\n' +
761
+ '文档自动以 dev-document 类型存储,不参与 Cursor Rules 压缩,保持原文完整性。\n' +
762
+ '交付路径: Channel D → .cursor/skills/autosnippet-devdocs/references/*.md。\n' +
763
+ 'Agent 可通过 autosnippet_search 全文检索已保存的文档。',
764
+ inputSchema: {
765
+ type: 'object',
766
+ properties: {
767
+ title: { type: 'string', description: '文档标题(中英文皆可)' },
768
+ markdown: { type: 'string', description: '文档 Markdown 全文' },
769
+ description: { type: 'string', description: '一句话摘要(可选)' },
770
+ tags: { type: 'array', items: { type: 'string' }, description: '标签列表,如 ["adr", "debug-report", "design-doc", "research"]' },
771
+ scope: { type: 'string', enum: ['universal', 'project-specific'], default: 'project-specific', description: '适用范围' },
772
+ source: { type: 'string', description: '来源标识(默认 agent)' },
773
+ },
774
+ required: ['title', 'markdown'],
775
+ },
776
+ },
754
777
  ];
@@ -26,6 +26,7 @@ import violationsRouter from './routes/violations.js';
26
26
  import authRouter from './routes/auth.js';
27
27
  import skillsRouter from './routes/skills.js';
28
28
  import knowledgeRouter from './routes/knowledge.js';
29
+ import recipesRouter from './routes/recipes.js';
29
30
  import apiSpec from './api-spec.js';
30
31
  import { initRedisService } from '../infrastructure/cache/RedisService.js';
31
32
  import { initCacheAdapter } from '../infrastructure/cache/UnifiedCacheAdapter.js';
@@ -264,6 +265,9 @@ export class HttpServer {
264
265
  // 知识条目路由 (V3)
265
266
  this.app.use(`${apiPrefix}/knowledge`, knowledgeRouter);
266
267
 
268
+ // Recipe 操作路由(关系发现等)
269
+ this.app.use(`${apiPrefix}/recipes`, recipesRouter);
270
+
267
271
  // 根路径 — 返回 API 元信息(避免外部探测产生无意义 404)
268
272
  this.app.all('/', (req, res) => {
269
273
  res.json({
@@ -5,24 +5,41 @@
5
5
  * 精简策略:
6
6
  * - GET 请求 + 2xx 状态码: 降为 debug(Dashboard 轮询高频噪音)
7
7
  * - 非 GET / 非 2xx / 慢请求(>2s): 保留 info 级别
8
+ *
9
+ * ⚠️ 重要: 使用 req.originalUrl 而非 req.path。
10
+ * Express 4 子路由器 (app.use('/api/v1/x', router)) 会在执行
11
+ * handler 期间临时修改 req.url / req.path 为相对路径 (e.g. '/')。
12
+ * 当 res.on('finish') 同步触发时 req.url 尚未恢复,导致日志中
13
+ * 所有子路由请求都显示为 'GET / ...',SILENT_PATHS 匹配也失效。
14
+ * req.originalUrl 始终保持请求的原始 URL,不受路由挂载影响。
8
15
  */
9
16
 
10
17
  // 轮询/心跳路径 — 完全静默
11
- const SILENT_PATHS = ['/api/health', '/api/realtime/events', '/api/sse', '/socket.io'];
18
+ const SILENT_PATHS = ['/api/v1/health', '/api/health', '/api/realtime/events', '/api/sse', '/socket.io'];
19
+
20
+ /**
21
+ * 从 originalUrl 中提取 pathname(去除 query string)
22
+ */
23
+ function extractPath(originalUrl) {
24
+ const idx = originalUrl.indexOf('?');
25
+ return idx === -1 ? originalUrl : originalUrl.slice(0, idx);
26
+ }
12
27
 
13
28
  export function requestLogger(logger) {
14
29
  return (req, res, next) => {
15
30
  const startTime = Date.now();
31
+ // 在中间件进入时捕获 originalUrl — 此值不会被 Express 路由修改
32
+ const originalPath = extractPath(req.originalUrl);
16
33
 
17
34
  res.on('finish', () => {
18
35
  const duration = Date.now() - startTime;
19
36
 
20
37
  // 完全静默的路径
21
- if (SILENT_PATHS.some(p => req.path.startsWith(p))) return;
38
+ if (SILENT_PATHS.some(p => originalPath.startsWith(p))) return;
22
39
 
23
40
  const logData = {
24
41
  method: req.method,
25
- path: req.path,
42
+ path: originalPath,
26
43
  statusCode: res.statusCode,
27
44
  duration: `${duration}ms`,
28
45
  };
@@ -31,7 +48,7 @@ export function requestLogger(logger) {
31
48
  const isNoisy = req.method === 'GET' && (res.statusCode >= 200 && res.statusCode < 300 || res.statusCode === 304) && duration < 2000;
32
49
  const isSlow = duration >= 1000;
33
50
  if (isSlow) {
34
- logger.warn(`🐌慢请求: ${req.method} ${req.path} - ${duration}ms`, logData);
51
+ logger.warn(`🐌慢请求: ${req.method} ${originalPath} - ${duration}ms`, logData);
35
52
  } else if (isNoisy) {
36
53
  logger.debug('HTTP', logData);
37
54
  } else {