autosnippet 2.7.0 → 2.7.1

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 (41) hide show
  1. package/README.md +137 -65
  2. package/bin/api-server.js +5 -0
  3. package/bin/cli.js +5 -0
  4. package/dashboard/dist/assets/{icons-Cq4-iQhP.js → icons-B_Xg4B-s.js} +61 -61
  5. package/dashboard/dist/assets/index-BjfUm8p9.js +197 -0
  6. package/dashboard/dist/assets/index-CkIih2CC.css +1 -0
  7. package/dashboard/dist/index.html +3 -3
  8. package/lib/bootstrap.js +17 -0
  9. package/lib/cli/SetupService.js +53 -0
  10. package/lib/external/ai/providers/ClaudeProvider.js +12 -1
  11. package/lib/external/ai/providers/GoogleGeminiProvider.js +13 -1
  12. package/lib/external/ai/providers/OpenAiProvider.js +13 -3
  13. package/lib/external/mcp/McpServer.js +6 -1
  14. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +194 -5
  15. package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +8 -10
  16. package/lib/external/mcp/handlers/bootstrap.js +8 -0
  17. package/lib/external/mcp/handlers/skill.js +4 -0
  18. package/lib/http/routes/ai.js +155 -1
  19. package/lib/infrastructure/config/Paths.js +3 -0
  20. package/lib/infrastructure/database/DatabaseConnection.js +6 -1
  21. package/lib/infrastructure/vector/JsonVectorAdapter.js +2 -0
  22. package/lib/service/candidate/CandidateFileWriter.js +4 -0
  23. package/lib/service/chat/AnalystAgent.js +37 -8
  24. package/lib/service/chat/CandidateGuardrail.js +3 -3
  25. package/lib/service/chat/ChatAgent.js +20 -1
  26. package/lib/service/chat/ConversationStore.js +3 -0
  27. package/lib/service/chat/HandoffProtocol.js +1 -0
  28. package/lib/service/chat/Memory.js +3 -0
  29. package/lib/service/chat/ProducerAgent.js +53 -0
  30. package/lib/service/chat/tools.js +13 -6
  31. package/lib/service/guard/ExclusionManager.js +2 -0
  32. package/lib/service/guard/RuleLearner.js +2 -0
  33. package/lib/service/quality/FeedbackCollector.js +2 -0
  34. package/lib/service/recipe/RecipeFileWriter.js +4 -0
  35. package/lib/service/recipe/RecipeStatsTracker.js +2 -0
  36. package/lib/service/skills/SignalCollector.js +2 -0
  37. package/lib/shared/PathGuard.js +314 -0
  38. package/package.json +1 -1
  39. package/resources/native-ui/combined-window.swift +494 -0
  40. package/dashboard/dist/assets/index-DBxH7pVn.css +0 -1
  41. package/dashboard/dist/assets/index-Dw2F6qAS.js +0 -197
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import path from 'node:path';
15
+ import fs from 'node:fs/promises';
15
16
  import { AnalystAgent } from '../../../../../service/chat/AnalystAgent.js';
16
17
  import { ProducerAgent } from '../../../../../service/chat/ProducerAgent.js';
17
18
  import { TierScheduler } from './tier-scheduler.js';
@@ -20,6 +21,69 @@ import Logger from '../../../../../infrastructure/logging/Logger.js';
20
21
 
21
22
  const logger = Logger.getInstance();
22
23
 
