autosnippet 2.8.3 → 2.10.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 (110) hide show
  1. package/README.md +5 -5
  2. package/bin/cli.js +5 -33
  3. package/config/constitution.yaml +9 -2
  4. package/dashboard/dist/assets/{icons-B_Xg4B-s.js → icons-BkT3XrKf.js} +105 -100
  5. package/dashboard/dist/assets/index-BsB7DzW4.css +1 -0
  6. package/dashboard/dist/assets/index-DdmQMrJJ.js +155 -0
  7. package/dashboard/dist/index.html +3 -3
  8. package/lib/cli/AiScanService.js +13 -11
  9. package/lib/cli/KnowledgeSyncService.js +343 -0
  10. package/lib/cli/SetupService.js +9 -27
  11. package/lib/core/ast/ProjectGraph.js +160 -0
  12. package/lib/core/gateway/GatewayActionRegistry.js +48 -58
  13. package/lib/domain/index.js +16 -11
  14. package/lib/domain/knowledge/KnowledgeEntry.js +351 -0
  15. package/lib/domain/knowledge/KnowledgeRepository.js +123 -0
  16. package/lib/domain/knowledge/Lifecycle.js +109 -0
  17. package/lib/domain/knowledge/index.js +27 -0
  18. package/lib/domain/knowledge/values/Constraints.js +125 -0
  19. package/lib/domain/knowledge/values/Content.js +86 -0
  20. package/lib/domain/knowledge/values/Quality.js +93 -0
  21. package/lib/domain/knowledge/values/Reasoning.js +69 -0
  22. package/lib/domain/knowledge/values/Relations.js +168 -0
  23. package/lib/domain/knowledge/values/Stats.js +87 -0
  24. package/lib/domain/knowledge/values/index.js +9 -0
  25. package/lib/external/ai/AiProvider.js +48 -0
  26. package/lib/external/ai/providers/GoogleGeminiProvider.js +12 -3
  27. package/lib/external/mcp/McpServer.js +7 -5
  28. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +3 -2
  29. package/lib/external/mcp/handlers/bootstrap.js +121 -12
  30. package/lib/external/mcp/handlers/browse.js +77 -73
  31. package/lib/external/mcp/handlers/candidate.js +29 -276
  32. package/lib/external/mcp/handlers/guard.js +2 -0
  33. package/lib/external/mcp/handlers/knowledge.js +205 -0
  34. package/lib/external/mcp/handlers/skill.js +4 -2
  35. package/lib/external/mcp/handlers/structure.js +25 -23
  36. package/lib/external/mcp/handlers/system.js +10 -12
  37. package/lib/external/mcp/tools.js +125 -138
  38. package/lib/http/HttpServer.js +4 -8
  39. package/lib/http/middleware/requestLogger.js +3 -3
  40. package/lib/http/routes/ai.js +17 -1
  41. package/lib/http/routes/extract.js +48 -4
  42. package/lib/http/routes/knowledge.js +246 -0
  43. package/lib/http/routes/search.js +12 -17
  44. package/lib/http/routes/skills.js +44 -1
  45. package/lib/infrastructure/cache/GraphCache.js +143 -0
  46. package/lib/infrastructure/database/migrations/015_create_token_usage.js +27 -0
  47. package/lib/infrastructure/database/migrations/016_unified_knowledge_entries.js +395 -0
  48. package/lib/infrastructure/external/XcodeAutomation.js +187 -103
  49. package/lib/infrastructure/realtime/RealtimeService.js +14 -2
  50. package/lib/injection/ServiceContainer.js +164 -63
  51. package/lib/repository/knowledge/KnowledgeRepository.impl.js +373 -0
  52. package/lib/repository/token/TokenUsageStore.js +162 -0
  53. package/lib/service/automation/DirectiveDetector.js +2 -3
  54. package/lib/service/automation/FileWatcher.js +67 -28
  55. package/lib/service/automation/XcodeIntegration.js +931 -156
  56. package/lib/service/automation/handlers/AlinkHandler.js +6 -4
  57. package/lib/service/automation/handlers/CreateHandler.js +53 -18
  58. package/lib/service/automation/handlers/GuardHandler.js +183 -20
  59. package/lib/service/automation/handlers/SearchHandler.js +35 -17
  60. package/lib/service/chat/AnalystAgent.js +25 -14
  61. package/lib/service/chat/CandidateGuardrail.js +1 -1
  62. package/lib/service/chat/ChatAgent.js +280 -48
  63. package/lib/service/chat/ContextWindow.js +92 -8
  64. package/lib/service/chat/HandoffProtocol.js +26 -1
  65. package/lib/service/chat/ProducerAgent.js +11 -9
  66. package/lib/service/chat/tools.js +298 -194
  67. package/lib/service/guard/GuardCheckEngine.js +114 -10
  68. package/lib/service/guard/GuardService.js +59 -48
  69. package/lib/service/knowledge/ConfidenceRouter.js +159 -0
  70. package/lib/service/knowledge/KnowledgeFileWriter.js +602 -0
  71. package/lib/service/knowledge/KnowledgeService.js +725 -0
  72. package/lib/service/search/SearchEngine.js +92 -19
  73. package/lib/service/skills/SignalCollector.js +15 -9
  74. package/lib/service/skills/SkillAdvisor.js +13 -11
  75. package/lib/service/snippet/SnippetFactory.js +5 -5
  76. package/lib/service/spm/SpmService.js +119 -18
  77. package/package.json +1 -1
  78. package/scripts/install-cursor-skill.js +0 -6
  79. package/scripts/migrate-md-to-knowledge.mjs +364 -0
  80. package/skills/autosnippet-analysis/SKILL.md +15 -7
  81. package/skills/autosnippet-candidates/SKILL.md +6 -6
  82. package/skills/autosnippet-coldstart/SKILL.md +7 -3
  83. package/skills/autosnippet-concepts/SKILL.md +7 -6
  84. package/skills/autosnippet-create/SKILL.md +13 -13
  85. package/skills/autosnippet-intent/SKILL.md +3 -2
  86. package/skills/autosnippet-lifecycle/SKILL.md +5 -5
  87. package/skills/autosnippet-recipes/SKILL.md +16 -4
  88. package/templates/constitution.yaml +1 -1
  89. package/templates/copilot-instructions.md +6 -6
  90. package/templates/recipes-setup/README.md +3 -3
  91. package/dashboard/dist/assets/index-CkIih2CC.css +0 -1
  92. package/dashboard/dist/assets/index-Duc8Qk-c.js +0 -197
  93. package/lib/cli/CandidateSyncService.js +0 -261
  94. package/lib/cli/SyncService.js +0 -356
  95. package/lib/domain/candidate/Candidate.js +0 -196
  96. package/lib/domain/candidate/CandidateRepository.js +0 -107
  97. package/lib/domain/candidate/Reasoning.js +0 -52
  98. package/lib/domain/recipe/Recipe.js +0 -421
  99. package/lib/domain/recipe/RecipeRepository.js +0 -54
  100. package/lib/domain/types/CandidateStatus.js +0 -52
  101. package/lib/http/routes/candidates.js +0 -559
  102. package/lib/http/routes/recipes.js +0 -397
  103. package/lib/repository/candidate/CandidateRepository.impl.js +0 -230
  104. package/lib/repository/recipe/RecipeRepository.impl.js +0 -498
  105. package/lib/service/candidate/CandidateAggregator.js +0 -52
  106. package/lib/service/candidate/CandidateFileWriter.js +0 -383
  107. package/lib/service/candidate/CandidateService.js +0 -973
  108. package/lib/service/recipe/RecipeFileWriter.js +0 -514
  109. package/lib/service/recipe/RecipeService.js +0 -786
  110. package/lib/service/recipe/RecipeStatsTracker.js +0 -148
