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.
- package/bin/cli.js +35 -0
- package/dashboard/dist/assets/{icons-B5rs8uNb.js → icons-rnn04CvH.js} +100 -85
- package/dashboard/dist/assets/index-BBKa3Dgi.js +195 -0
- package/dashboard/dist/assets/index-DLsECfzW.css +1 -0
- package/dashboard/dist/assets/{react-markdown-Bp8u1wRC.js → react-markdown-CWxUbOf4.js} +1 -1
- package/dashboard/dist/assets/{syntax-highlighter-C6bvFtpx.js → syntax-highlighter-CJ2drQQb.js} +1 -1
- package/dashboard/dist/assets/{vendor-Cky7Jynh.js → vendor-f83ah6cm.js} +13 -13
- package/dashboard/dist/index.html +6 -6
- package/lib/cli/SetupService.js +30 -4
- package/lib/core/gateway/Gateway.js +19 -4
- package/lib/external/ai/AiProvider.js +94 -10
- package/lib/external/mcp/McpServer.js +2 -1
- package/lib/external/mcp/handlers/skill.js +76 -18
- package/lib/external/mcp/tools.js +21 -0
- package/lib/http/HttpServer.js +4 -0
- package/lib/http/routes/search.js +5 -3
- package/lib/http/routes/skills.js +108 -0
- package/lib/infrastructure/audit/AuditStore.js +18 -0
- package/lib/injection/ServiceContainer.js +8 -2
- package/lib/service/chat/ChatAgent.js +281 -33
- package/lib/service/chat/ConversationStore.js +377 -0
- package/lib/service/chat/Memory.js +40 -10
- package/lib/service/chat/tools.js +104 -7
- package/lib/service/skills/EventAggregator.js +187 -0
- package/lib/service/skills/SignalCollector.js +524 -0
- package/lib/service/skills/SkillAdvisor.js +323 -0
- package/package.json +1 -1
- package/dashboard/dist/assets/index-0YzLw2ga.css +0 -1
- 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-
|
|
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-
|
|
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-
|
|
13
|
-
<link rel="modulepreload" crossorigin href="/assets/syntax-highlighter-
|
|
14
|
-
<link rel="modulepreload" crossorigin href="/assets/react-markdown-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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>
|
package/lib/cli/SetupService.js
CHANGED
|
@@ -623,11 +623,37 @@ export class SetupService {
|
|
|
623
623
|
let content = existsSync(giPath) ? readFileSync(giPath, 'utf8') : '';
|
|
624
624
|
let changed = false;
|
|
625
625
|
|
|
626
|
-
// ──
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 { /*
|
|
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
|
-
|
|
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)
|
|
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
|
-
*
|
|
23
|
+
* 解析 SKILL.md frontmatter 全部元数据
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* 返回 { description, createdBy, createdAt },缺失字段为 null。
|
|
26
|
+
* 同时兼容旧格式(无 createdBy 的 SKILL.md)。
|
|
27
27
|
*/
|
|
28
|
-
function
|
|
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
|
-
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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',
|
package/lib/http/HttpServer.js
CHANGED
|
@@ -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' &&
|
|
182
|
-
// 仅 recipe 类型才查
|
|
183
|
+
if (primaryType === 'recipe' && recipeRepo) {
|
|
184
|
+
// 仅 recipe 类型才查 DB;直接用 repo 避免 NotFoundError 日志噪音
|
|
183
185
|
try {
|
|
184
|
-
const r = await
|
|
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;
|