24
+ // ──────────────────────────────────────────────────────────────────
25
+ // P3: 断点续传 — Checkpoint 存储/恢复
26
+ // ──────────────────────────────────────────────────────────────────
27
+
28
+ const CHECKPOINT_TTL_MS = 3600_000; // 1小时内有效
29
+
30
+ /**
31
+ * 保存维度级 checkpoint
32
+ * @param {string} projectRoot
33
+ * @param {string} sessionId
34
+ * @param {string} dimId
35
+ * @param {object} result — 维度执行结果
36
+ * @param {object} [digest] — DimensionDigest
37
+ */
38
+ async function saveDimensionCheckpoint(projectRoot, sessionId, dimId, result, digest = null) {
39
+ try {
40
+ const checkpointDir = path.join(projectRoot, '.autosnippet', 'bootstrap-checkpoint');
41
+ await fs.mkdir(checkpointDir, { recursive: true });
42
+ await fs.writeFile(
43
+ path.join(checkpointDir, `${dimId}.json`),
44
+ JSON.stringify({ dimId, sessionId, ...result, digest, completedAt: Date.now() }),
45
+ );
46
+ } catch (err) {
47
+ logger.warn(`[Bootstrap-v3] checkpoint save failed for "${dimId}": ${err.message}`);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * 加载有效的 checkpoints
53
+ * @param {string} projectRoot
54
+ * @returns {Promise<Map<string, object>>} dimId → checkpoint data
55
+ */
56
+ async function loadCheckpoints(projectRoot) {
57
+ const checkpoints = new Map();
58
+ try {
59
+ const checkpointDir = path.join(projectRoot, '.autosnippet', 'bootstrap-checkpoint');
60
+ const files = await fs.readdir(checkpointDir).catch(() => []);
61
+ const now = Date.now();
62
+ for (const file of files) {
63
+ if (!file.endsWith('.json')) continue;
64
+ try {
65
+ const content = await fs.readFile(path.join(checkpointDir, file), 'utf-8');
66
+ const data = JSON.parse(content);
67
+ if (data.completedAt && (now - data.completedAt) < CHECKPOINT_TTL_MS) {
68
+ checkpoints.set(data.dimId, data);
69
+ }
70
+ } catch { /* skip corrupt checkpoint */ }
71
+ }
72
+ } catch { /* checkpoint dir doesn't exist */ }
73
+ return checkpoints;
74
+ }
75
+
76
+ /**
77
+ * 清理 checkpoint 目录
78
+ * @param {string} projectRoot
79
+ */
80
+ async function clearCheckpoints(projectRoot) {
81
+ try {
82
+ const checkpointDir = path.join(projectRoot, '.autosnippet', 'bootstrap-checkpoint');
83
+ await fs.rm(checkpointDir, { recursive: true, force: true });
84
+ } catch { /* ignore */ }
85
+ }
86
+
23
87
  // ──────────────────────────────────────────────────────────────────
24
88
  // v3.0 维度配置 (增加 focusAreas 用于 Analyst prompt)
25
89
  // ──────────────────────────────────────────────────────────────────
@@ -222,7 +286,7 @@ export async function fillDimensionsV3(fillContext) {
222
286
  // ═══════════════════════════════════════════════════════════
223
287
  // Step 2: 按维度分层执行 (Analyst → Gate → Producer)
224
288
  // ═══════════════════════════════════════════════════════════
225
- const concurrency = parseInt(process.env.ASD_PARALLEL_CONCURRENCY || '2', 10);
289
+ const concurrency = parseInt(process.env.ASD_PARALLEL_CONCURRENCY || '3', 10);
226
290
  const enableParallel = process.env.ASD_PARALLEL_BOOTSTRAP !== 'false';
227
291
  const scheduler = new TierScheduler();
228
292
 
@@ -233,13 +297,52 @@ export async function fillDimensionsV3(fillContext) {
233
297
 
234
298
  logger.info(`[Bootstrap-v3] Active dimensions: [${activeDimIds.join(', ')}], concurrency=${enableParallel ? concurrency : 1}`);
235
299
 
300
+ // ── P3: 断点续传 — 加载有效 checkpoints ──
301
+ const completedCheckpoints = await loadCheckpoints(projectRoot);
302
+ const skippedDims = [];
303
+ for (const [dimId, checkpoint] of completedCheckpoints) {
304
+ if (activeDimIds.includes(dimId)) {
305
+ // 恢复 DimensionContext 中的 digest
306
+ if (checkpoint.digest) {
307
+ dimContext.addDimensionDigest(dimId, checkpoint.digest);
308
+ }
309
+ taskManager?.markTaskCompleted(dimId, {
310
+ type: 'checkpoint-restored',
311
+ ...checkpoint,
312
+ });
313
+ skippedDims.push(dimId);
314
+ logger.info(`[Bootstrap-v3] ⏩ 跳过已完成维度 (checkpoint): "${dimId}"`);
315
+ }
316
+ }
317
+
236
318
  const candidateResults = { created: 0, failed: 0, errors: [] };
237
319
  const dimensionCandidates = {};
320
+ const dimensionStats = {}; // P4.2: 维度级统计
238
321
 
239
322
  /**
240
323
  * 执行单个维度: Analyst → Gate → Producer
241
324
  */
242
325
  async function executeDimension(dimId) {
326
+ // P3: 跳过已有 checkpoint 的维度
327
+ if (skippedDims.includes(dimId)) {
328
+ const cp = completedCheckpoints.get(dimId);
329
+ const cpResult = {
330
+ candidateCount: cp?.candidateCount || 0,
331
+ rejectedCount: cp?.rejectedCount || 0,
332
+ analysisChars: cp?.analysisChars || 0,
333
+ referencedFiles: cp?.referencedFiles || 0,
334
+ durationMs: cp?.durationMs || 0,
335
+ toolCallCount: cp?.toolCallCount || 0,
336
+ tokenUsage: cp?.tokenUsage || { input: 0, output: 0 },
337
+ skipped: true,
338
+ restoredFromCheckpoint: true,
339
+ };
340
+ // P4.2: 将恢复的维度也记入统计
341
+ dimensionStats[dimId] = cpResult;
342
+ candidateResults.created += cpResult.candidateCount;
343
+ return cpResult;
344
+ }
345
+
243
346
  const dim = dimensions.find(d => d.id === dimId);
244
347
  const v3Config = DIMENSION_CONFIGS_V3[dimId];
245
348
  if (!dim || !v3Config) {
@@ -270,7 +373,7 @@ export async function fillDimensionsV3(fillContext) {
270
373
  try {
271
374
  // ── Phase 1: Analyst ──
272
375
  const analysisReport = await Promise.race([
273
- analystAgent.analyze(dimConfig, projectInfo, { sessionId }),
376
+ analystAgent.analyze(dimConfig, projectInfo, { sessionId, dimensionContext: dimContext }),
274
377
  new Promise((_, reject) =>
275
378
  setTimeout(() => reject(new Error(`Analyst timeout for "${dimId}"`)), 180_000)),
276
379
  ]);
@@ -333,13 +436,32 @@ export async function fillDimensionsV3(fillContext) {
333
436
  toolCallCount: (analysisReport.metadata?.toolCallCount || 0) + (producerResult.toolCalls?.length || 0),
334
437
  });
335
438
 
336
- return {
439
+ // P4.1: 聚合 token 用量
440
+ const analystTokens = analysisReport.metadata?.tokenUsage || { input: 0, output: 0 };
441
+ const producerTokens = producerResult.tokenUsage || { input: 0, output: 0 };
442
+ const dimTokenUsage = {
443
+ input: (analystTokens.input || 0) + (producerTokens.input || 0),
444
+ output: (analystTokens.output || 0) + (producerTokens.output || 0),
445
+ };
446
+
447
+ const dimResult = {
337
448
  candidateCount: producerResult.candidateCount,
449
+ rejectedCount: producerResult.rejectedCount || 0,
338
450
  analysisChars: analysisReport.analysisText.length,
339
451
  referencedFiles: analysisReport.referencedFiles.length,
340
452
  durationMs: Date.now() - dimStartTime,
453
+ toolCallCount: (analysisReport.metadata?.toolCallCount || 0) + (producerResult.toolCalls?.length || 0),
454
+ tokenUsage: dimTokenUsage,
341
455
  };
342
456
 
457
+ // P4.2: 记录维度统计
458
+ dimensionStats[dimId] = dimResult;
459
+
460
+ // P3: 保存 checkpoint
461
+ await saveDimensionCheckpoint(projectRoot, sessionId, dimId, dimResult, digest);
462
+
463
+ return dimResult;
464
+
343
465
  } catch (err) {
344
466
  logger.error(`[Bootstrap-v3] Dimension "${dimId}" failed: ${err.message}`);
345
467
  candidateResults.errors.push({ dimId, error: err.message });
@@ -449,16 +571,83 @@ export async function fillDimensionsV3(fillContext) {
449
571
  }
450
572
 
451
573
  // ═══════════════════════════════════════════════════════════
452
- // Summary
574
+ // Summary + P4.2: Bootstrap Report
453
575
  // ═══════════════════════════════════════════════════════════
454
576
  const totalTimeMs = Date.now() - t0;
577
+
578
+ // P4.1: 汇总所有维度 token 用量
579
+ const totalTokenUsage = { input: 0, output: 0 };
580
+ const totalToolCalls = Object.values(dimensionStats).reduce((sum, s) => sum + (s.toolCallCount || 0), 0);
581
+ for (const stat of Object.values(dimensionStats)) {
582
+ if (stat.tokenUsage) {
583
+ totalTokenUsage.input += stat.tokenUsage.input || 0;
584
+ totalTokenUsage.output += stat.tokenUsage.output || 0;
585
+ }
586
+ }
587
+
455
588
  logger.info([
456
589
  `[Bootstrap-v3] ═══ Pipeline complete ═══`,
457
590
  ` Candidates: ${candidateResults.created} created, ${candidateResults.errors.length} errors`,
458
591
  ` Skills: ${skillResults.created} created, ${skillResults.failed} failed`,
459
592
  ` Time: ${totalTimeMs}ms (${(totalTimeMs / 1000).toFixed(1)}s)`,
460
593
  ` Mode: ${enableParallel ? `parallel (concurrency=${concurrency})` : 'serial'}`,
461
- ].join('\n'));
594
+ ` Tokens: input=${totalTokenUsage.input}, output=${totalTokenUsage.output}`,
595
+ ` Tool calls: ${totalToolCalls}`,
596
+ skippedDims.length > 0 ? ` Checkpoints restored: [${skippedDims.join(', ')}]` : '',
597
+ ].filter(Boolean).join('\n'));
598
+
599
+ // P4.2: 生成冷启动报告
600
+ try {
601
+ const report = {
602
+ version: '2.7.0',
603
+ timestamp: new Date().toISOString(),
604
+ project: {
605
+ name: projectInfo.name,
606
+ files: projectInfo.fileCount,
607
+ lang: projectInfo.lang,
608
+ },
609
+ duration: {
610
+ totalMs: totalTimeMs,
611
+ totalSec: Math.round(totalTimeMs / 1000),
612
+ },
613
+ dimensions: {},
614
+ totals: {
615
+ candidates: candidateResults.created,
616
+ skills: skillResults.created,
617
+ toolCalls: totalToolCalls,
618
+ tokenUsage: totalTokenUsage,
619
+ errors: candidateResults.errors.length,
620
+ },
621
+ checkpoints: {
622
+ restored: skippedDims,
623
+ },
624
+ };
625
+
626
+ for (const [dimId, stat] of Object.entries(dimensionStats)) {
627
+ report.dimensions[dimId] = {
628
+ candidatesSubmitted: stat.candidateCount || 0,
629
+ candidatesRejected: stat.rejectedCount || 0,
630
+ analysisChars: stat.analysisChars || 0,
631
+ referencedFiles: stat.referencedFiles || 0,
632
+ durationMs: stat.durationMs || 0,
633
+ toolCallCount: stat.toolCallCount || 0,
634
+ tokenUsage: stat.tokenUsage || { input: 0, output: 0 },
635
+ };
636
+ }
637
+
638
+ const reportDir = path.join(projectRoot, '.autosnippet');
639
+ await fs.mkdir(reportDir, { recursive: true });
640
+ await fs.writeFile(
641
+ path.join(reportDir, 'bootstrap-report.json'),
642
+ JSON.stringify(report, null, 2),
643
+ );
644
+ logger.info(`[Bootstrap-v3] 📊 Bootstrap report saved to .autosnippet/bootstrap-report.json`);
645
+ } catch (reportErr) {
646
+ logger.warn(`[Bootstrap-v3] Bootstrap report generation failed: ${reportErr.message}`);
647
+ }
648
+
649
+ // P3: 成功完成后清理 checkpoints
650
+ await clearCheckpoints(projectRoot);
462
651
 
463
652
  // 释放文件缓存
464
653
  allFiles = null;
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * TierScheduler.js — 维度分层并行调度器
3
3
  *
4
- * 按维度间信息依赖关系分 4 层执行:
4
+ * 按维度间信息依赖关系分 3 层执行:
5
5
  * - Tier 1: 基础数据层 (project-profile, objc-deep-scan, category-scan) — 可并行
6
- * - Tier 2: 规范+架构 (code-standard, architecture) — 依赖 Tier 1
7
- * - Tier 3: 模式+流转 (code-pattern, event-and-data-flow) — 依赖 Tier 2
8
- * - Tier 4: 总结层 (best-practice, agent-guidelines) — 依赖全部
6
+ * - Tier 2: 规范+架构+模式 (code-standard, architecture, code-pattern) — 依赖 Tier 1
7
+ * - Tier 3: 流转+实践+总结 (event-and-data-flow, best-practice, agent-guidelines) — 依赖 Tier 2
9
8
  *
10
9
  * 每层内部可并行 (受 concurrency 限制),层间串行。
11
10
  *
@@ -21,10 +20,9 @@ const logger = Logger.getInstance();
21
20
  // ──────────────────────────────────────────────────────────────────
22
21
 
23
22
  const DEFAULT_TIERS = [
24
- ['project-profile', 'objc-deep-scan', 'category-scan'], // Tier 1: 基础数据
25
- ['code-standard', 'architecture'], // Tier 2: 规范+架构
26
- ['code-pattern', 'event-and-data-flow'], // Tier 3: 模式+流转
27
- ['best-practice', 'agent-guidelines'], // Tier 4: 总结
23
+ ['project-profile', 'objc-deep-scan', 'category-scan'], // Tier 1: 基础数据
24
+ ['code-standard', 'architecture', 'code-pattern'], // Tier 2: 规范+架构+模式
25
+ ['event-and-data-flow', 'best-practice', 'agent-guidelines'], // Tier 3: 流转+实践+总结
28
26
  ];
29
27
 
30
28
  // ──────────────────────────────────────────────────────────────────
@@ -79,13 +77,13 @@ export class TierScheduler {
79
77
  *
80
78
  * @param {Function} executeDimension — async (dimId) => DimensionResult
81
79
  * @param {object} [options]
82
- * @param {number} [options.concurrency=2] — Tier 内最大并行数
80
+ * @param {number} [options.concurrency=3] — Tier 内最大并行数
83
81
  * @param {Function} [options.onTierComplete] — (tierIndex, tierResults) => void
84
82
  * @param {Function} [options.shouldAbort] — () => boolean — 外部中止信号
85
83
  * @returns {Promise<Map<string, any>>} — dimId → result
86
84
  */
87
85
  async execute(executeDimension, options = {}) {
88
- const { concurrency = 2, onTierComplete, shouldAbort } = options;
86
+ const { concurrency = 3, onTierComplete, shouldAbort } = options;
89
87
  const results = new Map();
90
88
 
91
89
  for (let tierIndex = 0; tierIndex < this.#tiers.length; tierIndex++) {
@@ -41,6 +41,7 @@ import { envelope } from '../envelope.js';
41
41
  import { inferLang, detectPrimaryLanguage, buildLanguageExtension } from './LanguageExtensions.js';
42
42
  import { inferTargetRole, inferFilePriority } from './TargetClassifier.js';
43
43
  import { analyzeProject, generateContextForAgent, isAvailable as astIsAvailable } from '../../../core/AstAnalyzer.js';
44
+ import pathGuard from '../../../shared/PathGuard.js';
44
45
 
45
46
  // ── Sub-modules ──
46
47
  import { loadBootstrapSkills, extractSkillDimensionGuides, enhanceDimensions } from './bootstrap/skills.js';
@@ -67,6 +68,13 @@ export { loadBootstrapSkills };
67
68
  export async function bootstrapKnowledge(ctx, args) {
68
69
  const t0 = Date.now();
69
70
  const projectRoot = process.env.ASD_PROJECT_DIR || process.cwd();
71
+
72
+ // 路径安全守卫 — 确保所有写操作限制在项目目录内
73
+ if (!pathGuard.configured) {
74
+ const { default: Bootstrap } = await import('../../../bootstrap.js');
75
+ Bootstrap.configurePathGuard(projectRoot);
76
+ }
77
+
70
78
  const maxFiles = args.maxFiles || 500;
71
79
  const skipGuard = args.skipGuard || false;
72
80
  const contentMaxLines = args.contentMaxLines || 120;
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { getProjectSkillsPath } from '../../../infrastructure/config/Paths.js';
17
+ import pathGuard from '../../../shared/PathGuard.js';
17
18
 
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
  const SKILLS_DIR = path.resolve(__dirname, '../../../../skills');
@@ -282,6 +283,8 @@ export function createSkill(_ctx, args) {
282
283
 
283
284
  // ── 写入 SKILL.md ──
284
285
  try {
286
+ // 路径安全检查 — name 来自用户输入,可能含路径字符
287
+ pathGuard.assertProjectWriteSafe(skillDir);
285
288
  fs.mkdirSync(skillDir, { recursive: true });
286
289
 
287
290
  // 自动推断 title: 优先使用传入参数,否则从 content 的第一个 # heading 提取
@@ -385,6 +388,7 @@ function _regenerateEditorIndex() {
385
388
  ].join('\n');
386
389
 
387
390
  // 写入 .cursor/rules/
391
+ pathGuard.assertProjectWriteSafe(rulesDir);
388
392
  fs.mkdirSync(rulesDir, { recursive: true });
389
393
  const indexPath = path.join(rulesDir, 'autosnippet-skills.mdc');
390
394
  fs.writeFileSync(indexPath, mdcContent, 'utf8');
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * AI API 路由
3
- * AI 提供商管理、摘要、翻译、对话
3
+ * AI 提供商管理、摘要、翻译、对话、.env LLM 配置
4
4
  */
5
5
 
6
6
  import express from 'express';
7
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
7
9
  import { asyncHandler } from '../middleware/errorHandler.js';
8
10
  import { getServiceContainer } from '../../injection/ServiceContainer.js';
9
11
  import { createProvider } from '../../external/ai/AiFactory.js';
@@ -244,4 +246,156 @@ router.post('/format-usage-guide', asyncHandler(async (req, res) => {
244
246
  res.json({ success: true, data: { formatted } });
245
247
  }));
246
248
 
249
+ // ═══════════════════════════════════════════════════════
250
+ // .env LLM 配置读写
251
+ // ═══════════════════════════════════════════════════════
252
+
253
+ /** 获取用户项目目录下 .env 的路径 */
254
+ function _getProjectEnvPath() {
255
+ const container = getServiceContainer();
256
+ const projectRoot = container.singletons?._projectRoot || process.env.ASD_PROJECT_DIR || process.cwd();
257
+ return join(projectRoot, '.env');
258
+ }
259
+
260
+ /** LLM 相关的 env 变量名 → 标签映射 */
261
+ const LLM_ENV_KEYS = [
262
+ 'ASD_AI_PROVIDER',
263
+ 'ASD_AI_MODEL',
264
+ 'ASD_GOOGLE_API_KEY',
265
+ 'ASD_OPENAI_API_KEY',
266
+ 'ASD_CLAUDE_API_KEY',
267
+ 'ASD_DEEPSEEK_API_KEY',
268
+ 'ASD_AI_PROXY',
269
+ ];
270
+
271
+ /**
272
+ * 解析 .env 内容为 key-value(仅提取 LLM 相关变量)
273
+ * 返回 { vars, hasEnvFile, llmReady }
274
+ * llmReady: provider + 至少一个对应 API Key 已配置
275
+ */
276
+ function parseLlmEnv(envPath) {
277
+ if (!existsSync(envPath)) {
278
+ return { vars: {}, hasEnvFile: false, llmReady: false };
279
+ }
280
+
281
+ const raw = readFileSync(envPath, 'utf8');
282
+ const vars = {};
283
+
284
+ for (const line of raw.split('\n')) {
285
+ const trimmed = line.trim();
286
+ // 跳过注释和空行
287
+ if (!trimmed || trimmed.startsWith('#')) continue;
288
+ const eqIdx = trimmed.indexOf('=');
289
+ if (eqIdx === -1) continue;
290
+ const key = trimmed.slice(0, eqIdx).trim();
291
+ const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
292
+ if (LLM_ENV_KEYS.includes(key)) {
293
+ vars[key] = val;
294
+ }
295
+ }
296
+
297
+ // 判断 LLM 是否可用:有 provider + 对应的 API Key
298
+ const provider = vars.ASD_AI_PROVIDER || '';
299
+ const keyMap = {
300
+ google: 'ASD_GOOGLE_API_KEY',
301
+ openai: 'ASD_OPENAI_API_KEY',
302
+ claude: 'ASD_CLAUDE_API_KEY',
303
+ deepseek: 'ASD_DEEPSEEK_API_KEY',
304
+ ollama: '', // ollama 不需要 key
305
+ mock: '', // mock 不需要 key
306
+ };
307
+ const neededKey = keyMap[provider] || '';
308
+ const llmReady = !!provider && (!neededKey || !!vars[neededKey]);
309
+
310
+ return { vars, hasEnvFile: true, llmReady };
311
+ }
312
+
313
+ /**
314
+ * GET /api/v1/ai/env-config
315
+ * 读取用户项目 .env 中的 LLM 配置
316
+ */
317
+ router.get('/env-config', asyncHandler(async (req, res) => {
318
+ const envPath = _getProjectEnvPath();
319
+ const result = parseLlmEnv(envPath);
320
+ res.json({ success: true, data: result });
321
+ }));
322
+
323
+ /**
324
+ * POST /api/v1/ai/env-config
325
+ * 写入 / 更新用户项目 .env 中的 LLM 配置
326
+ *
327
+ * Body: { provider, model, apiKey, proxy? }
328
+ */
329
+ router.post('/env-config', asyncHandler(async (req, res) => {
330
+ const { provider, model, apiKey, proxy } = req.body;
331
+ if (!provider || typeof provider !== 'string') {
332
+ throw new ValidationError('provider is required');
333
+ }
334
+
335
+ const envPath = _getProjectEnvPath();
336
+ let content = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
337
+
338
+ // 构建 key-value 更新列表
339
+ const updates = {
340
+ ASD_AI_PROVIDER: provider,
341
+ };
342
+ if (model) updates.ASD_AI_MODEL = model;
343
+ if (proxy) updates.ASD_AI_PROXY = proxy;
344
+
345
+ // 根据 provider 决定写入哪个 API Key 变量
346
+ const providerKeyMap = {
347
+ google: 'ASD_GOOGLE_API_KEY',
348
+ openai: 'ASD_OPENAI_API_KEY',
349
+ claude: 'ASD_CLAUDE_API_KEY',
350
+ deepseek: 'ASD_DEEPSEEK_API_KEY',
351
+ };
352
+ const keyName = providerKeyMap[provider];
353
+ if (keyName && apiKey) {
354
+ updates[keyName] = apiKey;
355
+ }
356
+
357
+ // 逐条合并到 .env 内容
358
+ for (const [k, v] of Object.entries(updates)) {
359
+ // 匹配已有行(包括被注释的行)
360
+ const activeRe = new RegExp(`^${k}\\s*=.*$`, 'm');
361
+ const commentedRe = new RegExp(`^#\\s*${k}\\s*=.*$`, 'm');
362
+
363
+ if (activeRe.test(content)) {
364
+ // 替换已有活动行
365
+ content = content.replace(activeRe, `${k}=${v}`);
366
+ } else if (commentedRe.test(content)) {
367
+ // 取消注释并赋值
368
+ content = content.replace(commentedRe, `${k}=${v}`);
369
+ } else {
370
+ // 追加到末尾
371
+ if (!content.endsWith('\n')) content += '\n';
372
+ content += `${k}=${v}\n`;
373
+ }
374
+ }
375
+
376
+ writeFileSync(envPath, content);
377
+ logger.info('LLM env config updated', { provider, model });
378
+
379
+ // 同步到当前进程环境变量(热生效)
380
+ for (const [k, v] of Object.entries(updates)) {
381
+ process.env[k] = v;
382
+ }
383
+
384
+ // 尝试热切换 AI Provider
385
+ try {
386
+ const newProvider = createProvider({
387
+ provider: provider.toLowerCase(),
388
+ model: model || undefined,
389
+ });
390
+ const container = getServiceContainer();
391
+ container.singletons.aiProvider = newProvider;
392
+ logger.info('AI provider hot-swapped after env update', { provider, model: newProvider.model });
393
+ } catch (err) {
394
+ logger.debug('Hot-swap AI provider failed (will take effect on restart)', { error: err.message });
395
+ }
396
+
397
+ const result = parseLlmEnv(envPath);
398
+ res.json({ success: true, data: result });
399
+ }));
400
+
247
401
  export default router;
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
+ import pathGuard from '../../shared/PathGuard.js';
3
4
 
4
5
  /**
5
6
  * Paths — 项目路径解析工具
@@ -14,6 +15,8 @@ const USER_HOME = process.env.HOME || process.env.USERPROFILE || '';
14
15
  /** 确保目录存在(静默处理异常) */
15
16
  function ensureDir(dirPath) {
16
17
  try {
18
+ // 双层路径安全检查 — 阻止在项目允许范围外创建文件夹
19
+ pathGuard.assertProjectWriteSafe(dirPath);
17
20
  if (!fs.existsSync(dirPath)) {
18
21
  fs.mkdirSync(dirPath, { recursive: true });
19
22
  }
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
+ import pathGuard from '../../shared/PathGuard.js';
5
6
 
6
7
  const __filename = fileURLToPath(import.meta.url);
7
8
  const __dirname = path.dirname(__filename);
@@ -20,9 +21,13 @@ export class DatabaseConnection {
20
21
  */
21
22
  async connect() {
22
23
  const dbPath = this.config.path;
24
+
25
+ // 路径安全检查 — 防止 DB 文件创建到项目允许范围外
26
+ const resolvedDbPath = path.resolve(dbPath);
27
+ pathGuard.assertProjectWriteSafe(resolvedDbPath);
23
28
 
24
29
  // 确保数据目录存在
25
- const dbDir = path.dirname(dbPath);
30
+ const dbDir = path.dirname(resolvedDbPath);
26
31
  if (!fs.existsSync(dbDir)) {
27
32
  fs.mkdirSync(dbDir, { recursive: true });
28
33
  }
@@ -7,6 +7,7 @@
7
7
  import { VectorStore } from './VectorStore.js';
8
8
  import { writeFileSync, readFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
9
9
  import { join, dirname } from 'node:path';
10
+ import pathGuard from '../../shared/PathGuard.js';
10
11
 
11
12
  export class JsonVectorAdapter extends VectorStore {
12
13
  #indexPath;
@@ -238,6 +239,7 @@ export class JsonVectorAdapter extends VectorStore {
238
239
  if (!this.#dirty) return;
239
240
  try {
240
241
  const dir = dirname(this.#indexPath);
242
+ pathGuard.assertProjectWriteSafe(dir);
241
243
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
242
244
  const items = [...this.#data.values()];
243
245
  writeFileSync(this.#indexPath, JSON.stringify(items, null, 2));
@@ -19,6 +19,7 @@ import path from 'node:path';
19
19
  import { createHash } from 'node:crypto';
20
20
  import { CANDIDATES_DIR } from '../../infrastructure/config/Defaults.js';
21
21
  import Logger from '../../infrastructure/logging/Logger.js';
22
+ import pathGuard from '../../shared/PathGuard.js';
22
23
 
23
24
  export { CANDIDATES_DIR };
24
25
 
@@ -175,6 +176,9 @@ export class CandidateFileWriter {
175
176
  const category = (candidate.category || 'general').toLowerCase();
176
177
  const categoryDir = path.join(this.candidatesDir, category);
177
178
 
179
+ // 路径安全检查 — 阻止 category 含 ../ 导致路径逃逸
180
+ pathGuard.assertProjectWriteSafe(categoryDir);
181
+
178
182
  if (!fs.existsSync(categoryDir)) {
179
183
  fs.mkdirSync(categoryDir, { recursive: true });
180
184
  }