autosnippet 2.8.3 → 2.9.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 (31) hide show
  1. package/README.md +1 -1
  2. package/dashboard/dist/assets/{icons-B_Xg4B-s.js → icons-CH-H9x0E.js} +1 -1
  3. package/dashboard/dist/assets/index-CqJRvYRL.js +197 -0
  4. package/dashboard/dist/assets/index-DICm9PNa.css +1 -0
  5. package/dashboard/dist/index.html +3 -3
  6. package/lib/cli/SetupService.js +1 -1
  7. package/lib/core/ast/ProjectGraph.js +160 -0
  8. package/lib/external/ai/providers/GoogleGeminiProvider.js +12 -3
  9. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +1 -0
  10. package/lib/external/mcp/handlers/bootstrap.js +5 -1
  11. package/lib/external/mcp/handlers/skill.js +4 -2
  12. package/lib/http/middleware/requestLogger.js +3 -3
  13. package/lib/http/routes/ai.js +17 -1
  14. package/lib/http/routes/skills.js +44 -1
  15. package/lib/infrastructure/cache/GraphCache.js +143 -0
  16. package/lib/infrastructure/database/migrations/015_create_token_usage.js +27 -0
  17. package/lib/infrastructure/realtime/RealtimeService.js +14 -2
  18. package/lib/injection/ServiceContainer.js +114 -2
  19. package/lib/repository/token/TokenUsageStore.js +162 -0
  20. package/lib/service/candidate/CandidateService.js +28 -0
  21. package/lib/service/chat/AnalystAgent.js +25 -14
  22. package/lib/service/chat/ChatAgent.js +237 -6
  23. package/lib/service/chat/ContextWindow.js +87 -3
  24. package/lib/service/chat/HandoffProtocol.js +26 -1
  25. package/lib/service/chat/ProducerAgent.js +4 -2
  26. package/lib/service/chat/tools.js +168 -71
  27. package/lib/service/skills/SignalCollector.js +3 -2
  28. package/lib/service/spm/SpmService.js +119 -18
  29. package/package.json +1 -1
  30. package/dashboard/dist/assets/index-CkIih2CC.css +0 -1
  31. package/dashboard/dist/assets/index-Duc8Qk-c.js +0 -197
@@ -123,30 +123,133 @@ function _scoreSearchLine(line) {
123
123
  return 0;
124
124
  }
125
125
 
