autosnippet 2.4.0 → 2.6.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 (29) hide show
  1. package/bin/cli.js +35 -0
  2. package/dashboard/dist/assets/{icons-B5rs8uNb.js → icons-rnn04CvH.js} +100 -85
  3. package/dashboard/dist/assets/index-BBKa3Dgi.js +195 -0
  4. package/dashboard/dist/assets/index-DLsECfzW.css +1 -0
  5. package/dashboard/dist/assets/{react-markdown-Bp8u1wRC.js → react-markdown-CWxUbOf4.js} +1 -1
  6. package/dashboard/dist/assets/{syntax-highlighter-C6bvFtpx.js → syntax-highlighter-CJ2drQQb.js} +1 -1
  7. package/dashboard/dist/assets/{vendor-Cky7Jynh.js → vendor-f83ah6cm.js} +13 -13
  8. package/dashboard/dist/index.html +6 -6
  9. package/lib/cli/SetupService.js +30 -4
  10. package/lib/core/gateway/Gateway.js +19 -4
  11. package/lib/external/ai/AiProvider.js +94 -10
  12. package/lib/external/mcp/McpServer.js +2 -1
  13. package/lib/external/mcp/handlers/skill.js +76 -18
  14. package/lib/external/mcp/tools.js +21 -0
  15. package/lib/http/HttpServer.js +4 -0
  16. package/lib/http/routes/search.js +5 -3
  17. package/lib/http/routes/skills.js +108 -0
  18. package/lib/infrastructure/audit/AuditStore.js +18 -0
  19. package/lib/injection/ServiceContainer.js +8 -2
  20. package/lib/service/chat/ChatAgent.js +281 -33
  21. package/lib/service/chat/ConversationStore.js +377 -0
  22. package/lib/service/chat/Memory.js +40 -10
  23. package/lib/service/chat/tools.js +104 -7
  24. package/lib/service/skills/EventAggregator.js +187 -0
  25. package/lib/service/skills/SignalCollector.js +524 -0
  26. package/lib/service/skills/SkillAdvisor.js +323 -0
  27. package/package.json +1 -1
  28. package/dashboard/dist/assets/index-0YzLw2ga.css +0 -1
  29. package/dashboard/dist/assets/index-B9py3ybr.js +0 -154
@@ -5,14 +5,14 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>AutoSnippet Dashboard</title>
8
- <script type="module" crossorigin src="/assets/index-B9py3ybr.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BBKa3Dgi.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="/assets/yaml-qRaU8Ldn.js">
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-Cky7Jynh.js">
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-f83ah6cm.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/axios-C0Zqfgkc.js">
12
- <link rel="modulepreload" crossorigin href="/assets/icons-B5rs8uNb.js">
13
- <link rel="modulepreload" crossorigin href="/assets/syntax-highlighter-C6bvFtpx.js">
14
- <link rel="modulepreload" crossorigin href="/assets/react-markdown-Bp8u1wRC.js">
15
- <link rel="stylesheet" crossorigin href="/assets/index-0YzLw2ga.css">
12
+ <link rel="modulepreload" crossorigin href="/assets/icons-rnn04CvH.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/syntax-highlighter-CJ2drQQb.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/react-markdown-CWxUbOf4.js">
15
+ <link rel="stylesheet" crossorigin href="/assets/index-DLsECfzW.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -623,11 +623,37 @@ export class SetupService {
623
623
  let content = existsSync(giPath) ? readFileSync(giPath, 'utf8') : '';
624
624
  let changed = false;
625
625
 
