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.
- package/README.md +137 -65
- package/bin/api-server.js +5 -0
- package/bin/cli.js +5 -0
- package/dashboard/dist/assets/{icons-Cq4-iQhP.js → icons-B_Xg4B-s.js} +61 -61
- package/dashboard/dist/assets/index-BjfUm8p9.js +197 -0
- package/dashboard/dist/assets/index-CkIih2CC.css +1 -0
- package/dashboard/dist/index.html +3 -3
- package/lib/bootstrap.js +17 -0
- package/lib/cli/SetupService.js +53 -0
- package/lib/external/ai/providers/ClaudeProvider.js +12 -1
- package/lib/external/ai/providers/GoogleGeminiProvider.js +13 -1
- package/lib/external/ai/providers/OpenAiProvider.js +13 -3
- package/lib/external/mcp/McpServer.js +6 -1
- package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +194 -5
- package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +8 -10
- package/lib/external/mcp/handlers/bootstrap.js +8 -0
- package/lib/external/mcp/handlers/skill.js +4 -0
- package/lib/http/routes/ai.js +155 -1
- package/lib/infrastructure/config/Paths.js +3 -0
- package/lib/infrastructure/database/DatabaseConnection.js +6 -1
- package/lib/infrastructure/vector/JsonVectorAdapter.js +2 -0
- package/lib/service/candidate/CandidateFileWriter.js +4 -0
- package/lib/service/chat/AnalystAgent.js +37 -8
- package/lib/service/chat/CandidateGuardrail.js +3 -3
- package/lib/service/chat/ChatAgent.js +20 -1
- package/lib/service/chat/ConversationStore.js +3 -0
- package/lib/service/chat/HandoffProtocol.js +1 -0
- package/lib/service/chat/Memory.js +3 -0
- package/lib/service/chat/ProducerAgent.js +53 -0
- package/lib/service/chat/tools.js +13 -6
- package/lib/service/guard/ExclusionManager.js +2 -0
- package/lib/service/guard/RuleLearner.js +2 -0
- package/lib/service/quality/FeedbackCollector.js +2 -0
- package/lib/service/recipe/RecipeFileWriter.js +4 -0
- package/lib/service/recipe/RecipeStatsTracker.js +2 -0
- package/lib/service/skills/SignalCollector.js +2 -0
- package/lib/shared/PathGuard.js +314 -0
- package/package.json +1 -1
- package/resources/native-ui/combined-window.swift +494 -0
- package/dashboard/dist/assets/index-DBxH7pVn.css +0 -1
- 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 || '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
* 按维度间信息依赖关系分 3 层执行:
|
|
5
5
|
* - Tier 1: 基础数据层 (project-profile, objc-deep-scan, category-scan) — 可并行
|
|
6
|
-
* - Tier 2:
|
|
7
|
-
* - Tier 3:
|
|
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'],
|
|
25
|
-
['code-standard', 'architecture'],
|
|
26
|
-
['
|
|
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=
|
|
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 =
|
|
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');
|
package/lib/http/routes/ai.js
CHANGED
|
@@ -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(
|
|
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
|
}
|