@@ -15,6 +15,8 @@ const BM25_B = 0.75;
15
15
 
16
16
  /**
17
17
  * 分词: 中英文混合分词
18
+ * 英文: camelCase / PascalCase 拆分 + 小写化
19
+ * 中文: 单字 + 二元组(bigram)— 无需分词词典即可支持子串匹配
18
20
  */
19
21
  export function tokenize(text) {
20
22
  if (!text) return [];
@@ -23,7 +25,35 @@ export function tokenize(text) {
23
25
  // 拆全大写前缀:URLSession → URL Session, UITableView → UI Table View
24
26
  expanded = expanded.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
25
27
  const normalized = expanded.toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, ' ');
26
- const tokens = normalized.split(/[\s_-]+/).filter(t => t.length >= 2);
28
+ const rawTokens = normalized.split(/[\s_-]+/).filter(t => t.length >= 1);
29
+
30
+ const tokens = [];
31
+ // CJK 正则(中日韩统一表意文字 + 扩展区)
32
+ const cjkRe = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/;
33
+
34
+ for (const raw of rawTokens) {
35
+ if (cjkRe.test(raw)) {
36
+ // 中文片段:提取所有 CJK 连续子串,生成单字 + bigram 覆盖
37
+ const cjkChars = raw.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]+/g) || [];
38
+ for (const seg of cjkChars) {
39
+ // 单字
40
+ for (const ch of seg) tokens.push(ch);
41
+ // bigram
42
+ for (let i = 0; i < seg.length - 1; i++) tokens.push(seg[i] + seg[i + 1]);
43
+ // 完整片段(≥3 字时额外保留,提升精确匹配权重)
44
+ if (seg.length >= 3) tokens.push(seg);
45
+ }
46
+ // 非 CJK 部分(英文/数字)也保留
47
+ const nonCjk = raw.replace(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]+/g, ' ').trim();
48
+ if (nonCjk) {
49
+ for (const t of nonCjk.split(/\s+/)) {
50
+ if (t.length >= 2) tokens.push(t);
51
+ }
52
+ }
53
+ } else if (raw.length >= 2) {
54
+ tokens.push(raw);
55
+ }
56
+ }
27
57
  return [...new Set(tokens)];