626
- // ── 必须忽略:.autosnippet/(运行时缓存、DB、向量索引)──
627
- if (!content.includes('.autosnippet/')) {
628
- content += `\n# AutoSnippet 运行时缓存(不入库)\n.autosnippet/\n`;
626
+ // ── v2.4.0 迁移:旧格式 ".autosnippet/" → 新格式 ".autosnippet/*" ──
627
+ // 旧格式会忽略整个目录(git 不遍历内部),导致 skills/ 和 config.json 无法被 negation 恢复
628
+ // 新格式忽略目录内所有文件,允许 negation 模式取消特定子路径
629
+ if (content.includes('.autosnippet/') && !content.includes('.autosnippet/*')) {
630
+ content = content.replace(
631
+ /^\.autosnippet\/$/m,
632
+ '.autosnippet/*',
633
+ );
629
634
  changed = true;
630
- console.log(' ✅ .gitignore += .autosnippet/');
635
+ console.log(' ✅ .gitignore: .autosnippet/ .autosnippet/*(升级为精细忽略)');
636
+ }
637
+
638
+ // ── 必须忽略:.autosnippet/*(运行时缓存、DB、向量索引、memory) ──
639
+ if (!content.includes('.autosnippet/') && !content.includes('.autosnippet/*')) {
640
+ content += `\n# AutoSnippet 运行时缓存(不入库)\n.autosnippet/*\n`;
641
+ changed = true;
642
+ console.log(' ✅ .gitignore += .autosnippet/*');
643
+ }
644
+
645
+ // ── 必须跟踪:.autosnippet/config.json(项目配置) ──
646
+ if (!content.includes('!.autosnippet/config.json')) {
647
+ content += `!.autosnippet/config.json\n`;
648
+ changed = true;
649
+ console.log(' ✅ .gitignore += !.autosnippet/config.json');
650
+ }
651
+
652
+ // ── 必须跟踪:.autosnippet/skills/(项目级 Skill 文档) ──
653
+ if (!content.includes('!.autosnippet/skills/')) {
654
+ content += `!.autosnippet/skills/\n`;
655
+ changed = true;
656
+ console.log(' ✅ .gitignore += !.autosnippet/skills/');
631
657
  }
632
658
 
633
659
  // ── 必须跟踪:AutoSnippet/(知识库子仓库)──
@@ -22,6 +22,7 @@ export class Gateway extends EventEmitter {
22
22
  this.constitutionValidator = null;
23
23
  this.permissionManager = null;
24
24
  this.auditLogger = null;
25
+ this.eventBus = null; // 可选:外部注入 EventBus 实例
25
26
  }
26
27
 
27
28
  /**
@@ -219,7 +220,7 @@ export class Gateway extends EventEmitter {
219
220
  async auditSuccess(context, result) {
220
221
  if (!this.auditLogger) return;
221
222
 
222
- await this.auditLogger.log({
223
+ const entry = {
223
224
  requestId: context.requestId,
224
225
  actor: context.actor,
225
226
  action: context.action,
@@ -227,7 +228,14 @@ export class Gateway extends EventEmitter {
227
228
  result: 'success',
228
229
  duration: Date.now() - context.startTime,
229
230
  context: { session: context.session },
230
- });
231
+ };
232
+ await this.auditLogger.log(entry);
233
+
234
+ // 向 EventBus 发送 Gateway 操作完成事件(供 SignalCollector 等监听)
235
+ if (this.eventBus) {
236
+ this.emit('gateway:action:completed', { ...entry, timestamp: Date.now() });
237
+ this.eventBus.emit('gateway:action:completed', { ...entry, timestamp: Date.now() });
238
+ }
231
239
  }
232
240
 
233
241
  /**
@@ -236,7 +244,7 @@ export class Gateway extends EventEmitter {
236
244
  async auditFailure(context, error) {
237
245
  if (!this.auditLogger) return;
238
246
 
239
- await this.auditLogger.log({
247
+ const entry = {
240
248
  requestId: context.requestId,
241
249
  actor: context.actor,
242
250
  action: context.action,
@@ -245,7 +253,14 @@ export class Gateway extends EventEmitter {
245
253
  error: error.message,
246
254
  duration: Date.now() - context.startTime,
247
255
  context: { session: context.session },
248
- });
256
+ };
257
+ await this.auditLogger.log(entry);
258
+
259
+ // 向 EventBus 发送 Gateway 操作失败事件
260
+ if (this.eventBus) {
261
+ this.emit('gateway:action:failed', { ...entry, timestamp: Date.now() });
262
+ this.eventBus.emit('gateway:action:failed', { ...entry, timestamp: Date.now() });
263
+ }
249
264
  }
250
265
 
251
266
  /**
@@ -11,6 +11,13 @@ export class AiProvider {
11
11
  this.timeout = config.timeout || 300_000; // 5min
12
12
  this.maxRetries = config.maxRetries || 3;
13
13
  this.name = 'abstract';
14
+
15
+ // ── CircuitBreaker 状态 ──
16
+ this._circuitState = 'CLOSED'; // 'CLOSED' | 'OPEN' | 'HALF_OPEN'
17
+ this._circuitFailures = 0; // 连续失败计数
18
+ this._circuitThreshold = config.circuitThreshold || 5; // 触发熔断的连续失败次数
19
+ this._circuitOpenedAt = 0; // 熔断打开时间
20
+ this._circuitCooldownMs = 30_000; // 初始冷却 30 秒
14
21
  }
15
22
 
16
23
  /**
@@ -472,11 +479,25 @@ ${items}`;
472
479
 
473
480
  /**
474
481
  * 修复被截断的 JSON 数组 — 回收已完成的对象
475
- * 策略:找到最后一个完整的 {...} 对象,截断后闭合数组
482
+ * 策略 1(主路径): 字符级解析找到最后一个完整的顶层 {...} 对象
483
+ * 策略 2(回退路径): 正则 + 渐进 JSON.parse 尝试(应对代码段中未转义引号导致 inString 追踪失效)
476
484
  */
477
485
  _repairTruncatedArray(text) {
478
- // 找到最后一个完整对象的结尾 "}," 或 "}\n" 或直接 "}"
479
- // 从后向前搜索最后一个顶层 "}" 后跟 "," 或空白
486
+ // ── 策略 1:字符级深度追踪 ──
487
+ const charResult = this._repairByCharTracking(text);
488
+ if (charResult) return charResult;
489
+
490
+ // ── 策略 2:正则回退 — 找所有 "}," 或 "}\n" 位置,从后向前逐一尝试 JSON.parse ──
491
+ const regexResult = this._repairByRegexFallback(text);
492
+ if (regexResult) return regexResult;
493
+
494
+ return null;
495
+ }
496
+
497
+ /**
498
+ * 字符级深度追踪修复(原逻辑,处理标准 JSON)
499
+ */
500
+ _repairByCharTracking(text) {
480
501
  let depth = 0;
481
502
  let inString = false;
482
503
  let escape = false;
@@ -500,13 +521,39 @@ ${items}`;
500
521
  }
501
522
 
502
523
  if (lastCompleteObjEnd === -1) return null;
524
+ return this._tryRepairAt(text, lastCompleteObjEnd);
525
+ }
503
526
 
504
- // 截取到最后一个完整对象,闭合数组
505
- let repaired = text.slice(0, lastCompleteObjEnd + 1);
527
+ /**
528
+ * 正则回退修复 不依赖 inString 追踪
529
+ * 寻找所有 "},\s*{" 或 "}\s*]" 边界,从后往前尝试 JSON.parse
530
+ */
531
+ _repairByRegexFallback(text) {
532
+ // 收集所有 "}" 后跟 "," 或空白的位置(可能是对象边界)
533
+ const candidates = [];
534
+ const re = /\}[\s,]*(?=\s*[\[{]|$)/g;
535
+ let m;
536
+ while ((m = re.exec(text)) !== null) {
537
+ candidates.push(m.index); // "}" 的位置
538
+ }
539
+
540
+ // 从后往前尝试
541
+ for (let i = candidates.length - 1; i >= 0; i--) {
542
+ const result = this._tryRepairAt(text, candidates[i]);
543
+ if (result) return result;
544
+ }
545
+ return null;
546
+ }
547
+
548
+ /**
549
+ * 在指定位置截断并尝试闭合 JSON 数组
550
+ */
551
+ _tryRepairAt(text, endPos) {
552
+ let repaired = text.slice(0, endPos + 1);
506
553
  // 去掉尾逗号
507
554
  repaired = repaired.replace(/,\s*$/, '');
508
555
  repaired += ']';
509
- // 修复尾逗号
556
+ // 修复尾逗号(对象/数组末尾多余逗号)
510
557
  repaired = repaired.replace(/,\s*([}\]])/g, '$1');
511
558
 
512
559
  try {
@@ -515,17 +562,40 @@ ${items}`;
515
562
  this._log('warn', `[extractJSON] Repaired truncated JSON array: recovered ${result.length} items from truncated response`);
516
563
  return result;
517
564
  }
518
- } catch { /* repair failed */ }
565
+ } catch { /* this position didn't work, try next */ }
519
566
  return null;
