autosnippet 2.5.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.
@@ -0,0 +1,524 @@
1
+ /**
2
+ * SignalCollector — AI 驱动的后台行为分析与 Skill 推荐引擎
3
+ *
4
+ * 在 `asd ui` 运行时作为后台守护进程运行,周期性收集多维度信号并
5
+ * 通过 ChatAgent(AI ReAct 循环)进行深度分析,生成 Skill 推荐。
6
+ *
7
+ * 三种工作模式:
8
+ * - off — 不收集,不推荐
9
+ * - suggest — 收集信号 → AI 分析 → 推送推荐(默认)
10
+ * - auto — 收集信号 → AI 分析 → 推送推荐 + AI 自动创建 Skill
11
+ *
12
+ * 核心架构:
13
+ * 每次 tick → 收集 6 维度信号 → 构造分析 prompt → ChatAgent.execute()
14
+ * → AI ReAct 循环(可调用 suggest_skills / create_skill 等工具)
15
+ * → 解析 AI 响应(suggestions + nextIntervalMinutes + summary)
16
+ * → 推送建议 → 动态调整下次执行间隔
17
+ *
18
+ * 6 大信号维度:
19
+ * 1. Guard 冲突信号 — 当前错误/冲突检测
20
+ * 2. 对话记忆信号 — 用户近期对话主题
21
+ * 3. Recipe 健康信号 — 模板使用情况与质量
22
+ * 4. Candidate 堆积信号 — 待处理候选 Skill 分析
23
+ * 5. 操作日志信号 — 近期用户操作模式
24
+ * 6. 代码变更信号 — 项目 git diff 分析
25
+ *
26
+ * 设计原则:
27
+ * 1. 静默 — 不打断用户,后台运行,所有错误降级
28
+ * 2. 增量 — 只分析上次快照以来的新数据
29
+ * 3. 去重 — 同一推荐仅推送一次
30
+ * 4. AI 驱动 — 所有分析决策由 ChatAgent 完成
31
+ * 5. 自适应 — AI 根据信号密度动态调整执行频率
32
+ *
33
+ * 前提条件:
34
+ * 需要可用的 AI Provider(chatAgent.hasAI === true)
35
+ *
36
+ * 生命周期:
37
+ * new SignalCollector(opts) → instance.start() → ... → instance.stop()
38
+ */
39
+
40
+ import fs from 'node:fs';
41
+ import path from 'node:path';
42
+ import { execSync } from 'node:child_process';
43
+ import Logger from '../../infrastructure/logging/Logger.js';
44
+ import { EventAggregator } from './EventAggregator.js';
45
+
46
+ const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 小时(初始值,AI 可动态调整)
47
+ const MIN_INTERVAL_MS = 5 * 60 * 1000; // 最短 5 分钟
48
+ const MAX_INTERVAL_MS = 24 * 60 * 60 * 1000; // 最长 24 小时
49
+ const SNAPSHOT_FILE = 'signal-snapshot.json';
50
+
51
+ export class SignalCollector {
52
+ #projectRoot;
53
+ #db;
54
+ #chatAgent; // ChatAgent 实例 — AI 核心
55
+ #mode; // 'off' | 'suggest' | 'auto'
56
+ #intervalMs;
57
+ #timer = null;
58
+ #running = false;
59
+ #logger;
60
+ #snapshotPath;
61
+ #snapshot;
62
+ #onSuggestions; // callback(suggestions[]) — 由外部注入(如 RealtimeService 推送)
63
+ /** @type {EventAggregator} 信号聚类引擎 */
64
+ #aggregator;
65
+
66
+ /**
67
+ * @param {object} opts
68
+ * @param {string} opts.projectRoot — 用户项目根目录
69
+ * @param {object} [opts.database] — better-sqlite3 实例
70
+ * @param {object} [opts.chatAgent] — ChatAgent 实例
71
+ * @param {string} [opts.mode] — 'off' | 'suggest' | 'auto'
72
+ * @param {number} [opts.intervalMs] — 初始收集间隔(毫秒),后续由 AI 动态调整
73
+ * @param {function} [opts.onSuggestions] — 新建议回调 (suggestions[]) => void
74
+ */
75
+ constructor({
76
+ projectRoot,
77
+ database = null,
78
+ chatAgent = null,
79
+ mode = 'suggest',
80
+ intervalMs = DEFAULT_INTERVAL_MS,
81
+ onSuggestions = null,
82
+ }) {
83
+ this.#projectRoot = projectRoot;
84
+ this.#db = database;
85
+ this.#chatAgent = chatAgent;
86
+ this.#mode = ['off', 'suggest', 'auto'].includes(mode) ? mode : 'suggest';
87
+ this.#intervalMs = Math.max(Math.min(intervalMs, MAX_INTERVAL_MS), MIN_INTERVAL_MS);
88
+ this.#logger = Logger.getInstance();
89
+ this.#onSuggestions = onSuggestions;
90
+
91
+ const dotDir = path.join(projectRoot, '.autosnippet');
92
+ this.#snapshotPath = path.join(dotDir, SNAPSHOT_FILE);
93
+ this.#snapshot = this.#loadSnapshot();
94
+
95
+ // 信号聚类引擎: 外部推送的事件(file_change, guard_violation 等)
96
+ // 在时间窗口内聚合,避免高频操作重复触发 AI 分析
97
+ this.#aggregator = new EventAggregator({ windowMs: 10_000, dedupeMs: 120_000 });
98
+ this.#aggregator.on('batch', (key, events) => {
99
+ this.#logger.info(`[SignalCollector] aggregated batch: ${key} × ${events.length}`);
100
+ // 有聚合事件时提前触发 tick(取消当前定时器,立即执行)
101
+ if (this.#timer && !this.#running) {
102
+ clearTimeout(this.#timer);
103
+ this.#timer = setTimeout(() => this.#tick(), 3000); // 3 秒后执行,留出更多聚合时间
104
+ }
105
+ });
106
+ }
107
+
108
+ // ═══════════════════════════════════════════════════════
109
+ // 公共 API
110
+ // ═══════════════════════════════════════════════════════
111
+
112
+ start() {
113
+ if (this.#mode === 'off') {
114
+ this.#logger.info('[SignalCollector] mode=off, skipping start');
115
+ return;
116
+ }
117
+ if (!this.#chatAgent?.hasAI) {
118
+ this.#logger.info('[SignalCollector] no AI provider available, skipping start');
119
+ return;
120
+ }
121
+ if (this.#timer) {
122
+ this.#logger.warn('[SignalCollector] already running, ignoring start()');
123
+ return;
124
+ }
125
+
126
+ this.#logger.info(
127
+ `[SignalCollector] started — mode=${this.#mode}, initialInterval=${this.#intervalMs}ms, AI-driven`
128
+ );
129
+
130
+ // 首次延迟 15 秒后执行(等启动流程稳定)
131
+ this.#timer = setTimeout(() => this.#tick(), 15_000);
132
+ }
133
+
134
+ stop() {
135
+ if (this.#timer) {
136
+ clearTimeout(this.#timer);
137
+ this.#timer = null;
138
+ }
139
+ this.#running = false;
140
+ this.#aggregator.destroy();
141
+ this.#logger.info('[SignalCollector] stopped');
142
+ }
143
+
144
+ /**
145
+ * 外部事件推送入口(由 FileWatcher / Guard / CLI 等调用)
146
+ *
147
+ * 事件会经过 EventAggregator 聚合后触发提前分析。
148
+ * @param {string} key — 事件类型(如 'file_change', 'guard_violation', 'candidate_submit')
149
+ * @param {object} event — 事件数据
150
+ */
151
+ pushEvent(key, event) {
152
+ if (this.#mode === 'off') return;
153
+ this.#aggregator.push(key, event);
154
+ }
155
+
156
+ async collect() {
157
+ return this.#tick();
158
+ }
159
+
160
+ getSnapshot() { return { ...this.#snapshot }; }
161
+ getMode() { return this.#mode; }
162
+
163
+ setMode(mode) {
164
+ if (!['off', 'suggest', 'auto'].includes(mode)) return;
165
+ this.#mode = mode;
166
+ this.#logger.info(`[SignalCollector] mode changed to ${mode}`);
167
+ if (mode === 'off') this.stop();
168
+ }
169
+
170
+ // ═══════════════════════════════════════════════════════
171
+ // 核心 AI 分析循环
172
+ // ═══════════════════════════════════════════════════════
173
+
174
+ async #tick() {
175
+ if (this.#running) return null;
176
+ this.#running = true;
177
+
178
+ try {
179
+ // 1. 多维度收集信号
180
+ const signals = {
181
+ guard: this.#collectGuardSignals(),
182
+ memory: this.#collectMemorySignals(),
183
+ recipes: this.#collectRecipeSignals(),
184
+ candidates: this.#collectCandidateSignals(),
185
+ actions: this.#collectRecentActions(),
186
+ codeChanges: this.#collectCodeChangeSignals(),
187
+ };
188
+
189
+ // 2. 构造分析 prompt
190
+ const prompt = this.#buildAnalysisPrompt(signals);
191
+
192
+ // 3. 调用 ChatAgent AI 分析(source: 'system' 确保 Memory 隔离)
193
+ this.#logger.debug('[SignalCollector] invoking ChatAgent for analysis...');
194
+ const { reply, toolCalls } = await this.#chatAgent.execute(prompt, { history: [], source: 'system' });
195
+
196
+ // 4. 解析 AI 响应
197
+ const parsed = this.#parseAiResponse(reply);
198
+ const suggestions = parsed.suggestions || [];
199
+
200
+ // 5. 过滤已推送
201
+ const newSuggestions = suggestions.filter(
202
+ s => !this.#snapshot.pushedNames.includes(s.name),
203
+ );
204
+
205
+ // 6. 更新快照
206
+ this.#snapshot.lastRun = new Date().toISOString();
207
+ this.#snapshot.totalRuns = (this.#snapshot.totalRuns || 0) + 1;
208
+ this.#snapshot.lastAiSummary = parsed.summary || '';
209
+ this.#snapshot.lastResult = {
210
+ totalSuggestions: suggestions.length,
211
+ newSuggestions: newSuggestions.length,
212
+ aiToolCalls: toolCalls?.length || 0,
213
+ };
214
+
215
+ if (newSuggestions.length > 0) {
216
+ for (const s of newSuggestions) {
217
+ if (!this.#snapshot.pushedNames.includes(s.name)) {
218
+ this.#snapshot.pushedNames.push(s.name);
219
+ }
220
+ }
221
+
222
+ // 推送建议
223
+ if (this.#onSuggestions) {
224
+ try { this.#onSuggestions(newSuggestions); }
225
+ catch (err) {
226
+ this.#logger.warn(`[SignalCollector] onSuggestions callback error: ${err.message}`);
227
+ }
228
+ }
229
+
230
+ // 检测 AI 是否在 auto 模式下自主调用了 create_skill
231
+ if (this.#mode === 'auto' && toolCalls?.length) {
232
+ const created = toolCalls.filter(tc => tc.tool === 'create_skill');
233
+ if (created.length > 0) {
234
+ if (!this.#snapshot.autoCreated) this.#snapshot.autoCreated = [];
235
+ for (const tc of created) {
236
+ this.#snapshot.autoCreated.push({
237
+ name: tc.params?.name || 'unknown',
238
+ createdAt: new Date().toISOString(),
239
+ });
240
+ }
241
+ this.#logger.info(`[SignalCollector] AI auto-created ${created.length} skill(s)`);
242
+ }
243
+ }
244
+
245
+ this.#logger.info(`[SignalCollector] tick done — ${newSuggestions.length} new suggestions`);
246
+ } else {
247
+ this.#logger.debug('[SignalCollector] tick done — no new suggestions');
248
+ }
249
+
250
+ // 7. AI 动态调节下次间隔
251
+ if (parsed.nextIntervalMinutes && typeof parsed.nextIntervalMinutes === 'number') {
252
+ const aiMs = parsed.nextIntervalMinutes * 60 * 1000;
253
+ this.#intervalMs = Math.max(MIN_INTERVAL_MS, Math.min(aiMs, MAX_INTERVAL_MS));
254
+ this.#logger.info(`[SignalCollector] AI adjusted next interval to ${parsed.nextIntervalMinutes}min`);
255
+ }
256
+
257
+ // 8. 持久化快照
258
+ this.#saveSnapshot();
259
+
260
+ // 9. 调度下次执行
261
+ this.#scheduleNext(this.#intervalMs);
262
+
263
+ return { suggestions: newSuggestions, stats: this.#snapshot.lastResult };
264
+ } catch (err) {
265
+ this.#logger.warn(`[SignalCollector] tick error: ${err.message}`);
266
+ // 出错后也要调度下次(间隔加倍退避)
267
+ this.#scheduleNext(Math.min(this.#intervalMs * 2, MAX_INTERVAL_MS));
268
+ return { suggestions: [], stats: null };
269
+ } finally {
270
+ this.#running = false;
271
+ }
272
+ }
273
+
274
+ #scheduleNext(delayMs) {
275
+ if (this.#mode === 'off') return;
276
+ this.#timer = setTimeout(() => this.#tick(), delayMs);
277
+ }
278
+
279
+ // ═══════════════════════════════════════════════════════
280
+ // 信号收集器(6 维度)
281
+ // ═══════════════════════════════════════════════════════
282
+
283
+ #collectGuardSignals() {
284
+ try {
285
+ if (!this.#db) return [];
286
+ // audit_logs 中 action='guard:check' + result='violation' 的记录
287
+ const rows = this.#db.prepare(
288
+ `SELECT json_extract(operation_data, '$.ruleName') as ruleName,
289
+ COUNT(*) as cnt,
290
+ MAX(timestamp) as last_at
291
+ FROM audit_logs
292
+ WHERE action LIKE 'guard%'
293
+ AND result = 'violation'
294
+ GROUP BY ruleName
295
+ HAVING cnt > 0
296
+ ORDER BY cnt DESC LIMIT 20`
297
+ ).all();
298
+ return rows;
299
+ } catch { return []; }
300
+ }
301
+
302
+ #collectMemorySignals() {
303
+ try {
304
+ const memoryFile = path.join(this.#projectRoot, '.autosnippet', 'memory.jsonl');
305
+ if (!fs.existsSync(memoryFile)) return [];
306
+ const lines = fs.readFileSync(memoryFile, 'utf-8').trim().split('\n');
307
+ return lines.slice(-20).map(line => {
308
+ try { return JSON.parse(line); } catch { return null; }
309
+ }).filter(Boolean);
310
+ } catch { return []; }
311
+ }
312
+
313
+ #collectRecipeSignals() {
314
+ try {
315
+ if (!this.#db) return [];
316
+ const rows = this.#db.prepare(
317
+ `SELECT id, title, knowledge_type, category, language,
318
+ adoption_count, application_count, success_count,
319
+ quality_overall, updated_at
320
+ FROM recipes ORDER BY updated_at DESC LIMIT 30`
321
+ ).all();
322
+ return rows;
323
+ } catch { return []; }
324
+ }
325
+
326
+ #collectCandidateSignals() {
327
+ try {
328
+ if (!this.#db) return [];
329
+ const rows = this.#db.prepare(
330
+ `SELECT id, source, status, language, category,
331
+ json_extract(metadata_json, '$.title') as title,
332
+ created_at
333
+ FROM candidates WHERE status = 'pending'
334
+ ORDER BY created_at DESC LIMIT 30`
335
+ ).all();
336
+ return rows;
337
+ } catch { return []; }
338
+ }
339
+
340
+ #collectRecentActions() {
341
+ try {
342
+ if (!this.#db) return [];
343
+ // audit_logs.timestamp 是 INTEGER (epoch seconds)
344
+ const sinceStr = this.#snapshot.lastRun;
345
+ const sinceTs = sinceStr
346
+ ? Math.floor(new Date(sinceStr).getTime() / 1000)
347
+ : Math.floor((Date.now() - 24 * 3600 * 1000) / 1000);
348
+ const rows = this.#db.prepare(
349
+ `SELECT actor, action, resource, result, timestamp
350
+ FROM audit_logs WHERE timestamp > ?
351
+ ORDER BY timestamp DESC LIMIT 50`
352
+ ).all(sinceTs);
353
+ return rows;
354
+ } catch { return []; }
355
+ }
356
+
357
+ #collectCodeChangeSignals() {
358
+ try {
359
+ const diff = execSync('git diff --stat HEAD~1 2>/dev/null || echo ""', {
360
+ cwd: this.#projectRoot,
361
+ encoding: 'utf-8',
362
+ timeout: 5000,
363
+ }).trim();
364
+ if (!diff) return [];
365
+ return diff.split('\n').slice(0, 20);
366
+ } catch { return []; }
367
+ }
368
+
369
+ // ═══════════════════════════════════════════════════════
370
+ // AI Prompt 构建
371
+ // ═══════════════════════════════════════════════════════
372
+
373
+ #buildAnalysisPrompt(signals) {
374
+ const modeInstruction = this.#mode === 'auto'
375
+ ? '你处于 auto 模式:除了推荐之外,对于高优先级的建议,请直接调用 create_skill 工具自动创建 Skill。'
376
+ : '你处于 suggest 模式:只输出推荐,不要自动创建 Skill。';
377
+
378
+ return `你是 AutoSnippet 的后台行为分析 AI。你的任务是分析以下多维度信号,判断用户当前的开发状态,并给出 Skill 推荐建议。
379
+
380
+ ${modeInstruction}
381
+
382
+ ## 信号数据
383
+
384
+ ### 1. Guard 冲突信号
385
+ ${JSON.stringify(signals.guard, null, 2)}
386
+
387
+ ### 2. 对话记忆(近期对话主题)
388
+ ${JSON.stringify(signals.memory, null, 2)}
389
+
390
+ ### 3. Recipe 模板健康度
391
+ ${JSON.stringify(signals.recipes, null, 2)}
392
+
393
+ ### 4. 待处理 Candidate
394
+ ${JSON.stringify(signals.candidates, null, 2)}
395
+
396
+ ### 5. 近期操作日志
397
+ ${JSON.stringify(signals.actions, null, 2)}
398
+
399
+ ### 6. 代码变更(git diff --stat)
400
+ ${JSON.stringify(signals.codeChanges, null, 2)}
401
+
402
+ ## 分析要求
403
+
404
+ 1. 综合分析以上 6 个维度的信号
405
+ 2. 识别重复模式、高频错误、未覆盖的操作
406
+ 3. 给出 Skill 推荐建议(名称、原因、优先级、推荐 body)
407
+ 4. 根据信号密度判断下次分析应间隔多久(5-1440 分钟)
408
+ 5. 给出简要分析摘要
409
+
410
+ ## 输出格式
411
+
412
+ 在你的回复最后一行,输出一个 JSON 对象(不要包在 markdown code block 中):
413
+ {"suggestions":[{"name":"skill-name","reason":"推荐原因","priority":"high|medium|low","body":"推荐的 Skill 内容"}],"nextIntervalMinutes":60,"summary":"一句话分析摘要"}`;
414
+ }
415
+
416
+ // ═══════════════════════════════════════════════════════
417
+ // AI 响应解析
418
+ // ═══════════════════════════════════════════════════════
419
+
420
+ #parseAiResponse(reply) {
421
+ if (!reply) return { suggestions: [], nextIntervalMinutes: null, summary: '' };
422
+
423
+ try {
424
+ // 策略 1:尝试从最后一行解析 JSON
425
+ const lines = reply.trim().split('\n');
426
+ for (let i = lines.length - 1; i >= 0; i--) {
427
+ const line = lines[i].trim();
428
+ if (line.startsWith('{') && line.endsWith('}')) {
429
+ try {
430
+ const obj = JSON.parse(line);
431
+ if (obj.suggestions) return obj;
432
+ } catch { /* 继续尝试 */ }
433
+ }
434
+ }
435
+
436
+ // 策略 2:尝试从 ```json ... ``` 块解析
437
+ const codeBlockMatch = reply.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
438
+ if (codeBlockMatch) {
439
+ try {
440
+ const obj = JSON.parse(codeBlockMatch[1].trim());
441
+ if (obj.suggestions) return obj;
442
+ } catch { /* fallthrough */ }
443
+ }
444
+
445
+ // 策略 3:尝试找到任何 JSON 对象
446
+ const jsonMatch = reply.match(/\{[\s\S]*"suggestions"\s*:\s*\[[\s\S]*\][\s\S]*\}/);
447
+ if (jsonMatch) {
448
+ try {
449
+ const obj = JSON.parse(jsonMatch[0]);
450
+ if (obj.suggestions) return obj;
451
+ } catch { /* fallthrough */ }
452
+ }
453
+ } catch {
454
+ this.#logger.warn('[SignalCollector] failed to parse AI response');
455
+ }
456
+
457
+ return { suggestions: [], nextIntervalMinutes: null, summary: '' };
458
+ }
459
+
460
+ // ═══════════════════════════════════════════════════════
461
+ // 快照持久化
462
+ // ═══════════════════════════════════════════════════════
463
+
464
+ #loadSnapshot() {
465
+ try {
466
+ if (fs.existsSync(this.#snapshotPath)) {
467
+ const raw = fs.readFileSync(this.#snapshotPath, 'utf-8');
468
+ const data = JSON.parse(raw);
469
+ return {
470
+ lastRun: data.lastRun || null,
471
+ totalRuns: data.totalRuns || 0,
472
+ pushedNames: Array.isArray(data.pushedNames) ? data.pushedNames : [],
473
+ lastResult: data.lastResult || null,
474
+ lastAiSummary: data.lastAiSummary || '',
475
+ autoCreated: Array.isArray(data.autoCreated) ? data.autoCreated : [],
476
+ };
477
+ }
478
+ } catch { /* corrupt — reset */ }
479
+
480
+ return {
481
+ lastRun: null,
482
+ totalRuns: 0,
483
+ pushedNames: [],
484
+ lastResult: null,
485
+ lastAiSummary: '',
486
+ autoCreated: [],
487
+ };
488
+ }
489
+
490
+ #saveSnapshot() {
491
+ try {
492
+ // 自动截断无限增长的数组
493
+ const MAX_PUSHED = 200;
494
+ const MAX_AUTO_CREATED = 100;
495
+ if (this.#snapshot.pushedNames.length > MAX_PUSHED) {
496
+ this.#snapshot.pushedNames = this.#snapshot.pushedNames.slice(-MAX_PUSHED);
497
+ }
498
+ if (this.#snapshot.autoCreated && this.#snapshot.autoCreated.length > MAX_AUTO_CREATED) {
499
+ this.#snapshot.autoCreated = this.#snapshot.autoCreated.slice(-MAX_AUTO_CREATED);
500
+ }
501
+
502
+ const dir = path.dirname(this.#snapshotPath);
503
+ if (!fs.existsSync(dir)) {
504
+ fs.mkdirSync(dir, { recursive: true });
505
+ }
506
+ fs.writeFileSync(this.#snapshotPath, JSON.stringify(this.#snapshot, null, 2), 'utf-8');
507
+ } catch (err) {
508
+ this.#logger.warn(`[SignalCollector] snapshot save failed: ${err.message}`);
509
+ }
510
+ }
511
+
512
+ // ═══════════════════════════════════════════════════════
513
+ // 重置
514
+ // ═══════════════════════════════════════════════════════
515
+
516
+ resetPushed() {
517
+ this.#snapshot.pushedNames = [];
518
+ this.#snapshot.autoCreated = [];
519
+ this.#saveSnapshot();
520
+ this.#logger.info('[SignalCollector] pushed history reset');
521
+ }
522
+ }
523
+
524
+ export default SignalCollector;