28
58
  }
29
59
 
@@ -126,13 +156,33 @@ export class SearchEngine {
126
156
  this._cache.clear();
127
157
 
128
158
  try {
129
- // 索引 Recipes(统一模型,包含所有知识类型)
130
- const recipes = this.db.prepare(
131
- `SELECT id, title, description, language, category, knowledge_type, kind, content_json, status, tags_json, "trigger"
132
- FROM recipes WHERE status != 'deprecated'`
133
- ).all();
159
+ let entries = [];
160
+
161
+ // V3: 优先从 knowledge_entries 表索引
162
+ try {
163
+ entries = this.db.prepare(
164
+ `SELECT id, title, description, language, category, knowledge_type, kind,
165
+ content AS content_json, lifecycle, tags AS tags_json, trigger_key
166
+ FROM knowledge_entries WHERE lifecycle != 'deprecated'`
167
+ ).all();
168
+ // 标准化字段名以便后续处理
169
+ entries = entries.map(e => ({
170
+ ...e,
171
+ trigger: e.trigger_key,
172
+ status: e.lifecycle,
173
+ }));
174
+ } catch {
175
+ // V3 表不存在,回退到 V2 recipes 表
176
+ try {
177
+ entries = this.db.prepare(
178
+ `SELECT id, title, description, language, category, knowledge_type, kind,
179
+ content_json, status, tags_json, "trigger"
180
+ FROM recipes WHERE status != 'deprecated'`
181
+ ).all();
182
+ } catch { /* neither table exists */ }
183
+ }
134
184
 
135
- for (const r of recipes) {
185
+ for (const r of entries) {
136
186
  let contentText = '';
137
187
  try {
138
188
  const content = JSON.parse(r.content_json || '{}');
@@ -143,12 +193,12 @@ export class SearchEngine {
143
193
  try { tagText = JSON.parse(r.tags_json || '[]').join(' '); } catch { /* ignore */ }
144
194
  const text = [r.title, r.description, r.trigger, r.language, r.category, r.knowledge_type, tagText, contentText]
145
195
  .filter(Boolean).join(' ');
146
- this.scorer.addDocument(r.id, text, { type: 'recipe', title: r.title, trigger: r.trigger || '', status: r.status, knowledgeType: r.knowledge_type, kind: r.kind || 'pattern', language: r.language || '', category: r.category || '' });
196
+ this.scorer.addDocument(r.id, text, { type: 'knowledge', title: r.title, trigger: r.trigger || '', status: r.status, knowledgeType: r.knowledge_type, kind: r.kind || 'pattern', language: r.language || '', category: r.category || '' });
147
197
  }
148
198
 
149
199
  this._indexed = true;
150
200
  this.logger.info('Search index built', {
151
- recipes: recipes.length,
201
+ entries: entries.length,
152
202
  total: this.scorer.totalDocs,
153
203
  });
154
204
  } catch (err) {
@@ -229,14 +279,26 @@ export class SearchEngine {
229
279
  const escaped = query.replace(/[%_\\]/g, ch => `\\${ch}`);
230
280
  const pattern = `%${escaped}%`;
231
281
 
232
- if (type === 'all' || type === 'recipe' || type === 'rule' || type === 'solution') {
282
+ if (type === 'all' || type === 'recipe' || type === 'knowledge' || type === 'rule' || type === 'solution') {
233
283
  try {
234
- const rows = this.db.prepare(
235
- `SELECT id, title, description, language, category, knowledge_type, kind, status, content_json, dimensions_json, "trigger", 'recipe' as type
236
- FROM recipes
237
- WHERE status != 'deprecated' AND (title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR "trigger" LIKE ? ESCAPE '\\' OR content_json LIKE ? ESCAPE '\\')
238
- LIMIT ?`
239
- ).all(pattern, pattern, pattern, pattern, limit);
284
+ let rows = [];
285
+ // V3: 优先 knowledge_entries
286
+ try {
287
+ rows = this.db.prepare(
288
+ `SELECT id, title, description, language, category, knowledge_type, kind, lifecycle as status, content AS content_json, trigger_key as "trigger", headers, module_name, 'knowledge' as type
289
+ FROM knowledge_entries
290
+ WHERE lifecycle != 'deprecated' AND (title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR trigger_key LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')
291
+ LIMIT ?`
292
+ ).all(pattern, pattern, pattern, pattern, limit);
293
+ } catch {
294
+ // V3 表不存在,回退到 V2
295
+ rows = this.db.prepare(
296
+ `SELECT id, title, description, language, category, knowledge_type, kind, status, content_json, dimensions_json, "trigger", 'recipe' as type
297
+ FROM recipes
298
+ WHERE status != 'deprecated' AND (title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR "trigger" LIKE ? ESCAPE '\\' OR content_json LIKE ? ESCAPE '\\')
299
+ LIMIT ?`
300
+ ).all(pattern, pattern, pattern, pattern, limit);
301
+ }
240
302
  // 基础相关性排序:trigger 精确 > 标题匹配 > 描述匹配 > 内容匹配
241
303
  const lowerQ = query.toLowerCase();
242
304
  results.push(...rows.map(r => {
@@ -349,9 +411,17 @@ export class SearchEngine {
349
411
  try {
350
412
  const ids = items.map(it => it.id);
351
413
  const placeholders = ids.map(() => '?').join(',');
352
- const rows = this.db.prepare(
353
- `SELECT id, content_json, dimensions_json, description, "trigger" FROM recipes WHERE id IN (${placeholders})`
354
- ).all(...ids);
414
+ let rows = [];
415
+ // V3: 优先 knowledge_entries
416
+ try {
417
+ rows = this.db.prepare(
418
+ `SELECT id, content AS content_json, description, trigger_key as "trigger", headers, module_name FROM knowledge_entries WHERE id IN (${placeholders})`
419
+ ).all(...ids);
420
+ } catch {
421
+ rows = this.db.prepare(
422
+ `SELECT id, content_json, dimensions_json, description, "trigger" FROM recipes WHERE id IN (${placeholders})`
423
+ ).all(...ids);
424
+ }
355
425
  const rowMap = new Map(rows.map(r => [r.id, r]));
356
426
  for (const item of items) {
357
427
  const row = rowMap.get(item.id);
@@ -360,6 +430,9 @@ export class SearchEngine {
360
430
  item.dimensions_json = row.dimensions_json || null;
361
431
  item.description = item.description || row.description || '';
362
432
  item.trigger = item.trigger || row.trigger || '';
433
+ // V3: headers & module_name 是独立列
434
+ if (row.headers) item.headers = row.headers;
435
+ if (row.module_name) item.module_name = row.module_name;
363
436
  }
364
437
  }
365
438
  } catch { /* DB may not be available */ }
@@ -128,8 +128,9 @@ export class SignalCollector {
128
128
  `[SignalCollector] started — mode=${this.#mode}, initialInterval=${this.#intervalMs}ms, AI-driven`
129
129
  );
130
130
 
131
- // 首次延迟 15 秒后执行(等启动流程稳定)
132
- this.#timer = setTimeout(() => this.#tick(), 15_000);
131
+ // 首次按正常间隔执行(不立即触发,避免启动时消耗 AI token)
132
+ // 如果有事件推送(EventAggregator batch),会提前触发
133
+ this.#timer = setTimeout(() => this.#tick(), this.#intervalMs);
133
134
  }
134
135
 
135
136
  stop() {
@@ -335,11 +336,16 @@ export class SignalCollector {
335
336
  #collectRecipeSignals() {
336
337
  try {
337
338
  if (!this.#db) return [];
339
+ // V3: knowledge_entries 统一表,统计字段在 stats/quality JSON 中
338
340
  const rows = this.#db.prepare(
339
341
  `SELECT id, title, knowledge_type, category, language,
340
- adoption_count, application_count, success_count,
341
- quality_overall, updated_at
342
- FROM recipes ORDER BY updated_at DESC LIMIT 30`
342
+ json_extract(stats, '$.adoptions') as adoption_count,
343
+ json_extract(stats, '$.applications') as application_count,
344
+ json_extract(quality, '$.overall') as quality_overall,
345
+ updated_at
346
+ FROM knowledge_entries
347
+ WHERE lifecycle = 'active'
348
+ ORDER BY updated_at DESC LIMIT 30`
343
349
  ).all();
344
350
  return rows;
345
351
  } catch { return []; }
@@ -348,11 +354,11 @@ export class SignalCollector {
348
354
  #collectCandidateSignals() {
349
355
  try {
350
356
  if (!this.#db) return [];
357
+ // V3: candidates 已合并到 knowledge_entries,lifecycle='pending' 即为候选
351
358
  const rows = this.#db.prepare(
352
- `SELECT id, source, status, language, category,
353
- json_extract(metadata_json, '$.title') as title,
354
- created_at
355
- FROM candidates WHERE status = 'pending'
359
+ `SELECT id, source, lifecycle as status, language, category,
360
+ title, created_at
361
+ FROM knowledge_entries WHERE lifecycle = 'pending'
356
362
  ORDER BY created_at DESC LIMIT 30`
357
363
  ).all();
358
364
  return rows;
@@ -202,8 +202,8 @@ export class SkillAdvisor {
202
202
  // 按 category 分布
203
203
  const categories = this.#db.prepare(`
204
204
  SELECT category, COUNT(*) as cnt
205
- FROM recipes
206
- WHERE category IS NOT NULL AND category != ''
205
+ FROM knowledge_entries
206
+ WHERE lifecycle = 'active' AND category IS NOT NULL AND category != ''
207
207
  GROUP BY category
208
208
  ORDER BY cnt DESC
209
209
  `).all();
@@ -211,8 +211,8 @@ export class SkillAdvisor {
211
211
  // 按 language 分布
212
212
  const languages = this.#db.prepare(`
213
213
  SELECT language, COUNT(*) as cnt
214
- FROM recipes
215
- WHERE language IS NOT NULL AND language != ''
214
+ FROM knowledge_entries
215
+ WHERE lifecycle = 'active' AND language IS NOT NULL AND language != ''
216
216
  GROUP BY language
217
217
  ORDER BY cnt DESC
218
218
  `).all();
@@ -231,14 +231,15 @@ export class SkillAdvisor {
231
231
  });
232
232
  }
233
233
 
234
- // 高使用量 Recipe 统计(adoption_count + application_count >= 5
234
+ // 高使用量 Recipe 统计(V3: stats JSON 中的 adoptions + applications
235
235
  let hotRecipes = [];
236
236
  try {
237
237
  hotRecipes = this.#db.prepare(`
238
238
  SELECT title, category,
239
- (adoption_count + application_count) as total_usage
240
- FROM recipes
241
- WHERE (adoption_count + application_count) >= 5
239
+ (COALESCE(json_extract(stats, '$.adoptions'), 0) + COALESCE(json_extract(stats, '$.applications'), 0)) as total_usage
240
+ FROM knowledge_entries
241
+ WHERE lifecycle = 'active'
242
+ AND (COALESCE(json_extract(stats, '$.adoptions'), 0) + COALESCE(json_extract(stats, '$.applications'), 0)) >= 5
242
243
  ORDER BY total_usage DESC
243
244
  LIMIT 10
244
245
  `).all();
@@ -262,12 +263,13 @@ export class SkillAdvisor {
262
263
  if (!this.#db) return { summary: 'DB 不可用', suggestions };
263
264
 
264
265
  try {
266
+ // V3: candidates 已合并到 knowledge_entries,ifecycle='pending' 即为候选
265
267
  const stats = this.#db.prepare(`
266
268
  SELECT
267
269
  COUNT(*) as total,
268
- SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) as pending,
269
- SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as rejected
270
- FROM candidates
270
+ SUM(CASE WHEN lifecycle='pending' THEN 1 ELSE 0 END) as pending,
271
+ SUM(CASE WHEN lifecycle='deprecated' THEN 1 ELSE 0 END) as rejected
272
+ FROM knowledge_entries
271
273
  `).get();
272
274
 
273
275
  // 大量被拒绝 → 提示候选质量 Skill
@@ -45,16 +45,16 @@ const SNIPPET_TEMPLATE = `<?xml version="1.0" encoding="UTF-8"?>
45
45
 
46
46
  export class SnippetFactory {
47
47
  /**
48
- * @param {object} [recipeRepository] — RecipeRepositoryImpl (可选,用于列表查询)
48
+ * @param {object} [knowledgeRepository] — KnowledgeRepositoryImpl(可选,用于列表查询)
49
49
  */
50
- constructor(recipeRepository) {
51
- this._recipeRepo = recipeRepository || null;
50
+ constructor(knowledgeRepository) {
51
+ this._recipeRepo = knowledgeRepository || null;
52
52
  }
53
53
 
54
54
  /**
55
- * 运行时注入 recipeRepository(用于延迟绑定场景)
55
+ * 运行时注入 knowledgeRepository(用于延迟绑定场景)
56
56
  */
57
- setRecipeRepository(repo) {
57
+ setKnowledgeRepository(repo) {
58
58
  this._recipeRepo = repo;
59
59
  }
60
60
 
@@ -7,8 +7,9 @@ import Logger from '../../infrastructure/logging/Logger.js';
7
7
  import { PackageSwiftParser } from './PackageSwiftParser.js';
8
8
  import { DependencyGraph } from './DependencyGraph.js';
9
9
  import { PolicyEngine } from './PolicyEngine.js';
10
+ import { GraphCache } from '../../infrastructure/cache/GraphCache.js';
10
11
  import { dirname, relative, sep, resolve as pathResolve } from 'node:path';
11
- import { readFileSync, writeFileSync } from 'node:fs';
12
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
12
13
 
13
14
  export class SpmService {
14
15
  #parser;
@@ -35,6 +36,12 @@ export class SpmService {
35
36
  */
36
37
  #packageDepGraph;
37
38
 
39
+ /** @type {GraphCache} 磁盘缓存层 */
40
+ #graphCache;
41
+
42
+ /** @type {Map<string, object[]>} target 文件列表缓存 */
43
+ #targetFilesCache;
44
+
38
45
  constructor(projectRoot, options = {}) {
39
46
  this.#projectRoot = projectRoot;
40
47
  this.#aiFactory = options.aiFactory || null;
@@ -49,60 +56,92 @@ export class SpmService {
49
56
  this.#logger = Logger.getInstance();
50
57
  this.#targetPackageMap = new Map();
51
58
  this.#packageDepGraph = new Map();
59
+ this.#graphCache = new GraphCache(projectRoot);
60
+ this.#targetFilesCache = new Map();
52
61
  }
53
62
 
54
63
  /**
55
64
  * 加载并解析 Package.swift,构建依赖图
56
65
  * 支持多 Package 项目(如 BiliDemo 有多个子 Package)
66
+ * 优先从磁盘缓存加载(Package.swift contentHash 匹配即命中)
57
67
  */
58
68
  async load() {
59
69
  this.#targetPackageMap.clear();
60
70
  this.#packageDepGraph.clear();
71
+ this.#targetFilesCache.clear();
61
72
 
62
- // 优先尝试根目录的 Package.swift
73
+ // ── 收集所有 Package.swift 路径 + 联合 hash ──
63
74
  const packagePath = this.#parser.findPackageSwift(this.#projectRoot);
75
+ const allPaths = packagePath ? [packagePath] : this.#parser.findAllPackageSwifts(this.#projectRoot);
76
+ if (allPaths.length === 0) {
77
+ this.#logger.warn('[SpmService] Package.swift 未找到');
78
+ return null;
79
+ }
80
+
81
+ const combinedHash = allPaths
82
+ .map(p => this.#graphCache.computeFileHash(p))
83
+ .join(':');
84
+
85
+ // ── 尝试命中缓存 ──
86
+ const cached = this.#graphCache.load('spm-graph');
87
+ if (cached && cached.contentHash === combinedHash) {
88
+ this.#restoreFromCache(cached.data);
89
+ this.#logger.info(`[SpmService] ⚡ 缓存命中 (${this.#graph.getNodes().length} targets, hash=${combinedHash.substring(0, 8)})`);
90
+ return cached.data.parsedResult;
91
+ }
92
+
93
+ // ── 缓存未命中,走完整解析 ──
94
+ const startTime = Date.now();
95
+ let parsedResult;
96
+
64
97
  if (packagePath) {
98
+ // 单包模式
65
99
  const parsed = this.#parser.parse(packagePath);
66
100
  this.#graph.buildFromParsed(parsed);
67
- // 构建 target→package 映射
68
101
  for (const t of parsed.targets || []) {
69
102
  this.#targetPackageMap.set(t.name, { packageName: parsed.name, packagePath });
70
103
  }
71
- // 构建包级依赖图(解析 .package(path: "...") 引用)
72
104
  this.#buildPackageDepGraph([{ path: packagePath, parsed }]);
73
105
  this.#logger.info(`[SpmService] 加载完成: ${parsed.name} (${parsed.targets.length} targets)`);
74
- return parsed;
106
+ parsedResult = parsed;
107
+ } else {
108
+ // 多包模式
109
+ parsedResult = this.#loadMultiPackage(allPaths);
75
110
  }
76
111
 
77
- // 没有根 Package.swift,扫描子目录中的所有 Package.swift
78
- const allPaths = this.#parser.findAllPackageSwifts(this.#projectRoot);
79
- if (allPaths.length === 0) {
80
- this.#logger.warn('[SpmService] Package.swift 未找到');
81
- return null;
112
+ // ── 写入缓存 ──
113
+ if (parsedResult) {
114
+ this.#saveToCache(combinedHash, parsedResult);
115
+ this.#logger.info(`[SpmService] 缓存已写入 (${Date.now() - startTime}ms 解析)`);
82
116
  }
83
117
 
118
+ return parsedResult;
119
+ }
120
+
121
+ /**
122
+ * 多包加载(从 load() 拆出)
123
+ * @param {string[]} allPaths Package.swift 路径数组
124
+ * @returns {object|null}
125
+ */
126
+ #loadMultiPackage(allPaths) {
84
127
  this.#logger.info(`[SpmService] 发现 ${allPaths.length} 个 Package.swift,逐一解析...`);
85
128
  let mergedTargets = [];
86
129
  let lastName = 'multi-package';
87
130
  const allParsed = [];
88
131
 
89
- // 先清空图,然后逐个添加节点和边(不用 buildFromParsed 避免重复 clear)
90
132
  this.#graph.clear();
91
133
  for (const pkgPath of allPaths) {
92
134
  try {
93
135
  const parsed = this.#parser.parse(pkgPath);
94
136
  if (parsed) {
95
137
  allParsed.push({ path: pkgPath, parsed });
96
- // 逐个添加 target 到图中
97
138
  for (const t of parsed.targets || []) {
98
139
  this.#graph.addNode(t.name);
99
140
  for (const dep of t.dependencies || []) {
100
141
  this.#graph.addEdge(t.name, dep);
101
142
  }
102
- // 构建 target→package 映射
103
143
  this.#targetPackageMap.set(t.name, { packageName: parsed.name, packagePath: pkgPath });
104
144
  }
105
- // 合并 target 列表(带 packageName 标记)
106
145
  for (const t of parsed.targets) {
107
146
  mergedTargets.push({
108
147
  ...t,
@@ -117,7 +156,6 @@ export class SpmService {
117
156
  }
118
157
  }
119
158
 
120
- // 构建包级依赖图
121
159
  this.#buildPackageDepGraph(allParsed);
122
160
 
123
161
  this.#logger.info(`[SpmService] 多包加载完成: ${mergedTargets.length} targets from ${allPaths.length} packages`);
@@ -128,6 +166,51 @@ export class SpmService {
128
166
  };
129
167
  }
130
168
 
169
+ /**
170
+ * 将当前内存状态序列化到缓存
171
+ */
172
+ #saveToCache(contentHash, parsedResult) {
173
+ const graphJSON = this.#graph.toJSON();
174
+ const targetPackageEntries = [...this.#targetPackageMap.entries()];
175
+ const packageDepEntries = [...this.#packageDepGraph.entries()].map(
176
+ ([k, v]) => [k, [...v]]
177
+ );
178
+
179
+ this.#graphCache.save('spm-graph', {
180
+ parsedResult,
181
+ graphNodes: graphJSON.nodes,
182
+ graphEdges: graphJSON.edges,
183
+ targetPackageMap: targetPackageEntries,
184
+ packageDepGraph: packageDepEntries,
185
+ }, { contentHash });
186
+ }
187
+
188
+ /**
189
+ * 从缓存数据恢复内存状态
190
+ */
191
+ #restoreFromCache(data) {
192
+ // 恢复 DependencyGraph
193
+ this.#graph.clear();
194
+ for (const node of data.graphNodes || []) {
195
+ this.#graph.addNode(node);
196
+ }
197
+ for (const edge of data.graphEdges || []) {
198
+ this.#graph.addEdge(edge.from, edge.to);
199
+ }
200
+
201
+ // 恢复 targetPackageMap
202
+ this.#targetPackageMap.clear();
203
+ for (const [name, info] of data.targetPackageMap || []) {
204
+ this.#targetPackageMap.set(name, info);
205
+ }
206
+
207
+ // 恢复 packageDepGraph
208
+ this.#packageDepGraph.clear();
209
+ for (const [pkgPath, deps] of data.packageDepGraph || []) {
210
+ this.#packageDepGraph.set(pkgPath, new Set(deps));
211
+ }
212
+ }
213
+
131
214
  // ─────────────── 包级依赖图构建 ───────────────
132
215
 
133
216
  /**
@@ -585,14 +668,29 @@ export class SpmService {
585
668
  * 获取 Target 源文件列表(路由: POST /spm/target-files)
586
669
  * 支持多 Package 项目:先在 target 所属 package 目录查找
587
670
  * 支持非 SPM 项目:虚拟 directory target 直接扫描对应目录
671
+ * 使用内存缓存避免重复 fs walk
588
672
  */
589
673
  async getTargetFiles(target) {
590
- const { existsSync, readdirSync, statSync } = await import('fs');
591
- const { join, dirname, relative, extname } = await import('path');
592
-
593
674
  const targetName = typeof target === 'string' ? target : target?.name;
594
675
  if (!targetName) return [];
595
676
 
677
+ // 内存缓存命中
678
+ if (this.#targetFilesCache.has(targetName)) {
679
+ return this.#targetFilesCache.get(targetName);
680
+ }
681
+
682
+ const result = await this.#walkTargetFiles(target, targetName);
683
+ this.#targetFilesCache.set(targetName, result);
684
+ return result;
685
+ }
686
+
687
+ /**
688
+ * 实际的文件遍历逻辑(拆出以供缓存用)
689
+ */
690
+ async #walkTargetFiles(target, targetName) {
691
+ const { existsSync, readdirSync, statSync } = await import('fs');
692
+ const { join, dirname, relative, extname } = await import('path');
693
+
596
694
  // 判断是否为虚拟目录 target(非 SPM fallback)
597
695
  const isDirectoryTarget = typeof target === 'object' && target?.type === 'directory';
598
696
 
@@ -1103,8 +1201,11 @@ export class SpmService {
1103
1201
 
1104
1202
  /**
1105
1203
  * 刷新依赖映射(路由: POST /commands/spm-map)
1204
+ * 会主动使缓存失效并重新解析
1106
1205
  */
1107
1206
  async updateDependencyMap(options = {}) {
1207
+ this.#graphCache.invalidate('spm-graph');
1208
+ this.#targetFilesCache.clear();
1108
1209
  this.#graph.clear();
1109
1210
  const parsed = await this.load();
1110
1211
  if (!parsed) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autosnippet",
3
- "version": "2.8.3",
3
+ "version": "2.10.0",
4
4
  "description": "AutoSnippet - 连接开发者、AI 与项目知识库的工具",
5
5
  "type": "module",
6
6
  "main": "lib/bootstrap.js",
@@ -22,7 +22,6 @@ const require = createRequire(import.meta.url);
22
22
  import fs from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import * as defaults from '../lib/infrastructure/config/Defaults.js';
25
- import { RecipeStatsTracker } from '../lib/service/recipe/RecipeStatsTracker.js';
26
25
 
27
26
  const autoSnippetRoot = path.join(__dirname, '..');
28
27
  const skillsSource = path.join(autoSnippetRoot, 'skills');
@@ -156,11 +155,6 @@ function buildProjectRecipesContext(projectRoot) {
156
155
  if (!fs.existsSync(recipesDir)) return null;
157
156
  const mdFiles = collectMdFiles(recipesDir, recipesDir).sort();
158
157
  if (mdFiles.length === 0) return null;
159
- let stats = { byFile: {} };
160
- try {
161
- const tracker = new RecipeStatsTracker(projectRoot);
162
- stats = tracker.getStats();
163
- } catch (_) {}
164
158
 
165
159
  const lines = [
166
160
  '# Project Recipes Index\n\n',