520
567
  }
521
568
 
522
569
  /**
523
- * 指数退避重试
570
+ * 指数退避重试 + 熔断器(受 Cline 三级错误恢复启发)
571
+ *
572
+ * 熔断器三态:
573
+ * CLOSED — 正常工作,计数连续失败
574
+ * OPEN — 连续 N 次失败,直接拒绝请求(快速失败),持续 cooldownMs
575
+ * HALF_OPEN — 冷却期后尝试一次,成功则恢复,失败则重新 OPEN
576
+ *
577
+ * 这避免了 AI 服务宕机时无意义的重试风暴。
524
578
  */
525
579
  async _withRetry(fn, retries = this.maxRetries, baseDelay = 2000) {
580
+ // ── 熔断器检查 ──
581
+ if (this._circuitState === 'OPEN') {
582
+ const elapsed = Date.now() - (this._circuitOpenedAt || 0);
583
+ if (elapsed < (this._circuitCooldownMs || 30000)) {
584
+ const err = new Error(`AI 服务熔断中 (连续 ${this._circuitFailures} 次失败),${Math.ceil(((this._circuitCooldownMs || 30000) - elapsed) / 1000)}s 后恢复`);
585
+ err.code = 'CIRCUIT_OPEN';
586
+ throw err;
587
+ }
588
+ // 冷却期结束 → HALF_OPEN
589
+ this._circuitState = 'HALF_OPEN';
590
+ }
591
+
526
592
  for (let attempt = 0; attempt <= retries; attempt++) {
527
593
  try {
528
- return await fn();
594
+ const result = await fn();
595
+ // 成功 → 重置熔断器
596
+ this._circuitFailures = 0;
597
+ this._circuitState = 'CLOSED';
598
+ return result;
529
599
  } catch (err) {
530
600
  // 连接超时提示代理配置
531
601
  if (err.cause?.code === 'UND_ERR_CONNECT_TIMEOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT') {
@@ -535,7 +605,21 @@ ${items}`;
535
605
  }
536
606
  }
537
607
  const isRetryable = err.status === 429 || err.status === 503 || err.code === 'ECONNRESET';
538
- if (attempt >= retries || !isRetryable) throw err;
608
+ if (attempt >= retries || !isRetryable) {
609
+ // 累计熔断失败计数
610
+ this._circuitFailures = (this._circuitFailures || 0) + 1;
611
+ if (this._circuitFailures >= (this._circuitThreshold || 5)) {
612
+ this._circuitState = 'OPEN';
613
+ this._circuitOpenedAt = Date.now();
614
+ // 逐级递增冷却时间: 30s → 60s → 120s(最大 5 分钟)
615
+ this._circuitCooldownMs = Math.min(
616
+ (this._circuitCooldownMs || 15000) * 2,
617
+ 300_000,
618
+ );
619
+ this._log?.('warn', `[CircuitBreaker] OPEN — ${this._circuitFailures} consecutive failures, cooldown ${this._circuitCooldownMs / 1000}s`);
620
+ }
621
+ throw err;
622
+ }
539
623
  const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
540
624
  await new Promise(r => setTimeout(r, delay));
541
625
  }
@@ -143,10 +143,11 @@ export class McpServer {
143
143
  // Bootstrap 冷启动
144
144
  case 'autosnippet_bootstrap_knowledge': return bootstrapHandlers.bootstrapKnowledge(ctx, args);
145
145
  case 'autosnippet_bootstrap_refine': return bootstrapHandlers.bootstrapRefine(ctx, args);
146
- // Skills 加载 & 创建
146
+ // Skills 加载 & 创建 & 推荐
147
147
  case 'autosnippet_list_skills': return skillHandlers.listSkills();
148
148
  case 'autosnippet_load_skill': return skillHandlers.loadSkill(ctx, args);
149
149
  case 'autosnippet_create_skill': return skillHandlers.createSkill(ctx, args);
150
+ case 'autosnippet_suggest_skills': return skillHandlers.suggestSkills(ctx);
150
151
  default: throw new Error(`Unknown tool: ${name}`);
151
152
  }
152
153
  }
@@ -20,27 +20,34 @@ const SKILLS_DIR = path.resolve(PROJECT_ROOT, 'skills');
20
20
  const PROJECT_SKILLS_DIR = path.resolve(PROJECT_ROOT, '.autosnippet', 'skills');
21
21
 
22
22
  /**
23
- * Skill 名称 摘要描述映射(用于 list_skills 返回)
23
+ * 解析 SKILL.md frontmatter 全部元数据
24
24
  *
25
- * SKILL.md frontmatter description 提取。
26
- * 如果解析失败,返回 Skill 名称本身。
25
+ * 返回 { description, createdBy, createdAt },缺失字段为 null。
26
+ * 同时兼容旧格式(无 createdBy 的 SKILL.md)。
27
27
  */
28
- function _parseSkillSummary(skillName, baseDir = SKILLS_DIR) {
28
+ function _parseSkillMeta(skillName, baseDir = SKILLS_DIR) {
29
29
  try {
30
30
  const content = fs.readFileSync(
31
31
  path.join(baseDir, skillName, 'SKILL.md'), 'utf8',
32
32
  );
33
- // 提取 frontmatter 的 description 字段
34
- const descMatch = content.match(/^description:\s*(.+?)(?:\n|$)/m);
35
- if (descMatch) {
36
- // 截断到第一句或 120 字符
37
- const desc = descMatch[1].trim();
38
- const firstSentence = desc.split(/\.\s/)[0];
39
- return firstSentence.length < desc.length ? `${firstSentence}.` : desc.substring(0, 120);
33
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
34
+ const meta = { description: skillName, createdBy: null, createdAt: null };
35
+ if (fmMatch) {
36
+ const fm = fmMatch[1];
37
+ const descMatch = fm.match(/^description:\s*(.+?)$/m);
38
+ if (descMatch) {
39
+ const desc = descMatch[1].trim();
40
+ const firstSentence = desc.split(/\.\s/)[0];
41
+ meta.description = firstSentence.length < desc.length ? `${firstSentence}.` : desc.substring(0, 120);
42
+ }
43
+ const cbMatch = fm.match(/^createdBy:\s*(.+?)$/m);
44
+ if (cbMatch) meta.createdBy = cbMatch[1].trim();
45
+ const caMatch = fm.match(/^createdAt:\s*(.+?)$/m);
46
+ if (caMatch) meta.createdAt = caMatch[1].trim();
40
47
  }
41
- return skillName;
48
+ return meta;
42
49
  } catch {
43
- return skillName;
50
+ return { description: skillName, createdBy: null, createdAt: null };
44
51
  }
45
52
  }
46
53
 
@@ -80,7 +87,8 @@ export function listSkills() {
80
87
  const builtinDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
81
88
  .filter(d => d.isDirectory()).map(d => d.name);
82
89
  for (const name of builtinDirs) {
83
- skillMap.set(name, { name, source: 'builtin', summary: _parseSkillSummary(name, SKILLS_DIR), useCase: SKILL_USE_CASES[name] || null });
90
+ const meta = _parseSkillMeta(name, SKILLS_DIR);
91
+ skillMap.set(name, { name, source: 'builtin', summary: meta.description, createdBy: null, createdAt: null, useCase: SKILL_USE_CASES[name] || null });
84
92
  }
85
93
 
86
94
  // 项目级 Skills(覆盖同名内置)
@@ -88,18 +96,29 @@ export function listSkills() {
88
96
  const projectDirs = fs.readdirSync(PROJECT_SKILLS_DIR, { withFileTypes: true })
89
97
  .filter(d => d.isDirectory()).map(d => d.name);
90
98
  for (const name of projectDirs) {
91
- skillMap.set(name, { name, source: 'project', summary: _parseSkillSummary(name, PROJECT_SKILLS_DIR), useCase: SKILL_USE_CASES[name] || null });
99
+ const meta = _parseSkillMeta(name, PROJECT_SKILLS_DIR);
100
+ skillMap.set(name, { name, source: 'project', summary: meta.description, createdBy: meta.createdBy, createdAt: meta.createdAt, useCase: SKILL_USE_CASES[name] || null });
92
101
  }
93
102
  } catch { /* no project skills */ }
94
103
 
95
104
  const skills = [...skillMap.values()].sort((a, b) => a.name.localeCompare(b.name));
96
105
 
106
+ // _meta:附带 SignalCollector 推荐计数(如果后台服务可用)
107
+ let suggestionCount = 0;
108
+ try {
109
+ if (global._signalCollector) {
110
+ const snapshot = global._signalCollector.getSnapshot();
111
+ suggestionCount = snapshot?.lastResult?.newSuggestions || 0;
112
+ }
113
+ } catch { /* silent */ }
114
+
97
115
  return JSON.stringify({
98
116
  success: true,
99
117
  data: {
100
118
  skills,
101
119
  total: skills.length,
102
120
  hint: '根据当前任务选择合适的 Skill 加载(load_skill)。不确定时先加载 autosnippet-intent 做意图路由。',
121
+ _meta: { signalSuggestions: suggestionCount },
103
122
  },
104
123
  });
105
124
  } catch (err) {
@@ -152,6 +171,9 @@ export function loadSkill(_ctx, args) {
152
171
  }
153
172
  }
154
173
 
174
+ // 提取 createdBy/createdAt
175
+ const meta = _parseSkillMeta(skillName, source === 'project' ? PROJECT_SKILLS_DIR : SKILLS_DIR);
176
+
155
177
  return JSON.stringify({
156
178
  success: true,
157
179
  data: {
@@ -159,6 +181,8 @@ export function loadSkill(_ctx, args) {
159
181
  source,
160
182
  content,
161
183
  charCount: content.length,
184
+ createdBy: source === 'project' ? meta.createdBy : null,
185
+ createdAt: source === 'project' ? meta.createdAt : null,
162
186
  useCase: SKILL_USE_CASES[skillName] || null,
163
187
  relatedSkills: _getRelatedSkills(skillName),
164
188
  },
@@ -193,7 +217,7 @@ export function loadSkill(_ctx, args) {
193
217
  * @returns {string} JSON envelope
194
218
  */
195
219
  export function createSkill(_ctx, args) {
196
- const { name, description, content, overwrite = false } = args || {};
220
+ const { name, description, content, overwrite = false, createdBy = 'external-ai' } = args || {};
197
221
 
198
222
  // ── 参数校验 ──
199
223
  if (!name || !description || !content) {
@@ -247,6 +271,8 @@ export function createSkill(_ctx, args) {
247
271
  '---',
248
272
  `name: ${name}`,
249
273
  `description: ${description}`,
274
+ `createdBy: ${createdBy}`,
275
+ `createdAt: ${new Date().toISOString()}`,
250
276
  '---',
251
277
  '',
252
278
  ].join('\n');
@@ -289,8 +315,8 @@ function _regenerateEditorIndex() {
289
315
  .filter(d => d.isDirectory())
290
316
  .map(d => d.name);
291
317
  for (const name of dirs) {
292
- const summary = _parseSkillSummary(name, PROJECT_SKILLS_DIR);
293
- projectSkills.push({ name, summary });
318
+ const meta = _parseSkillMeta(name, PROJECT_SKILLS_DIR);
319
+ projectSkills.push({ name, summary: meta.description });
294
320
  }
295
321
  } catch { /* no project skills dir */ }
296
322
 
@@ -332,6 +358,38 @@ function _regenerateEditorIndex() {
332
358
  }
333
359
  }
334
360
 
361
+ // ═══════════════════════════════════════════════════════════
362
+ // Handler: suggestSkills
363
+ // ═══════════════════════════════════════════════════════════
364
+
365
+ /**
366
+ * 基于项目使用模式分析,推荐创建 Skill
367
+ *
368
+ * 分析维度:Guard 违规模式、Memory 偏好积累、Recipe 分布缺口、候选积压
369
+ * Agent 可根据推荐结果自行决定是否调用 createSkill 创建
370
+ *
371
+ * @param {object} ctx MCP context(含 container)
372
+ * @returns {string} JSON envelope
373
+ */
374
+ export async function suggestSkills(ctx) {
375
+ try {
376
+ const { SkillAdvisor } = await import('../../../service/skills/SkillAdvisor.js');
377
+ const database = ctx?.container?.get?.('database') || null;
378
+ const advisor = new SkillAdvisor(PROJECT_ROOT, { database });
379
+ const result = advisor.suggest();
380
+
381
+ return JSON.stringify({
382
+ success: true,
383
+ data: result,
384
+ });
385
+ } catch (err) {
386
+ return JSON.stringify({
387
+ success: false,
388
+ error: { code: 'SUGGEST_ERROR', message: err.message },
389
+ });
390
+ }
391
+ }
392
+
335
393
  /**
336
394
  * 推荐相关 Skills(基于静态映射)
337
395
  */
@@ -658,10 +658,31 @@ export const TOOLS = [
658
658
  default: false,
659
659
  description: '如果同名 Skill 已存在,是否覆盖(默认 false)',
660
660
  },
661
+ createdBy: {
662
+ type: 'string',
663
+ enum: ['manual', 'user-ai', 'system-ai', 'external-ai'],
664
+ default: 'external-ai',
665
+ description: '创建者类型:manual=用户手动 | user-ai=用户调用AI | system-ai=系统自动 | external-ai=外部AI Agent',
666
+ },
661
667
  },
662
668
  required: ['name', 'description', 'content'],
663
669
  },
664
670
  },
671
+ // 36. Skill 推荐:基于使用模式分析,推荐创建 Skill
672
+ {
673
+ name: 'autosnippet_suggest_skills',
674
+ description:
675
+ '基于项目使用模式分析,推荐创建 Skill。\n' +
676
+ '分析 4 个维度:Guard 违规模式、Memory 偏好积累、Recipe 分布缺口、候选积压率。\n' +
677
+ '返回推荐列表(含 name / description / rationale / priority),Agent 可据此直接调用 autosnippet_create_skill 创建。\n' +
678
+ '\n' +
679
+ '使用时机:\n' +
680
+ ' • 项目使用一段时间后,定期调用检查是否有新的 Skill 需求\n' +
681
+ ' • 用户反复说"我们项目不用…"、"以后都…"等偏好表述时\n' +
682
+ ' • Guard 违规频繁出现同一规则时\n' +
683
+ ' • 候选被大量驳回时',
684
+ inputSchema: { type: 'object', properties: {}, required: [] },
685
+ },
665
686
  // 36. ② 内容润色:Bootstrap 候选 AI 精炼(Phase 6)
666
687
  {
667
688
  name: 'autosnippet_bootstrap_refine',
@@ -26,6 +26,7 @@ import commandsRouter from './routes/commands.js';
26
26
  import spmRouter from './routes/spm.js';
27
27
  import violationsRouter from './routes/violations.js';
28
28
  import authRouter from './routes/auth.js';
29
+ import skillsRouter from './routes/skills.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';
@@ -258,6 +259,9 @@ export class HttpServer {
258
259
  // 命令路由
259
260
  this.app.use(`${apiPrefix}/commands`, commandsRouter);
260
261
 
262
+ // Skills 路由
263
+ this.app.use(`${apiPrefix}/skills`, skillsRouter);
264
+
261
265
  // SPM 路由
262
266
  this.app.use(`${apiPrefix}/spm`, spmRouter);
263
267
 
@@ -173,15 +173,17 @@ router.get('/graph/all', asyncHandler(async (req, res) => {
173
173
  const nodeCategories = {}; // id → category/target 名(供前端分组布局)
174
174
  if (nodeMap.size > 0) {
175
175
  const recipeService = container.get('recipeService');
176
+ // 使用 repository.findById 避免 RecipeService.getRecipe 在找不到时记录 error 日志
177
+ const recipeRepo = recipeService?.recipeRepository ?? null;
176
178
  for (const [id, types] of nodeMap) {
177
179
  // 记录节点主要类型
178
180
  const primaryType = types.has('recipe') ? 'recipe' : [...types][0];
179
181
  nodeTypes[id] = primaryType;
180
182
 
181
- if (primaryType === 'recipe' && recipeService) {
182
- // 仅 recipe 类型才查 recipeService
183
+ if (primaryType === 'recipe' && recipeRepo) {
184
+ // 仅 recipe 类型才查 DB;直接用 repo 避免 NotFoundError 日志噪音
183
185
  try {
184
- const r = await recipeService.getRecipe(id);
186
+ const r = await recipeRepo.findById(id);
185
187
  if (r) {
186
188
  nodeLabels[id] = r.title || r.name || id;
187
189
  nodeCategories[id] = r.category || '';
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Skills API 路由
3
+ * 管理 Agent Skills 的查询、加载和创建(项目级)
4
+ */
5
+
6
+ import express from 'express';
7
+ import { asyncHandler } from '../middleware/errorHandler.js';
8
+ import { listSkills, loadSkill, createSkill, suggestSkills } from '../../external/mcp/handlers/skill.js';
9
+ import { ValidationError } from '../../shared/errors/index.js';
10
+
11
+ const router = express.Router();
12
+
13
+ /**
14
+ * GET /api/v1/skills
15
+ * 列出所有可用 Skills(内置 + 项目级)
16
+ */
17
+ router.get('/', asyncHandler(async (_req, res) => {
18
+ const raw = listSkills();
19
+ const parsed = JSON.parse(raw);
20
+
21
+ if (!parsed.success) {
22
+ return res.status(500).json(parsed);
23
+ }
24
+
25
+ res.json({ success: true, data: parsed.data });
26
+ }));
27
+
28
+ /**
29
+ * GET /api/v1/skills/signal-status
30
+ * 获取 SignalCollector 后台服务状态
31
+ */
32
+ router.get('/signal-status', asyncHandler(async (_req, res) => {
33
+ const { _signalCollector } = global;
34
+ if (!_signalCollector) {
35
+ return res.json({ success: true, data: { running: false, mode: 'off', snapshot: null } });
36
+ }
37
+ res.json({
38
+ success: true,
39
+ data: {
40
+ running: true,
41
+ mode: _signalCollector.getMode(),
42
+ snapshot: _signalCollector.getSnapshot(),
43
+ },
44
+ });
45
+ }));
46
+
47
+ /**
48
+ * GET /api/v1/skills/suggest
49
+ * 基于使用模式分析,推荐创建 Skill
50
+ */
51
+ router.get('/suggest', asyncHandler(async (req, res) => {
52
+ const ctx = { container: req.app.locals?.container || null };
53
+ const raw = await suggestSkills(ctx);
54
+ const parsed = JSON.parse(raw);
55
+
56
+ if (!parsed.success) {
57
+ return res.status(500).json(parsed);
58
+ }
59
+
60
+ res.json({ success: true, data: parsed.data });
61
+ }));
62
+
63
+ /**
64
+ * GET /api/v1/skills/:name
65
+ * 加载指定 Skill 的完整文档
66
+ * Query: ?section=xxx 可只返回指定章节
67
+ */
68
+ router.get('/:name', asyncHandler(async (req, res) => {
69
+ const { name } = req.params;
70
+ const { section } = req.query;
71
+
72
+ const raw = loadSkill(null, { skillName: name, section });
73
+ const parsed = JSON.parse(raw);
74
+
75
+ if (!parsed.success) {
76
+ const status = parsed.error?.code === 'SKILL_NOT_FOUND' ? 404 : 400;
77
+ return res.status(status).json(parsed);
78
+ }
79
+
80
+ res.json({ success: true, data: parsed.data });
81
+ }));
82
+
83
+ /**
84
+ * POST /api/v1/skills
85
+ * 创建项目级 Skill
86
+ * Body: { name, description, content, overwrite? }
87
+ */
88
+ router.post('/', asyncHandler(async (req, res) => {
89
+ const { name, description, content, overwrite, createdBy } = req.body;
90
+
91
+ if (!name || !description || !content) {
92
+ throw new ValidationError('name, description, content are all required');
93
+ }
94
+
95
+ const raw = createSkill(null, { name, description, content, overwrite, createdBy: createdBy || 'manual' });
96
+ const parsed = JSON.parse(raw);
97
+
98
+ if (!parsed.success) {
99
+ const status = parsed.error?.code === 'BUILTIN_CONFLICT' ? 409
100
+ : parsed.error?.code === 'ALREADY_EXISTS' ? 409
101
+ : parsed.error?.code === 'INVALID_NAME' ? 400 : 500;
102
+ return res.status(status).json(parsed);
103
+ }
104
+
105
+ res.status(201).json({ success: true, data: parsed.data });
106
+ }));
107
+
108
+ export default router;
@@ -193,6 +193,24 @@ export class AuditStore {
193
193
  byAction,
194
194
  };
195
195
  }
196
+
197
+ /**
198
+ * 清理过期审计日志
199
+ * @param {object} [opts]
200
+ * @param {number} [opts.maxAgeDays=90] — 保留天数,超过此天数的记录将被删除
201
+ * @returns {{ deleted: number }}
202
+ */
203
+ cleanup({ maxAgeDays = 90 } = {}) {
204
+ try {
205
+ const cutoff = Date.now() - maxAgeDays * 86400000;
206
+ const result = this.db.prepare(
207
+ 'DELETE FROM audit_logs WHERE timestamp < ?'
208
+ ).run(cutoff);
209
+ return { deleted: result.changes || 0 };
210
+ } catch {
211
+ return { deleted: 0 };
212
+ }
213
+ }
196
214
  }
197
215
 
198
216
  export default AuditStore;