autosnippet 2.6.0 → 2.7.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.
Files changed (66) hide show
  1. package/bin/cli.js +1 -1
  2. package/dashboard/dist/assets/{icons-rnn04CvH.js → icons-Cq4-iQhP.js} +148 -88
  3. package/dashboard/dist/assets/index-DBxH7pVn.css +1 -0
  4. package/dashboard/dist/assets/index-Dw2F6qAS.js +197 -0
  5. package/dashboard/dist/assets/{react-markdown-CWxUbOf4.js → react-markdown-BA6FB2NP.js} +1 -1
  6. package/dashboard/dist/assets/{syntax-highlighter-CJ2drQQb.js → syntax-highlighter-CVLHn9O5.js} +1 -1
  7. package/dashboard/dist/assets/{vendor-f83ah6cm.js → vendor-BotF760a.js} +61 -61
  8. package/dashboard/dist/index.html +6 -6
  9. package/lib/bootstrap.js +1 -1
  10. package/lib/cli/SetupService.js +33 -8
  11. package/lib/cli/UpgradeService.js +139 -2
  12. package/lib/core/ast/ProjectGraph.js +599 -0
  13. package/lib/core/gateway/GatewayActionRegistry.js +2 -2
  14. package/lib/domain/recipe/Recipe.js +3 -0
  15. package/lib/external/ai/AiProvider.js +83 -20
  16. package/lib/external/ai/providers/ClaudeProvider.js +197 -0
  17. package/lib/external/ai/providers/GoogleGeminiProvider.js +235 -1
  18. package/lib/external/ai/providers/OpenAiProvider.js +131 -0
  19. package/lib/external/mcp/handlers/bootstrap/pipeline/dimension-context.js +216 -0
  20. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +468 -0
  21. package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +162 -0
  22. package/lib/external/mcp/handlers/bootstrap/skills.js +225 -0
  23. package/lib/external/mcp/handlers/bootstrap.js +151 -1634
  24. package/lib/external/mcp/handlers/browse.js +1 -1
  25. package/lib/external/mcp/handlers/candidate.js +1 -33
  26. package/lib/external/mcp/handlers/skill.js +54 -17
  27. package/lib/external/mcp/tools.js +4 -3
  28. package/lib/http/middleware/requestLogger.js +23 -4
  29. package/lib/http/routes/ai.js +3 -1
  30. package/lib/http/routes/auth.js +3 -2
  31. package/lib/http/routes/candidates.js +49 -25
  32. package/lib/http/routes/commands.js +0 -8
  33. package/lib/http/routes/guardRules.js +1 -16
  34. package/lib/http/routes/recipes.js +4 -17
  35. package/lib/http/routes/search.js +11 -19
  36. package/lib/http/routes/skills.js +2 -0
  37. package/lib/http/routes/snippets.js +0 -33
  38. package/lib/http/routes/spm.js +37 -63
  39. package/lib/http/utils/routeHelpers.js +31 -0
  40. package/lib/infrastructure/config/Paths.js +9 -0
  41. package/lib/infrastructure/logging/Logger.js +86 -3
  42. package/lib/infrastructure/realtime/RealtimeService.js +2 -5
  43. package/lib/infrastructure/vector/JsonVectorAdapter.js +24 -1
  44. package/lib/injection/ServiceContainer.js +55 -2
  45. package/lib/service/bootstrap/BootstrapTaskManager.js +400 -0
  46. package/lib/service/candidate/CandidateFileWriter.js +68 -27
  47. package/lib/service/candidate/CandidateService.js +156 -10
  48. package/lib/service/chat/AnalystAgent.js +216 -0
  49. package/lib/service/chat/CandidateGuardrail.js +134 -0
  50. package/lib/service/chat/ChatAgent.js +1036 -167
  51. package/lib/service/chat/ContextWindow.js +730 -0
  52. package/lib/service/chat/HandoffProtocol.js +180 -0
  53. package/lib/service/chat/ProducerAgent.js +240 -0
  54. package/lib/service/chat/ToolRegistry.js +149 -5
  55. package/lib/service/chat/tools.js +1397 -61
  56. package/lib/service/recipe/RecipeFileWriter.js +12 -1
  57. package/lib/service/skills/SignalCollector.js +31 -6
  58. package/lib/service/skills/SkillAdvisor.js +2 -1
  59. package/lib/service/skills/SkillHooks.js +13 -5
  60. package/lib/service/spm/SpmService.js +2 -2
  61. package/package.json +1 -1
  62. package/templates/copilot-instructions.md +20 -3
  63. package/templates/cursor-rules/autosnippet-conventions.mdc +21 -4
  64. package/templates/cursor-rules/autosnippet-skills.mdc +45 -0
  65. package/dashboard/dist/assets/index-BBKa3Dgi.js +0 -195
  66. package/dashboard/dist/assets/index-DLsECfzW.css +0 -1
