autosnippet 2.5.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 (72) hide show
  1. package/bin/cli.js +35 -0
  2. package/dashboard/dist/assets/{icons-Dtm0E6DS.js → icons-Cq4-iQhP.js} +152 -87
  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/Gateway.js +19 -4
  14. package/lib/core/gateway/GatewayActionRegistry.js +2 -2
  15. package/lib/domain/recipe/Recipe.js +3 -0
  16. package/lib/external/ai/AiProvider.js +117 -10
  17. package/lib/external/ai/providers/ClaudeProvider.js +197 -0
  18. package/lib/external/ai/providers/GoogleGeminiProvider.js +235 -1
  19. package/lib/external/ai/providers/OpenAiProvider.js +131 -0
  20. package/lib/external/mcp/McpServer.js +2 -1
  21. package/lib/external/mcp/handlers/bootstrap/pipeline/dimension-context.js +216 -0
  22. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +468 -0
  23. package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +162 -0
  24. package/lib/external/mcp/handlers/bootstrap/skills.js +225 -0
  25. package/lib/external/mcp/handlers/bootstrap.js +151 -1634
  26. package/lib/external/mcp/handlers/browse.js +1 -1
  27. package/lib/external/mcp/handlers/candidate.js +1 -33
  28. package/lib/external/mcp/handlers/skill.js +126 -31
  29. package/lib/external/mcp/tools.js +25 -3
  30. package/lib/http/middleware/requestLogger.js +23 -4
  31. package/lib/http/routes/ai.js +3 -1
  32. package/lib/http/routes/auth.js +3 -2
  33. package/lib/http/routes/candidates.js +49 -25
  34. package/lib/http/routes/commands.js +0 -8
  35. package/lib/http/routes/guardRules.js +1 -16
  36. package/lib/http/routes/recipes.js +4 -17
  37. package/lib/http/routes/search.js +16 -22
  38. package/lib/http/routes/skills.js +40 -3
  39. package/lib/http/routes/snippets.js +0 -33
  40. package/lib/http/routes/spm.js +37 -63
  41. package/lib/http/utils/routeHelpers.js +31 -0
  42. package/lib/infrastructure/audit/AuditStore.js +18 -0
  43. package/lib/infrastructure/config/Paths.js +9 -0
  44. package/lib/infrastructure/logging/Logger.js +86 -3
  45. package/lib/infrastructure/realtime/RealtimeService.js +2 -5
  46. package/lib/infrastructure/vector/JsonVectorAdapter.js +24 -1
  47. package/lib/injection/ServiceContainer.js +62 -3
  48. package/lib/service/bootstrap/BootstrapTaskManager.js +400 -0
  49. package/lib/service/candidate/CandidateFileWriter.js +68 -27
  50. package/lib/service/candidate/CandidateService.js +156 -10
  51. package/lib/service/chat/AnalystAgent.js +216 -0
  52. package/lib/service/chat/CandidateGuardrail.js +134 -0
  53. package/lib/service/chat/ChatAgent.js +1272 -155
  54. package/lib/service/chat/ContextWindow.js +730 -0
  55. package/lib/service/chat/ConversationStore.js +377 -0
  56. package/lib/service/chat/HandoffProtocol.js +180 -0
  57. package/lib/service/chat/Memory.js +40 -10
  58. package/lib/service/chat/ProducerAgent.js +240 -0
  59. package/lib/service/chat/ToolRegistry.js +149 -5
  60. package/lib/service/chat/tools.js +1493 -60
  61. package/lib/service/recipe/RecipeFileWriter.js +12 -1
  62. package/lib/service/skills/EventAggregator.js +187 -0
  63. package/lib/service/skills/SignalCollector.js +549 -0
  64. package/lib/service/skills/SkillAdvisor.js +324 -0
  65. package/lib/service/skills/SkillHooks.js +13 -5
  66. package/lib/service/spm/SpmService.js +2 -2
  67. package/package.json +1 -1
  68. package/templates/copilot-instructions.md +20 -3
  69. package/templates/cursor-rules/autosnippet-conventions.mdc +21 -4
  70. package/templates/cursor-rules/autosnippet-skills.mdc +45 -0
  71. package/dashboard/dist/assets/index-B7VpZOCz.css +0 -1
  72. package/dashboard/dist/assets/index-D87IZTmZ.js +0 -187