126
+ /**
127
+ * 收集项目文件列表 — 抽取为公用函数,供单次和批量搜索复用。
128
+ * 优先使用内存缓存(bootstrap 场景),否则从磁盘递归读取。
129
+ */
130
+ async function _getProjectFiles(params, ctx) {
131
+ const { fileFilter } = params;
132
+ const projectRoot = ctx.projectRoot || process.cwd();
133
+
134
+ let extFilter = null;
135
+ if (fileFilter) {
136
+ const exts = fileFilter.split(',').map(e => e.trim().replace(/^\./, ''));
137
+ extFilter = new RegExp(`\\.(${exts.join('|')})$`, 'i');
138
+ }
139
+
140
+ const fileCache = ctx.fileCache || null;
141
+ let files;
142
+ let skippedThirdParty = 0;
143
+
144
+ if (fileCache && Array.isArray(fileCache)) {
145
+ files = fileCache.filter(f => {
146
+ const p = f.relativePath || f.path || '';
147
+ if (THIRD_PARTY_RE.test(p)) { skippedThirdParty++; return false; }
148
+ if (extFilter && !extFilter.test(p)) return false;
149
+ if (!SOURCE_EXT_RE.test(p)) return false;
150
+ return true;
151
+ });
152
+ } else {
153
+ files = [];
154
+ const MAX_FILE_SIZE = 512 * 1024;
155
+ const walk = (dir, relBase = '') => {
156
+ try {
157
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
158
+ for (const entry of entries) {
159
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
160
+ const fullPath = path.join(dir, entry.name);
161
+ const isDir = entry.isDirectory() || (entry.isSymbolicLink() && (() => { try { return fs.statSync(fullPath).isDirectory(); } catch { return false; } })());
162
+ const isFile = entry.isFile() || (entry.isSymbolicLink() && (() => { try { return fs.statSync(fullPath).isFile(); } catch { return false; } })());
163
+ if (isDir) {
164
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'build') continue;
165
+ if (THIRD_PARTY_RE.test(relPath + '/')) { skippedThirdParty++; continue; }
166
+ walk(fullPath, relPath);
167
+ } else if (isFile) {
168
+ if (THIRD_PARTY_RE.test(relPath)) { skippedThirdParty++; continue; }
169
+ if (!SOURCE_EXT_RE.test(entry.name)) continue;
170
+ if (extFilter && !extFilter.test(entry.name)) continue;
171
+ try {
172
+ const stat = fs.statSync(fullPath);
173
+ if (stat.size > MAX_FILE_SIZE) continue;
174
+ const content = fs.readFileSync(fullPath, 'utf-8');
175
+ files.push({ relativePath: relPath, content, name: entry.name });
176
+ } catch { /* skip unreadable files */ }
177
+ }
178
+ }
179
+ } catch { /* skip inaccessible dirs */ }
180
+ };
181
+ walk(projectRoot);
182
+ }
183
+
184
+ return { files, skippedThirdParty };
185
+ }
186
+
126
187
  const searchProjectCode = {
127
188
  name: 'search_project_code',
128
189
  description: '在用户项目源码中搜索指定模式。返回匹配的代码片段及上下文。' +
129
190
  '自动过滤三方库代码(Pods/Carthage/node_modules),优先返回实际使用行而非声明行。' +
130
- '适用场景:验证代码模式存在性、查找更多项目示例、理解项目中某个 API 的用法。',
191
+ '适用场景:验证代码模式存在性、查找更多项目示例、理解项目中某个 API 的用法。' +
192
+ '批量搜索:传入 patterns 数组可一次搜索多个关键词(每个关键词独立返回结果),减少工具调用次数。',
131
193
  parameters: {
132
194
  type: 'object',
133
195
  properties: {
134
- pattern: { type: 'string', description: '搜索词或正则表达式' },
196
+ pattern: { type: 'string', description: '搜索词或正则表达式(单个搜索时使用)' },
197
+ patterns: { type: 'array', items: { type: 'string' }, description: '批量搜索:多个搜索词数组,如 ["methodA", "methodB", "classC"]。与 pattern 互斥,优先使用 patterns。' },
135
198
  isRegex: { type: 'boolean', description: '是否为正则表达式,默认 false' },
136
199
  fileFilter: { type: 'string', description: '文件扩展名过滤,如 ".m,.swift"' },
137
- contextLines: { type: 'number', description: '匹配行前后的上下文行数,默认 5' },
138
- maxResults: { type: 'number', description: '最大返回结果数,默认 8' },
200
+ contextLines: { type: 'number', description: '匹配行前后的上下文行数,默认 3' },
201
+ maxResults: { type: 'number', description: '每个 pattern 的最大返回结果数,默认 5' },
139
202
  },
140
- required: ['pattern'],
203
+ required: [],
141
204
  },
142
205
  handler: async (params, ctx) => {
206
+ // ── 去重缓存初始化 ──
207
+ const state = ctx._sharedState || ctx;
208
+ if (!state._searchCache) state._searchCache = new Map();
209
+
210
+ // ── 批量模式:patterns 数组 ──
211
+ if (Array.isArray(params.patterns) && params.patterns.length > 0) {
212
+ const batchPatterns = params.patterns.slice(0, 10); // 最多 10 个
213
+ const batchResults = {};
214
+ let dedupCount = 0;
215
+ for (const p of batchPatterns) {
216
+ // 去重:已搜索过的 pattern 直接返回缓存
217
+ const cacheKey = `${p}|${params.isRegex || false}|${params.fileFilter || ''}`;
218
+ if (state._searchCache.has(cacheKey)) {
219
+ batchResults[p] = { ...state._searchCache.get(cacheKey), _cached: true };
220
+ dedupCount++;
221
+ continue;
222
+ }
223
+ const sub = await searchProjectCode.handler(
224
+ { ...params, pattern: p, patterns: undefined },
225
+ ctx,
226
+ );
227
+ const entry = { matches: sub.matches || [], total: sub.total || 0 };
228
+ state._searchCache.set(cacheKey, entry);
229
+ batchResults[p] = entry;
230
+ }
231
+ return {
232
+ batchResults,
233
+ patternsSearched: batchPatterns.length,
234
+ searchedFiles: (await _getProjectFiles(params, ctx)).files.length,
235
+ ...(dedupCount > 0 ? { _deduped: dedupCount, hint: `${dedupCount} 个 pattern 命中缓存,请避免重复搜索相同关键词。` } : {}),
236
+ };
237
+ }
238
+
143
239
  // 兼容 AI 传 "query" / "search" / "keyword" 替代 "pattern"
144
240
  const pattern = params.pattern || params.query || params.search || params.keyword || params.search_query;
145
- const { isRegex = false, fileFilter, contextLines = 5, maxResults = 8 } = params;
241
+ const { isRegex = false, fileFilter, contextLines = 3, maxResults = 5 } = params;
146
242
  const projectRoot = ctx.projectRoot || process.cwd();
147
243
 
148
244
  if (!pattern || typeof pattern !== 'string') {
149
- return { error: '参数错误: 请提供 pattern(搜索关键词或正则表达式)', matches: [], total: 0 };
245
+ return { error: '参数错误: 请提供 pattern(搜索关键词或正则表达式)或 patterns 数组', matches: [], total: 0 };
246
+ }
247
+
248
+ // ── 单 pattern 去重检查 ──
249
+ const cacheKey = `${pattern}|${params.isRegex || false}|${params.fileFilter || ''}`;
250
+ if (state._searchCache.has(cacheKey)) {
251
+ const cached = state._searchCache.get(cacheKey);
252
+ return { ...cached, _cached: true, hint: `⚠ 已搜索过 "${pattern}",返回缓存结果。请搜索不同的关键词以获取新信息。` };
150
253
  }
151
254
 
152
255
  // 构建搜索正则
@@ -157,61 +260,7 @@ const searchProjectCode = {
157
260
  return { error: `Invalid pattern: ${err.message}`, matches: [], total: 0 };
158
261
  }
159
262
 
160
- // 文件扩展名过滤
161
- let extFilter = null;
162
- if (fileFilter) {
163
- const exts = fileFilter.split(',').map(e => e.trim().replace(/^\./, ''));
164
- extFilter = new RegExp(`\\.(${exts.join('|')})$`, 'i');
165
- }
166
-
167
- // 收集文件列表 — 优先使用内存缓存(bootstrap 场景),否则从磁盘递归读取
168
- const fileCache = ctx.fileCache || null;
169
- let files;
170
- let skippedThirdParty = 0;
171
-
172
- if (fileCache && Array.isArray(fileCache)) {
173
- // Bootstrap 场景: allFiles 已在内存
174
- files = fileCache.filter(f => {
175
- const p = f.relativePath || f.path || '';
176
- if (THIRD_PARTY_RE.test(p)) { skippedThirdParty++; return false; }
177
- if (extFilter && !extFilter.test(p)) return false;
178
- if (!SOURCE_EXT_RE.test(p)) return false;
179
- return true;
180
- });
181
- } else {
182
- // Dashboard / SignalCollector 场景: 从磁盘递归读取
183
- files = [];
184
- const MAX_FILE_SIZE = 512 * 1024; // 512KB — 跳过超大文件
185
- const walk = (dir, relBase = '') => {
186
- try {
187
- const entries = fs.readdirSync(dir, { withFileTypes: true });
188
- for (const entry of entries) {
189
- const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
190
- const fullPath = path.join(dir, entry.name);
191
- // 支持 symlink: 解析为目录或文件
192
- const isDir = entry.isDirectory() || (entry.isSymbolicLink() && (() => { try { return fs.statSync(fullPath).isDirectory(); } catch { return false; } })());
193
- const isFile = entry.isFile() || (entry.isSymbolicLink() && (() => { try { return fs.statSync(fullPath).isFile(); } catch { return false; } })());
194
- if (isDir) {
195
- // 跳过隐藏目录和常见无关目录
196
- if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'build') continue;
197
- if (THIRD_PARTY_RE.test(relPath + '/')) { skippedThirdParty++; continue; }
198
- walk(fullPath, relPath);
199
- } else if (isFile) {
200
- if (THIRD_PARTY_RE.test(relPath)) { skippedThirdParty++; continue; }
201
- if (!SOURCE_EXT_RE.test(entry.name)) continue;
202
- if (extFilter && !extFilter.test(entry.name)) continue;
203
- try {
204
- const stat = fs.statSync(fullPath);
205
- if (stat.size > MAX_FILE_SIZE) continue; // 跳过超大文件
206
- const content = fs.readFileSync(fullPath, 'utf-8');
207
- files.push({ relativePath: relPath, content, name: entry.name });
208
- } catch { /* skip unreadable files */ }
209
- }
210
- }
211
- } catch { /* skip inaccessible dirs */ }
212
- };
213
- walk(projectRoot);
214
- }
263
+ const { files, skippedThirdParty } = await _getProjectFiles(params, ctx);
215
264
 
216
265
  // 搜索匹配
217
266
  const matches = [];
@@ -253,21 +302,25 @@ const searchProjectCode = {
253
302
  // 按 score 降序排列(实际使用行优先)
254
303
  matches.sort((a, b) => b.score - a.score);
255
304
 
256
- return {
305
+ const result = {
257
306
  matches,
258
307
  total,
259
308
  searchedFiles: files.length,
260
309
  skippedThirdParty,
261
310
  ...((() => {
262
311
  // P2.2: 搜索超限提示 — 引导使用 AST 工具
263
- const state = ctx._sharedState || ctx;
264
312
  state._searchCallCount = (state._searchCallCount || 0) + 1;
265
- if (state._searchCallCount > 8 && ctx.source === 'system') {
313
+ if (state._searchCallCount > 12 && ctx.source === 'system') {
266
314
  return { hint: `💡 你已搜索 ${state._searchCallCount} 次。考虑使用 get_class_info / get_class_hierarchy / get_project_overview 获取结构化信息,效率更高。` };
267
315
  }
268
316
  return {};
269
317
  })()),
270
318
  };
319
+
320
+ // 缓存搜索结果
321
+ state._searchCache.set(cacheKey, { matches: result.matches, total: result.total });
322
+
323
+ return result;
271
324
  },
272
325
  };
273
326
 
@@ -277,18 +330,51 @@ const searchProjectCode = {
277
330
  const readProjectFile = {
278
331
  name: 'read_project_file',
279
332
  description: '读取项目中指定文件的内容(部分或全部)。' +
280
- '通常在 search_project_code 找到匹配后使用,获取更完整的上下文。',
333
+ '通常在 search_project_code 找到匹配后使用,获取更完整的上下文。' +
334
+ '批量读取:传入 filePaths 数组可一次读取多个文件,减少工具调用次数。',
281
335
  parameters: {
282
336
  type: 'object',
283
337
  properties: {
284
- filePath: { type: 'string', description: '相对于项目根目录的文件路径' },
338
+ filePath: { type: 'string', description: '相对于项目根目录的文件路径(单个文件时使用)' },
339
+ filePaths: { type: 'array', items: { type: 'string' }, description: '批量读取:多个文件路径数组。与 filePath 互斥,优先使用 filePaths。' },
285
340
  startLine: { type: 'number', description: '起始行号(1-based),默认 1' },
286
341
  endLine: { type: 'number', description: '结束行号(1-based),默认文件末尾' },
287
- maxLines: { type: 'number', description: '最大返回行数,默认 200' },
342
+ maxLines: { type: 'number', description: '最大返回行数,默认 200(批量模式下每个文件最多 100 行)' },
288
343
  },
289
- required: ['filePath'],
344
+ required: [],
290
345
  },
291
346
  handler: async (params, ctx) => {
347
+ // ── 去重缓存初始化 ──
348
+ const state = ctx._sharedState || ctx;
349
+ if (!state._readCache) state._readCache = new Map();
350
+
351
+ // ── 批量模式:filePaths 数组 ──
352
+ if (Array.isArray(params.filePaths) && params.filePaths.length > 0) {
353
+ const batchPaths = params.filePaths.slice(0, 8); // 最多 8 个文件
354
+ const batchResults = {};
355
+ let dedupCount = 0;
356
+ for (const fp of batchPaths) {
357
+ const cacheKey = `${fp}|${params.startLine || 1}|${params.endLine || ''}|${params.maxLines || 100}`;
358
+ if (state._readCache.has(cacheKey)) {
359
+ batchResults[fp] = { ...state._readCache.get(cacheKey), _cached: true };
360
+ dedupCount++;
361
+ continue;
362
+ }
363
+ const sub = await readProjectFile.handler(
364
+ { ...params, filePath: fp, filePaths: undefined, maxLines: Math.min(params.maxLines || 100, 100) },
365
+ ctx,
366
+ );
367
+ const entry = sub.error ? { error: sub.error } : { content: sub.content, totalLines: sub.totalLines, language: sub.language };
368
+ state._readCache.set(cacheKey, entry);
369
+ batchResults[fp] = entry;
370
+ }
371
+ return {
372
+ batchResults,
373
+ filesRead: batchPaths.length,
374
+ ...(dedupCount > 0 ? { _deduped: dedupCount, hint: `${dedupCount} 个文件命中缓存,请避免重复读取相同文件。` } : {}),
375
+ };
376
+ }
377
+
292
378
  // 兼容各种参数名变体 (ToolRegistry 层已做 snake→camel 归一化,
293
379
  // 这里兜底处理漏网之鱼)
294
380
  const filePath = params.filePath || params.path || params.file_path || params.filepath || params.file || params.filename;
@@ -296,7 +382,13 @@ const readProjectFile = {
296
382
  const projectRoot = ctx.projectRoot || process.cwd();
297
383
 
298
384
  if (!filePath || typeof filePath !== 'string') {
299
- return { error: '参数错误: 请提供 filePath(相对于项目根目录的文件路径)' };
385
+ return { error: '参数错误: 请提供 filePath(相对于项目根目录的文件路径)或 filePaths 数组' };
386
+ }
387
+
388
+ // ── 单文件去重检查 ──
389
+ const readCacheKey = `${filePath}|${startLine}|${params.endLine || ''}|${maxLines}`;
390
+ if (state._readCache.has(readCacheKey)) {
391
+ return { ...state._readCache.get(readCacheKey), _cached: true, hint: `⚠ 已读取过该文件相同行范围,返回缓存结果。如需其他行范围请指定不同的 startLine/endLine。` };
300
392
  }
301
393
 
302
394
  // 安全检查: 禁止路径遍历
@@ -349,7 +441,7 @@ const readProjectFile = {
349
441
  const langMap = { '.m': 'objectivec', '.mm': 'objectivec', '.h': 'objectivec', '.swift': 'swift', '.js': 'javascript', '.ts': 'typescript', '.py': 'python', '.java': 'java', '.kt': 'kotlin', '.go': 'go', '.rs': 'rust', '.rb': 'ruby' };
350
442
  const language = langMap[ext] || 'unknown';
351
443
 
352
- return {
444
+ const readResult = {
353
445
  filePath,
354
446
  totalLines,
355
447
  startLine: start,
@@ -357,6 +449,11 @@ const readProjectFile = {
357
449
  content: selectedLines.join('\n'),
358
450
  language,
359
451
  };
452
+
453
+ // 缓存读取结果
454
+ state._readCache.set(readCacheKey, { content: readResult.content, totalLines, language });
455
+
456
+ return readResult;
360
457
  },
361
458
  };
362
459
 
@@ -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() {
@@ -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.9.0",
4
4
  "description": "AutoSnippet - 连接开发者、AI 与项目知识库的工具",
5
5
  "type": "module",
6
6
  "main": "lib/bootstrap.js",