@@ -0,0 +1,180 @@
1
+ /**
2
+ * HandoffProtocol.js — Analyst → Producer 交接协议
3
+ *
4
+ * 职责:
5
+ * 1. 从 Analyst 执行结果构建 AnalysisReport
6
+ * 2. 质量门控: 判断分析是否足够深入
7
+ * 3. 提供重试提示构建
8
+ *
9
+ * @module HandoffProtocol
10
+ */
11
+
12
+ // ──────────────────────────────────────────────────────────────────
13
+ // AnalysisReport 构建
14
+ // ──────────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * 从 Analyst 的执行结果构建 AnalysisReport
18
+ *
19
+ * @param {object} analystResult — ChatAgent.execute() 返回值
20
+ * @param {string} analystResult.reply — Analyst 的自然语言分析文本
21
+ * @param {Array} analystResult.toolCalls — 工具调用记录
22
+ * @param {string} dimensionId — 维度 ID
23
+ * @param {object} [projectGraph] — ProjectGraph 实例 (用于从 className 反查文件路径)
24
+ * @returns {AnalysisReport}
25
+ */
26
+ export function buildAnalysisReport(analystResult, dimensionId, projectGraph = null) {
27
+ const referencedFiles = new Set();
28
+ const searchQueries = [];
29
+ const classesExplored = [];
30
+
31
+ for (const call of (analystResult.toolCalls || [])) {
32
+ const tool = call.tool || call.name;
33
+ const args = call.params || call.args || {};
34
+ const result = call.result;
35
+
36
+ switch (tool) {
37
+ case 'read_project_file':
38
+ if (args.filePath) referencedFiles.add(args.filePath);
39
+ break;
40
+ case 'search_project_code':
41
+ if (args.pattern || args.query) searchQueries.push(args.pattern || args.query);
42
+ // 从搜索结果中提取文件路径
43
+ if (typeof result === 'string') {
44
+ const fileMatches = result.match(/(?:^|\n)([\w/.-]+\.[mhswift]+)(?::\d+)?/g);
45
+ if (fileMatches) {
46
+ for (const m of fileMatches) {
47
+ const clean = m.trim().replace(/:\d+$/, '').replace(/^\n/, '');
48
+ if (clean.length > 2 && clean.length < 120) referencedFiles.add(clean);
49
+ }
50
+ }
51
+ }
52
+ break;
53
+ case 'get_class_info':
54
+ if (args.className) {
55
+ classesExplored.push(args.className);
56
+ // 从 ProjectGraph 反查文件路径
57
+ if (projectGraph) {
58
+ const info = projectGraph.getClassInfo(args.className);
59
+ if (info?.filePath) referencedFiles.add(info.filePath);
60
+ }
61
+ }
62
+ break;
63
+ case 'get_protocol_info':
64
+ if (args.protocolName && projectGraph) {
65
+ const info = projectGraph.getProtocolInfo(args.protocolName);
66
+ if (info?.filePath) referencedFiles.add(info.filePath);
67
+ }
68
+ break;
69
+ case 'get_file_summary':
70
+ if (args.filePath) referencedFiles.add(args.filePath);
71
+ break;
72
+ }
73
+ }
74
+
75
+ // 从分析文本中提取文件路径
76
+ const text = analystResult.reply || '';
77
+ const textFileRefs = text.match(/[\w/.-]+\.[mhswift]+/g);
78
+ if (textFileRefs) {
79
+ for (const f of textFileRefs) {
80
+ if (f.length > 2 && f.length < 120 && /\.[mhswift]+$/.test(f)) {
81
+ referencedFiles.add(f);
82
+ }
83
+ }
84
+ }
85
+
86
+ return {
87
+ analysisText: text,
88
+ referencedFiles: [...referencedFiles],
89
+ searchQueries,
90
+ classesExplored,
91
+ dimensionId,
92
+ metadata: {
93
+ iterations: analystResult.toolCalls?.length || 0,
94
+ toolCallCount: analystResult.toolCalls?.length || 0,
95
+ },
96
+ };
97
+ }
98
+
99
+ // ──────────────────────────────────────────────────────────────────
100
+ // 质量门控 (Gate)
101
+ // ──────────────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * 分析质量门控 — 判断 Analyst 的输出是否足够好
105
+ *
106
+ * @param {AnalysisReport} report
107
+ * @param {object} [options]
108
+ * @param {string} [options.outputType] — 'analysis' | 'dual' | 'candidate'
109
+ * @returns {{ pass: boolean, reason?: string, action?: 'retry' | 'degrade' }}
110
+ */
111
+ export function analysisQualityGate(report, options = {}) {
112
+ const needsCandidates = options.outputType === 'dual' || options.outputType === 'candidate';
113
+ // 需要产出候选的维度要求更高门槛
114
+ const minChars = needsCandidates ? 400 : 200;
115
+ const minFileRefs = needsCandidates ? 3 : 2;
116
+
117
+ // 规则 1: 最少字符数 — 分析太短说明未充分探索
118
+ if (report.analysisText.length < minChars) {
119
+ return { pass: false, reason: 'Analysis too short', action: 'retry' };
120
+ }
121
+
122
+ // 规则 2: 最少引用文件数 — 未引用文件说明未看代码
123
+ if (report.referencedFiles.length < minFileRefs) {
124
+ return { pass: false, reason: 'Too few file references', action: 'retry' };
125
+ }
126
+
127
+ // 规则 3: 检测"拒绝回答"模式
128
+ const refusalPatterns = [
129
+ /I cannot|I'm unable|I don't have access/i,
130
+ /无法分析|无法访问|没有足够/,
131
+ ];
132
+ if (refusalPatterns.some(p => p.test(report.analysisText))) {
133
+ return { pass: false, reason: 'Agent refused to analyze', action: 'degrade' };
134
+ }
135
+
136
+ // 规则 4: 内容实质性检查 — 有结构化内容或足够多的探索
137
+ // v3.1: 放宽条件 — tool calling 模式下 AI 往往不输出 markdown 格式
138
+ // 只要分析足够长且引用了足够多的文件,就认为有实质性内容
139
+ const hasStructure = /#{1,3}\s/.test(report.analysisText) ||
140
+ /\d+\.\s/.test(report.analysisText) ||
141
+ /[-•]\s/.test(report.analysisText) ||
142
+ /[::].+\n/.test(report.analysisText) ||
143
+ report.analysisText.length >= 500 ||
144
+ (report.referencedFiles.length >= 3 && report.analysisText.length >= 200);
145
+ if (!hasStructure) {
146
+ return { pass: false, reason: 'Analysis lacks structure', action: 'retry' };
147
+ }
148
+
149
+ return { pass: true };
150
+ }
151
+
152
+ /**
153
+ * 构建重试提示 — Gate 失败时给 Analyst 的追加指令
154
+ *
155
+ * @param {string} reason — Gate 失败原因
156
+ * @returns {string}
157
+ */
158
+ export function buildRetryPrompt(reason) {
159
+ const hints = {
160
+ 'Analysis too short': '你的分析不够深入。请使用更多工具(get_class_info、read_project_file、search_project_code)查看实际代码,输出至少 500 字的分析。',
161
+ 'Too few file references': '你的分析缺少代码引用。请使用 get_class_info 和 read_project_file 查看至少 3 个相关文件,并在分析中引用具体文件和行号。',
162
+ 'Analysis lacks structure': '请将分析组织成结构化的段落,使用编号列表或标题来区分不同的发现。每个发现应包含具体的文件路径和代码位置。',
163
+ };
164
+
165
+ return hints[reason] || '请更深入地分析代码,引用至少 3 个具体文件,每个发现都要有代码证据。';
166
+ }
167
+
168
+ // ──────────────────────────────────────────────────────────────────
169
+ // 类型定义 (JSDoc)
170
+ // ──────────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * @typedef {object} AnalysisReport
174
+ * @property {string} analysisText — Analyst 的完整回复文本
175
+ * @property {string[]} referencedFiles — 从 toolCalls 中提取的已引用文件路径
176
+ * @property {string[]} searchQueries — 从 toolCalls 中提取的搜索查询
177
+ * @property {string[]} classesExplored — 从 toolCalls 中提取的已查看类名
178
+ * @property {string} dimensionId — 维度 ID
179
+ * @property {object} metadata — { iterations, toolCallCount }
180
+ */
@@ -0,0 +1,240 @@
1
+ /**
2
+ * ProducerAgent.js — v3.0 生产者 Agent
3
+ *
4
+ * 职责:
5
+ * - 将 Analyst 的分析文本转换为结构化的 submit_candidate 调用
6
+ * - 遵循 PROJECT_SNAPSHOT_STYLE_GUIDE 格式
7
+ * - 使用 read_project_file 获取代码片段
8
+ * - CandidateGuardrail 验证每次提交
9
+ *
10
+ * 设计哲学:
11
+ * "像编辑把记者的手稿变成标准格式的文章。"
12
+ *
13
+ * @module ProducerAgent
14
+ */
15
+
16
+ import Logger from '../../infrastructure/logging/Logger.js';
17
+
18
+ // ──────────────────────────────────────────────────────────────────
19
+ // System Prompt — Producer 专用 (~150 tokens)
20
+ // ──────────────────────────────────────────────────────────────────
21
+
22
+ const PRODUCER_SYSTEM_PROMPT = `你是知识管理专家。你会收到一段代码分析文本,需要将其中的知识点转化为结构化的知识候选。
23
+
24
+ 核心原则: 分析文本已经包含了所有发现,你的唯一工作是将它们格式化为 submit_candidate 调用。
25
+
26
+ 每个候选必须:
27
+ 1. 有清晰的标题 (描述知识点的核心,使用项目真实类名)
28
+ 2. 有项目特写风格的正文 (结合代码展示)
29
+ 3. 标注相关文件路径
30
+ 4. 选择正确的 knowledgeType (必须是维度约束中允许的类型)
31
+
32
+ 工作流程:
33
+ 1. 阅读分析文本,识别每个独立的知识点/发现
34
+ 2. 对每个知识点,用 read_project_file 获取关键代码片段(读取 30-80 行,不要只读 5 行头部)
35
+ 3. 立刻调用 submit_candidate 提交
36
+ 4. 重复直到分析中的所有知识点都已提交
37
+
38
+ 关键规则:
39
+ - 分析中的每个要点/段落都应转化为至少一个候选
40
+ - read_project_file 时读取足够多的行数(startLine + maxLines 至少 30 行)
41
+ - reasoning.sources 必须是非空数组,填写相关文件路径如 ["FileName.m"]
42
+ - 如果分析提到了 3 个模式,就应该提交 3 个候选,不要合并
43
+ - 禁止: 不要搜索新文件、不要做额外分析,专注于格式化和提交
44
+
45
+ 容错规则:
46
+ - 如果 read_project_file 返回"文件不存在"或错误,不要重试同一文件的其他路径变体
47
+ - 文件读取失败时,直接使用分析文本中已有的代码和描述来提交候选
48
+ - 永远不要因为文件读取失败而跳过知识点 — 分析文本已经包含足够信息
49
+ - 先提交候选,再考虑是否需要读取更多代码(提交优先于验证)`;
50
+
51
+ // ──────────────────────────────────────────────────────────────────
52
+ // Producer 可用工具白名单 — 只做格式化和提交
53
+ // ──────────────────────────────────────────────────────────────────
54
+
55
+ const PRODUCER_TOOLS = [
56
+ 'submit_candidate',
57
+ 'submit_with_check',
58
+ 'read_project_file',
59
+ ];
60
+
61
+ // ──────────────────────────────────────────────────────────────────
62
+ // Producer 预算 — 格式化任务,迭代更少
63
+ // ──────────────────────────────────────────────────────────────────
64
+
65
+ const PRODUCER_BUDGET = {
66
+ maxIterations: 15,
67
+ searchBudget: 4,
68
+ searchBudgetGrace: 3,
69
+ maxSubmits: 10,
70
+ softSubmitLimit: 10,
71
+ idleRoundsToExit: 3,
72
+ };
73
+
74
+ // ──────────────────────────────────────────────────────────────────
75
+ // 项目特写风格指南 (只注入一次到 Producer)
76
+ // ──────────────────────────────────────────────────────────────────
77
+
78
+ const STYLE_GUIDE = `# 「项目特写」写作要求
79
+
80
+ submit_candidate 的 code 字段必须是「项目特写」。
81
+
82
+ ## 什么是「项目特写」
83
+ 将一种技术的**基本用法**与**本项目的具体特征**融合为一体。
84
+
85
+ ## 四大核心内容
86
+ 1. **项目选择了什么** — 采用了哪种写法/模式/约定
87
+ 2. **为什么这样选** — 统计分布、占比、历史决策
88
+ 3. **项目禁止什么** — 反模式、已废弃写法
89
+ 4. **新代码怎么写** — 可直接复制使用的代码模板 + 来源标注 (来源: FileName.m:行号)
90
+
91
+ ## 格式要求
92
+ - 标题使用项目真实类名/前缀,不用占位名
93
+ - 代码来源标注: (来源: FileName.m:行号)
94
+ - 不要纯代码罗列,必须有项目上下文`;
95
+
96
+ // ──────────────────────────────────────────────────────────────────
97
+ // Prompt 构建
98
+ // ──────────────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * 构建 Producer Prompt
102
+ *
103
+ * @param {import('./HandoffProtocol.js').AnalysisReport} analysisReport
104
+ * @param {object} dimConfig — { id, label, allowedKnowledgeTypes, outputType }
105
+ * @param {object} projectInfo — { name }
106
+ * @returns {string}
107
+ */
108
+ function buildProducerPrompt(analysisReport, dimConfig, projectInfo) {
109
+ const parts = [];
110
+
111
+ // §1 任务描述
112
+ parts.push(`将以下对 ${projectInfo.name} 项目 "${dimConfig.label}" 维度的分析,转化为知识候选:`);
113
+
114
+ // §2 分析内容
115
+ parts.push(`---\n${analysisReport.analysisText}\n---`);
116
+
117
+ // §3 引用文件
118
+ if (analysisReport.referencedFiles.length > 0) {
119
+ parts.push(`分析中引用的关键文件: ${analysisReport.referencedFiles.join(', ')}`);
120
+ }
121
+
122
+ // §4 维度约束
123
+ parts.push(`维度约束:
124
+ - dimensionId: ${dimConfig.id}
125
+ - 允许的 knowledgeType: ${(dimConfig.allowedKnowledgeTypes || []).join(', ') || '(all)'}
126
+ - category: ${dimConfig.id}`);
127
+
128
+ // §5 写作指南 (只注入一次)
129
+ parts.push(STYLE_GUIDE);
130
+
131
+ // §6 提交要求
132
+ parts.push(`要求:
133
+ 1. 每个独立的知识点单独提交为一个候选 — 目标: 至少 3 个候选
134
+ 2. 先使用分析中已有的代码片段直接提交候选; 仅在需要更多代码上下文时才用 read_project_file
135
+ 3. filePaths 填写分析中提到的相关文件路径
136
+ 4. summary 概括该知识点的核心价值
137
+ 5. reasoning 中 sources 必须非空,填写来源文件名如 ["FileName.m"],confidence 填 0.7~0.9
138
+ 6. 不要跳过任何分析中提到的知识点
139
+ 7. 如果 read_project_file 失败(文件不存在),直接用分析文本内容提交,不要重试其他路径`);
140
+
141
+ return parts.join('\n\n');
142
+ }
143
+
144
+ // ──────────────────────────────────────────────────────────────────
145
+ // ProducerAgent 类
146
+ // ──────────────────────────────────────────────────────────────────
147
+
148
+ export class ProducerAgent {
149
+ /** @type {import('./ChatAgent.js').ChatAgent} */
150
+ #chatAgent;
151
+
152
+ /** @type {import('../../infrastructure/logging/Logger.js').default} */
153
+ #logger;
154
+
155
+ /**
156
+ * @param {object} chatAgent — ChatAgent 实例
157
+ */
158
+ constructor(chatAgent) {
159
+ this.#chatAgent = chatAgent;
160
+ this.#logger = Logger.getInstance();
161
+ }
162
+
163
+ /**
164
+ * 将分析报告转化为候选
165
+ *
166
+ * @param {import('./HandoffProtocol.js').AnalysisReport} analysisReport — Analyst 产出
167
+ * @param {object} dimConfig — 维度配置
168
+ * @param {object} projectInfo — { name, lang, fileCount }
169
+ * @param {object} [options]
170
+ * @param {string} [options.sessionId]
171
+ * @param {object} [options.budget] — 覆盖默认预算
172
+ * @returns {Promise<ProducerResult>}
173
+ */
174
+ async produce(analysisReport, dimConfig, projectInfo, options = {}) {
175
+ const dimId = dimConfig.id;
176
+
177
+ // 分析文本为空时直接跳过
178
+ if (!analysisReport.analysisText || analysisReport.analysisText.length < 50) {
179
+ this.#logger.warn(`[ProducerAgent] ⚠ empty analysis for "${dimId}" — skipping`);
180
+ return { candidateCount: 0, toolCalls: [], reply: '' };
181
+ }
182
+
183
+ const prompt = buildProducerPrompt(analysisReport, dimConfig, projectInfo);
184
+ this.#logger.info(`[ProducerAgent] ▶ producing candidates for "${dimId}" — prompt ${prompt.length} chars`);
185
+
186
+ const budget = options.budget
187
+ ? { ...PRODUCER_BUDGET, ...options.budget }
188
+ : { ...PRODUCER_BUDGET };
189
+
190
+ try {
191
+ const result = await this.#chatAgent.execute(prompt, {
192
+ source: 'system',
193
+ conversationId: options.sessionId ? `producer-${options.sessionId}-${dimId}` : undefined,
194
+ budget,
195
+ systemPromptOverride: PRODUCER_SYSTEM_PROMPT,
196
+ allowedTools: PRODUCER_TOOLS,
197
+ disablePhaseRouter: true,
198
+ temperature: 0.3,
199
+ dimensionMeta: {
200
+ id: dimId,
201
+ outputType: dimConfig.outputType || 'candidate',
202
+ allowedKnowledgeTypes: dimConfig.allowedKnowledgeTypes || [],
203
+ },
204
+ });
205
+
206
+ // 统计提交 (区分成功/失败)
207
+ const submitCalls = (result.toolCalls || []).filter(
208
+ tc => (tc.tool || tc.name) === 'submit_candidate' || (tc.tool || tc.name) === 'submit_with_check'
209
+ );
210
+ const successCount = submitCalls.filter(tc => {
211
+ const res = tc.result;
212
+ if (!res) return true; // 无结果信息默认成功
213
+ if (typeof res === 'string') return !res.includes('rejected') && !res.includes('error');
214
+ return res.status !== 'rejected' && res.status !== 'error';
215
+ }).length;
216
+ const rejectedCount = submitCalls.length - successCount;
217
+
218
+ this.#logger.info(`[ProducerAgent] ✅ dimension "${dimId}" — ${successCount} candidates created (${rejectedCount} rejected), ${result.toolCalls?.length || 0} total tool calls`);
219
+
220
+ return {
221
+ candidateCount: successCount,
222
+ rejectedCount,
223
+ toolCalls: result.toolCalls || [],
224
+ reply: result.reply || '',
225
+ };
226
+ } catch (err) {
227
+ this.#logger.error(`[ProducerAgent] ❌ dimension "${dimId}" error: ${err.message}`);
228
+ return { candidateCount: 0, toolCalls: [], reply: '' };
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * @typedef {object} ProducerResult
235
+ * @property {number} candidateCount
236
+ * @property {Array} toolCalls
237
+ * @property {string} reply
238
+ */
239
+
240
+ export default ProducerAgent;
@@ -12,6 +12,38 @@
12
12
 
13
13
  import Logger from '../../infrastructure/logging/Logger.js';
14
14
 
15
+ /**
16
+ * AI 模型常见的参数命名变体 → schema 标准名映射
17
+ * 覆盖 Gemini / GPT / DeepSeek / Claude 常见偏好
18
+ */
19
+ const PARAM_ALIASES = {
20
+ // read_project_file 变体
21
+ file: 'filePath',
22
+ filename: 'filePath',
23
+ file_name: 'filePath',
24
+ filepath: 'filePath',
25
+ file_path: 'filePath',
26
+ path: 'filePath',
27
+ // search_project_code 变体
28
+ query: 'pattern',
29
+ search: 'pattern',
30
+ keyword: 'pattern',
31
+ search_query: 'pattern',
32
+ search_text: 'pattern',
33
+ regex: 'pattern',
34
+ // 通用变体
35
+ is_regex: 'isRegex',
36
+ file_filter: 'fileFilter',
37
+ context_lines: 'contextLines',
38
+ max_results: 'maxResults',
39
+ start_line: 'startLine',
40
+ end_line: 'endLine',
41
+ max_lines: 'maxLines',
42
+ candidate_id: 'candidateId',
43
+ recipe_id: 'recipeId',
44
+ skill_name: 'skillName',
45
+ };
46
+
15
47
  export class ToolRegistry {
16
48
  #tools = new Map();
17
49
  #logger;
@@ -32,7 +64,6 @@ export class ToolRegistry {
32
64
  const { name, description, handler, parameters = {} } = toolDef;
33
65
  if (!name || !handler) throw new Error('Tool must have name and handler');
34
66
  this.#tools.set(name, { name, description, parameters, handler });
35
- this.#logger.debug(`Tool registered: ${name}`);
36
67
  }
37
68
 
38
69
  /**
@@ -41,14 +72,18 @@ export class ToolRegistry {
41
72
  */
42
73
  registerAll(defs) {
43
74
  for (const def of defs) this.register(def);
75
+ this.#logger.info(`[ToolRegistry] ${defs.length} tools registered`);
44
76
  }
45
77
 
46
78
  /**
47
79
  * 获取工具定义(不含 handler,给 LLM prompt 使用)
80
+ * @param {string[]} [allowedTools] — 限制返回的工具列表(不传则返回全部)
81
+ * @returns {Array<{name: string, description: string, parameters: object}>}
48
82
  */
49
- getToolSchemas() {
83
+ getToolSchemas(allowedTools) {
50
84
  const schemas = [];
51
- for (const [, tool] of this.#tools) {
85
+ for (const [name, tool] of this.#tools) {
86
+ if (allowedTools && !allowedTools.includes(name)) continue;
52
87
  schemas.push({
53
88
  name: tool.name,
54
89
  description: tool.description,
@@ -69,9 +104,13 @@ export class ToolRegistry {
69
104
  const tool = this.#tools.get(name);
70
105
  if (!tool) throw new Error(`Tool '${name}' not found`);
71
106
 
72
- this.#logger.debug(`Tool execute: ${name}`, { params: Object.keys(params) });
107
+ // 参数归一化: AI 可能用 snake_case / 不同命名传参,
108
+ // 将其映射到 tool schema 中定义的 camelCase 参数名
109
+ const normalized = this.#normalizeParams(params, tool.parameters);
110
+
111
+ this.#logger.debug(`Tool execute: ${name}`, { params: Object.keys(normalized) });
73
112
  try {
74
- const result = await tool.handler(params, context);
113
+ const result = await tool.handler(normalized, context);
75
114
  return result;
76
115
  } catch (err) {
77
116
  this.#logger.error(`Tool '${name}' failed`, { error: err.message });
@@ -79,6 +118,59 @@ export class ToolRegistry {
79
118
  }
80
119
  }
81
120
 
121
+ /**
122
+ * 参数归一化 — 将 AI 传来的 snake_case / 变体参数名映射到 schema 定义名
123
+ *
124
+ * 例: AI 传 { file_path: "x.m" } → schema 定义 filePath → 归一化为 { filePath: "x.m" }
125
+ * AI 传 { file: "x.m" } → schema 定义 filePath → 通过别名表匹配
126
+ *
127
+ * 策略:
128
+ * 1. schema 中已有的 key → 保留不动
129
+ * 2. snake_case → camelCase 自动转换
130
+ * 3. 常用别名表兜底
131
+ */
132
+ #normalizeParams(params, schema) {
133
+ if (!params || typeof params !== 'object') return params || {};
134
+ const properties = schema?.properties || {};
135
+ const schemaKeys = new Set(Object.keys(properties));
136
+ if (schemaKeys.size === 0) return params;
137
+
138
+ const result = {};
139
+ const unmatched = [];
140
+
141
+ for (const [key, value] of Object.entries(params)) {
142
+ // 1. 精确匹配 — 已在 schema 中
143
+ if (schemaKeys.has(key)) {
144
+ result[key] = value;
145
+ continue;
146
+ }
147
+
148
+ // 2. snake_case → camelCase 转换
149
+ const camelKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
150
+ if (schemaKeys.has(camelKey)) {
151
+ result[camelKey] = value;
152
+ continue;
153
+ }
154
+
155
+ // 3. 常用别名映射
156
+ const aliased = PARAM_ALIASES[key];
157
+ if (aliased && schemaKeys.has(aliased)) {
158
+ result[aliased] = value;
159
+ continue;
160
+ }
161
+
162
+ // 4. 无匹配 — 保留原样(handler 可能有自定义处理)
163
+ result[key] = value;
164
+ unmatched.push(key);
165
+ }
166
+
167
+ if (unmatched.length > 0) {
168
+ this.#logger.debug(`[ToolRegistry] param normalization: unmatched keys [${unmatched.join(', ')}]`);
169
+ }
170
+
171
+ return result;
172
+ }
173
+
82
174
  /**
83
175
  * 检查工具是否存在
84
176
  */
@@ -86,6 +178,58 @@ export class ToolRegistry {
86
178
  return this.#tools.has(name);
87
179
  }
88
180
 
181
+ /**
182
+ * 转换为 Gemini functionDeclarations 格式
183
+ * 供 GoogleGeminiProvider.chatWithTools() 使用
184
+ *
185
+ * @param {string[]} [allowedTools] — 限制可用工具列表(不传则返回全部)
186
+ * @returns {Array<{name: string, description: string, parameters: object}>}
187
+ */
188
+ toFunctionDeclarations(allowedTools) {
189
+ const result = [];
190
+ for (const [name, tool] of this.#tools) {
191
+ if (allowedTools && !allowedTools.includes(name)) continue;
192
+ result.push({
193
+ name: tool.name,
194
+ description: tool.description || '',
195
+ parameters: this.#sanitizeSchemaForGemini(tool.parameters),
196
+ });
197
+ }
198
+ return result;
199
+ }
200
+
201
+ /**
202
+ * 清理 JSON Schema 使之兼容 Gemini API 的 OpenAPI 子集
203
+ * Gemini API 不支持某些 JSON Schema 扩展语法
204
+ */
205
+ #sanitizeSchemaForGemini(schema) {
206
+ if (!schema || typeof schema !== 'object') {
207
+ return { type: 'object', properties: {} };
208
+ }
209
+
210
+ const cleaned = { ...schema };
211
+
212
+ // 确保 type 存在
213
+ if (!cleaned.type) cleaned.type = 'object';
214
+
215
+ // 递归清理 properties
216
+ if (cleaned.properties) {
217
+ const props = {};
218
+ for (const [key, val] of Object.entries(cleaned.properties)) {
219
+ const prop = { ...val };
220
+ // 移除 Gemini 不支持的字段
221
+ delete prop.default;
222
+ delete prop.examples;
223
+ // 确保 type 存在
224
+ if (!prop.type) prop.type = 'string';
225
+ props[key] = prop;
226
+ }
227
+ cleaned.properties = props;
228
+ }
229
+
230
+ return cleaned;
231
+ }
232
+
89
233
  /**
90
234
  * 获取所有工具名
91
235
  */