@@ -0,0 +1,377 @@
1
+ /**
2
+ * ConversationStore — 对话持久化 + 上下文窗口管理
3
+ *
4
+ * 设计:
5
+ * - 每个对话一个 JSONL 文件: .autosnippet/conversations/{id}.jsonl
6
+ * - 索引文件: .autosnippet/conversations/index.json
7
+ * - 按 category 隔离: 'user'(Dashboard) / 'system'(SignalCollector)
8
+ * - Token 预算: 超限时自动生成摘要压缩旧轮次
9
+ * - 静默降级: 持久化失败不影响核心功能
10
+ *
11
+ * Token 计算策略:
12
+ * 采用字符数近似估算 (1 token ≈ 3.5 字符中文 / ≈ 4 字符英文)
13
+ * 简单高效,无需额外依赖
14
+ *
15
+ * 文件结构:
16
+ * .autosnippet/conversations/
17
+ * index.json — 对话元数据索引
18
+ * {id}.jsonl — 每行一条消息 {role, content, ts}
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import crypto from 'node:crypto';
24
+ import Logger from '../../infrastructure/logging/Logger.js';
25
+
26
+ const DEFAULT_TOKEN_BUDGET = 12000; // ~12K tokens 留给历史, 其余给系统提示词和当前消息
27
+ const CHARS_PER_TOKEN = 3.5; // 近似: 中文 ~3.5 / 英文 ~4 / 取偏保守值
28
+ const MAX_CONVERSATIONS = 100; // 索引最多保留 100 个对话
29
+ const SUMMARY_TARGET_TOKENS = 500; // 压缩后的摘要目标 token 数
30
+
31
+ export class ConversationStore {
32
+ #dir;
33
+ #indexPath;
34
+ #logger;
35
+
36
+ /**
37
+ * @param {string} projectRoot — 用户项目根目录
38
+ */
39
+ constructor(projectRoot) {
40
+ this.#dir = path.join(projectRoot, '.autosnippet', 'conversations');
41
+ this.#indexPath = path.join(this.#dir, 'index.json');
42
+ this.#logger = Logger.getInstance();
43
+ }
44
+
45
+ // ═══════════════════════════════════════════════════════
46
+ // 公共 API
47
+ // ═══════════════════════════════════════════════════════
48
+
49
+ /**
50
+ * 创建新对话
51
+ * @param {object} opts
52
+ * @param {'user'|'system'} opts.category — 对话类别
53
+ * @param {string} [opts.title] — 对话标题
54
+ * @returns {string} conversationId
55
+ */
56
+ create({ category = 'user', title = '' } = {}) {
57
+ const id = crypto.randomUUID();
58
+ const entry = {
59
+ id,
60
+ category,
61
+ title,
62
+ createdAt: new Date().toISOString(),
63
+ updatedAt: new Date().toISOString(),
64
+ messageCount: 0,
65
+ hasSummary: false,
66
+ };
67
+
68
+ const index = this.#loadIndex();
69
+ index.unshift(entry);
70
+
71
+ // 限制索引大小
72
+ if (index.length > MAX_CONVERSATIONS) {
73
+ const removed = index.splice(MAX_CONVERSATIONS);
74
+ // 清理旧对话文件
75
+ for (const old of removed) {
76
+ this.#deleteConversationFile(old.id);
77
+ }
78
+ }
79
+
80
+ this.#saveIndex(index);
81
+ return id;
82
+ }
83
+
84
+ /**
85
+ * 追加消息到对话
86
+ * @param {string} conversationId
87
+ * @param {{ role: string, content: string }} message
88
+ */
89
+ append(conversationId, message) {
90
+ try {
91
+ fs.mkdirSync(this.#dir, { recursive: true });
92
+ const filePath = this.#conversationPath(conversationId);
93
+ const line = JSON.stringify({
94
+ role: message.role,
95
+ content: message.content,
96
+ ts: new Date().toISOString(),
97
+ });
98
+ fs.appendFileSync(filePath, line + '\n', 'utf-8');
99
+
100
+ // 更新索引
101
+ const index = this.#loadIndex();
102
+ const entry = index.find(e => e.id === conversationId);
103
+ if (entry) {
104
+ entry.updatedAt = new Date().toISOString();
105
+ entry.messageCount = (entry.messageCount || 0) + 1;
106
+ // 用首条用户消息作为标题
107
+ if (!entry.title && message.role === 'user') {
108
+ entry.title = message.content.substring(0, 60);
109
+ }
110
+ this.#saveIndex(index);
111
+ }
112
+ } catch (err) {
113
+ this.#logger.warn(`[ConversationStore] append failed: ${err.message}`);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * 加载对话历史(带 token 预算控制)
119
+ *
120
+ * 如果历史超出 tokenBudget:
121
+ * - 保留开头的摘要(如有)
122
+ * - 截断中间的旧消息
123
+ * - 保留最新的消息
124
+ *
125
+ * @param {string} conversationId
126
+ * @param {object} [opts]
127
+ * @param {number} [opts.tokenBudget] — token 预算
128
+ * @returns {{ role: string, content: string }[]}
129
+ */
130
+ load(conversationId, { tokenBudget = DEFAULT_TOKEN_BUDGET } = {}) {
131
+ try {
132
+ const filePath = this.#conversationPath(conversationId);
133
+ if (!fs.existsSync(filePath)) return [];
134
+
135
+ const raw = fs.readFileSync(filePath, 'utf-8').trim();
136
+ if (!raw) return [];
137
+
138
+ const messages = raw.split('\n')
139
+ .filter(Boolean)
140
+ .map(line => {
141
+ try {
142
+ const parsed = JSON.parse(line);
143
+ return { role: parsed.role, content: parsed.content };
144
+ } catch { return null; }
145
+ })
146
+ .filter(Boolean);
147
+
148
+ return this.#fitWithinBudget(messages, tokenBudget);
149
+ } catch {
150
+ return [];
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 对话列表
156
+ * @param {object} [opts]
157
+ * @param {'user'|'system'} [opts.category] — 按类别过滤
158
+ * @param {number} [opts.limit=20]
159
+ * @returns {Array}
160
+ */
161
+ list({ category, limit = 20 } = {}) {
162
+ const index = this.#loadIndex();
163
+ let results = index;
164
+ if (category) {
165
+ results = results.filter(e => e.category === category);
166
+ }
167
+ return results.slice(0, limit);
168
+ }
169
+
170
+ /**
171
+ * 删除对话
172
+ * @param {string} conversationId
173
+ */
174
+ delete(conversationId) {
175
+ this.#deleteConversationFile(conversationId);
176
+ const index = this.#loadIndex();
177
+ const filtered = index.filter(e => e.id !== conversationId);
178
+ this.#saveIndex(filtered);
179
+ }
180
+
181
+ /**
182
+ * 为对话生成压缩摘要(需要 AI)
183
+ * 将旧消息替换为一条 system 摘要消息
184
+ *
185
+ * @param {string} conversationId
186
+ * @param {object} opts
187
+ * @param {object} opts.aiProvider — AI Provider 实例
188
+ * @returns {Promise<boolean>} 是否成功压缩
189
+ */
190
+ async summarize(conversationId, { aiProvider }) {
191
+ if (!aiProvider) return false;
192
+
193
+ try {
194
+ const filePath = this.#conversationPath(conversationId);
195
+ if (!fs.existsSync(filePath)) return false;
196
+
197
+ const raw = fs.readFileSync(filePath, 'utf-8').trim();
198
+ if (!raw) return false;
199
+
200
+ const messages = raw.split('\n')
201
+ .filter(Boolean)
202
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
203
+ .filter(Boolean);
204
+
205
+ if (messages.length < 6) return false; // 太短不需要压缩
206
+
207
+ // 保留最近 4 条消息,压缩其余
208
+ const toSummarize = messages.slice(0, -4);
209
+ const toKeep = messages.slice(-4);
210
+
211
+ const summaryPrompt = `请用 2-3 句话总结以下对话的要点(保留关键决策、用户偏好、操作结果):\n\n${
212
+ toSummarize.map(m => `[${m.role}] ${m.content}`).join('\n').substring(0, 4000)
213
+ }`;
214
+
215
+ const summary = await aiProvider.chat(summaryPrompt, {
216
+ temperature: 0.3,
217
+ maxTokens: 300,
218
+ });
219
+
220
+ if (!summary) return false;
221
+
222
+ // 重写对话文件: 摘要 + 最近消息
223
+ const newMessages = [
224
+ { role: 'system', content: `[对话摘要] ${summary.trim()}`, ts: new Date().toISOString() },
225
+ ...toKeep,
226
+ ];
227
+
228
+ fs.writeFileSync(
229
+ filePath,
230
+ newMessages.map(m => JSON.stringify(m)).join('\n') + '\n',
231
+ 'utf-8',
232
+ );
233
+
234
+ // 更新索引
235
+ const index = this.#loadIndex();
236
+ const entry = index.find(e => e.id === conversationId);
237
+ if (entry) {
238
+ entry.hasSummary = true;
239
+ entry.messageCount = newMessages.length;
240
+ this.#saveIndex(index);
241
+ }
242
+
243
+ this.#logger.info(`[ConversationStore] summarized conversation ${conversationId}: ${messages.length} → ${newMessages.length} messages`);
244
+ return true;
245
+ } catch (err) {
246
+ this.#logger.warn(`[ConversationStore] summarize failed: ${err.message}`);
247
+ return false;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * 清理过期对话
253
+ * @param {object} [opts]
254
+ * @param {number} [opts.maxAgeDays=30] — 超过此天数的对话将被删除
255
+ * @param {'user'|'system'} [opts.category] — 只清理特定类别
256
+ * @returns {{ deleted: number }}
257
+ */
258
+ cleanup({ maxAgeDays = 30, category } = {}) {
259
+ const index = this.#loadIndex();
260
+ const cutoff = Date.now() - maxAgeDays * 86400000;
261
+ let deleted = 0;
262
+
263
+ const kept = index.filter(entry => {
264
+ if (category && entry.category !== category) return true;
265
+ const updatedAt = new Date(entry.updatedAt).getTime();
266
+ if (updatedAt < cutoff) {
267
+ this.#deleteConversationFile(entry.id);
268
+ deleted++;
269
+ return false;
270
+ }
271
+ return true;
272
+ });
273
+
274
+ if (deleted > 0) {
275
+ this.#saveIndex(kept);
276
+ this.#logger.info(`[ConversationStore] cleaned up ${deleted} old conversations`);
277
+ }
278
+
279
+ return { deleted };
280
+ }
281
+
282
+ /**
283
+ * 估算 token 数
284
+ * @param {string} text
285
+ * @returns {number}
286
+ */
287
+ estimateTokens(text) {
288
+ if (!text) return 0;
289
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
290
+ }
291
+
292
+ // ═══════════════════════════════════════════════════════
293
+ // 内部方法
294
+ // ═══════════════════════════════════════════════════════
295
+
296
+ /**
297
+ * 将消息列表裁剪到 token 预算内
298
+ * 策略: 保留首条摘要(如有) + 最新消息,丢弃中间旧消息
299
+ */
300
+ #fitWithinBudget(messages, tokenBudget) {
301
+ if (messages.length === 0) return [];
302
+
303
+ // 计算总 token
304
+ let totalTokens = 0;
305
+ const tokenCounts = messages.map(m => {
306
+ const tokens = this.estimateTokens(m.content);
307
+ totalTokens += tokens;
308
+ return tokens;
309
+ });
310
+
311
+ if (totalTokens <= tokenBudget) return messages;
312
+
313
+ // 超预算 — 保留首条(摘要) + 从末尾往前取
314
+ const result = [];
315
+ let used = 0;
316
+
317
+ // 如果首条是 system 摘要,优先保留
318
+ if (messages[0].role === 'system' && messages[0].content.startsWith('[对话摘要]')) {
319
+ result.push(messages[0]);
320
+ used += tokenCounts[0];
321
+ }
322
+
323
+ // 从末尾往前填充
324
+ const tail = [];
325
+ for (let i = messages.length - 1; i >= (result.length > 0 ? 1 : 0); i--) {
326
+ if (used + tokenCounts[i] > tokenBudget) break;
327
+ tail.unshift(messages[i]);
328
+ used += tokenCounts[i];
329
+ }
330
+
331
+ // 如果丢弃了消息,插入提示
332
+ const keptFromStart = result.length;
333
+ const keptFromEnd = tail.length;
334
+ const dropped = messages.length - keptFromStart - keptFromEnd;
335
+
336
+ if (dropped > 0) {
337
+ result.push({
338
+ role: 'system',
339
+ content: `[上下文截断] 省略了 ${dropped} 条较早的消息以适应上下文窗口。`,
340
+ });
341
+ }
342
+
343
+ result.push(...tail);
344
+ return result;
345
+ }
346
+
347
+ #conversationPath(id) {
348
+ return path.join(this.#dir, `${id}.jsonl`);
349
+ }
350
+
351
+ #deleteConversationFile(id) {
352
+ try {
353
+ const filePath = this.#conversationPath(id);
354
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
355
+ } catch { /* ignore */ }
356
+ }
357
+
358
+ #loadIndex() {
359
+ try {
360
+ if (fs.existsSync(this.#indexPath)) {
361
+ return JSON.parse(fs.readFileSync(this.#indexPath, 'utf-8'));
362
+ }
363
+ } catch { /* corrupt — reset */ }
364
+ return [];
365
+ }
366
+
367
+ #saveIndex(index) {
368
+ try {
369
+ fs.mkdirSync(this.#dir, { recursive: true });
370
+ fs.writeFileSync(this.#indexPath, JSON.stringify(index, null, 2), 'utf-8');
371
+ } catch (err) {
372
+ this.#logger.warn(`[ConversationStore] index save failed: ${err.message}`);
373
+ }
374
+ }
375
+ }
376
+
377
+ export default ConversationStore;
@@ -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
+ */
@@ -1,17 +1,24 @@
1
1
  /**
2
- * Memory — 跨对话轻量记忆
2
+ * Memory — 跨对话轻量记忆(带 source 隔离 + 去重)
3
3
  *
4
4
  * 设计:
5
5
  * - JSONL 文件存储,每行一条记忆
6
6
  * - 支持 TTL 自动过期
7
7
  * - 上限 maxEntries,超出时截断旧条目
8
8
  * - 读写均做静默降级(Memory 是增强,不是核心路径)
9
+ * - source 标签: 'user'(用户对话) / 'system'(SignalCollector 等后台)
10
+ * - 去重: 相同 type+content 的记忆不重复写入
9
11
  *
10
12
  * 记忆类型:
11
13
  * - preference: 用户偏好("我们不用 singleton"、"以后用 DI")
12
14
  * - decision: 关键决策("Network 模块审核通过")
13
15
  * - context: 项目上下文("主语言是 Swift,使用 SPM")
14
16
  *
17
+ * 隔离规则:
18
+ * toPromptSection({ source }) 可按 source 过滤
19
+ * 用户对话只看 source=user 的记忆
20
+ * 系统分析可看全部记忆
21
+ *
15
22
  * 文件路径: .autosnippet/memory.jsonl
16
23
  */
17
24
 
@@ -35,9 +42,11 @@ export class Memory {
35
42
  /**
36
43
  * 读取最近 N 条记忆,过滤过期项
37
44
  * @param {number} [limit=20]
38
- * @returns {{ ts: string, type: string, content: string, ttl?: number }[]}
45
+ * @param {object} [opts]
46
+ * @param {'user'|'system'} [opts.source] — 按 source 过滤
47
+ * @returns {{ ts: string, type: string, content: string, source?: string, ttl?: number }[]}
39
48
  */
40
- load(limit = 20) {
49
+ load(limit = 20, { source } = {}) {
41
50
  try {
42
51
  if (!fs.existsSync(this.#filePath)) return [];
43
52
  const raw = fs.readFileSync(this.#filePath, 'utf-8').trim();
@@ -48,6 +57,7 @@ export class Memory {
48
57
  .map(l => { try { return JSON.parse(l); } catch { return null; } })
49
58
  .filter(Boolean)
50
59
  .filter(m => !m.ttl || (now - new Date(m.ts).getTime()) < m.ttl * 86400000)
60
+ .filter(m => !source || (m.source || 'user') === source)
51
61
  .slice(-limit);
52
62
  } catch {
53
63
  return [];
@@ -55,14 +65,27 @@ export class Memory {
55
65
  }
56
66
 
57
67
  /**
58
- * 追加一条记忆
59
- * @param {{ type: string, content: string, ttl?: number }} entry
68
+ * 追加一条记忆(自动去重)
69
+ * @param {{ type: string, content: string, source?: string, ttl?: number }} entry
60
70
  */
61
71
  append(entry) {
62
72
  try {
73
+ // 去重: 检查是否已有相同 type+content 的记忆
74
+ const existing = this.load(this.#maxEntries);
75
+ const normalizedContent = (entry.content || '').trim().substring(0, 200);
76
+ const isDuplicate = existing.some(
77
+ m => m.type === entry.type && m.content === normalizedContent,
78
+ );
79
+ if (isDuplicate) return;
80
+
63
81
  const dir = path.dirname(this.#filePath);
64
82
  fs.mkdirSync(dir, { recursive: true });
65
- const line = JSON.stringify({ ts: new Date().toISOString(), ...entry });
83
+ const line = JSON.stringify({
84
+ ts: new Date().toISOString(),
85
+ source: entry.source || 'user',
86
+ ...entry,
87
+ content: normalizedContent,
88
+ });
66
89
  fs.appendFileSync(this.#filePath, line + '\n', 'utf-8');
67
90
  this.#compact();
68
91
  } catch { /* write failure non-critical */ }
@@ -70,10 +93,15 @@ export class Memory {
70
93
 
71
94
  /**
72
95
  * 生成供系统提示词的记忆摘要
96
+ *
97
+ * @param {object} [opts]
98
+ * @param {'user'|'system'} [opts.source] — 只包含指定 source 的记忆
99
+ * - 用户对话建议传 'user' 避免系统分析记忆污染
100
+ * - 后台分析可传 undefined 获取全部
73
101
  * @returns {string}
74
102
  */
75
- toPromptSection() {
76
- const memories = this.load();
103
+ toPromptSection({ source } = {}) {
104
+ const memories = this.load(20, { source });
77
105
  if (memories.length === 0) return '';
78
106
  const lines = memories.map(m => `- [${m.type}] ${m.content}`).join('\n');
79
107
  return `\n## 历史记忆\n以下是之前对话中积累的项目偏好和决策,请参考:\n${lines}\n`;
@@ -81,9 +109,11 @@ export class Memory {
81
109
 
82
110
  /**
83
111
  * 当前记忆条数
112
+ * @param {object} [opts]
113
+ * @param {'user'|'system'} [opts.source]
84
114
  */
85
- get size() {
86
- return this.load(this.#maxEntries).length;
115
+ size({ source } = {}) {
116
+ return this.load(this.#maxEntries, { source }).length;
87
117
  }
88
118
 
89
119
  /**